From 5cfc765ff6f2d1a99588f33a95bd4c4501e5771a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 12:11:13 +0800 Subject: [PATCH 01/60] docs(session): deprecate direct event readers --- docs/subsystems/session.i18n.yaml | 4 ++-- docs/subsystems/session.md | 3 +++ docs/subsystems/session.zh.md | 3 +++ packages/core/session/README.i18n.yaml | 4 ++-- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/index.ts | 3 +++ 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 26fee2e323..4d4f5bc022 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: de0930ea7effcba69bc1f9a4dd405ceda23919d7 -session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b +session.md: 077e90c66abe3b5f3f24d14c3fb25f356d887fa8 +session.zh.md: 6b9ceb85a276b0221c1e6ff474fbcdea77dff1fd diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index de0930ea7e..077e90c66a 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -473,6 +473,7 @@ declare class Session { ): Session; /** * Return the immutable event stored at one exact sequence number. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -481,6 +482,7 @@ declare class Session { * 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. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @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. @@ -491,6 +493,7 @@ declare class Session { ): readonly SessionEvent[]; /** * Return this Session's events after its fork-inherited prefix. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[]; diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index ec15dbdec3..6b9ceb85a2 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -475,6 +475,7 @@ declare class Session { ): Session; /** * Return the immutable event stored at one exact sequence number. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -483,6 +484,7 @@ declare class Session { * 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. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @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. @@ -493,6 +495,7 @@ declare class Session { ): readonly SessionEvent[]; /** * Return this Session's events after its fork-inherited prefix. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[]; diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index fd052eb9f9..ed9b39bdff 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: a06b22b5e8f48743047c68e883edd496fbc6eb77 -README.zh.md: 755894b4c30f0a3807f472cece32685d102efbeb +README.md: 402f3536402d78d0a70135290092cd7086706fb2 +README.zh.md: 303af9a878909f99e20dec1052b6592da7c8c415 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a06b22b5e8..402f353640 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -55,7 +55,7 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and ### 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()`. +`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. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new calls are prohibited. Callers that only need a length use `seq`. Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 755894b4c3..303af9a878 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -55,7 +55,7 @@ session.deriveMessages() // the derived model history ### 读取日志 -`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增调用。只需要长度的调用方使用 `seq`。 会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,品牌在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9db8327409..68577578dd 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -615,6 +615,7 @@ export class Session { /** * Return the immutable event stored at one exact sequence number. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -626,6 +627,7 @@ export class Session { * 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. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @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. @@ -643,6 +645,7 @@ export class Session { /** * Return this Session's events after its fork-inherited prefix. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[] { From aa491acc2939b6bfe87aef70290af4058fa069b2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 12:20:38 +0800 Subject: [PATCH 02/60] docs(session): record synchronous history read deprecation policy --- ...ev-invariants-over-deep-readonly.i18n.yaml | 4 +- ...06-11-dev-invariants-over-deep-readonly.md | 4 +- ...11-dev-invariants-over-deep-readonly.zh.md | 4 +- ...-synchronous-session-event-reads.i18n.yaml | 6 +++ ...precate-synchronous-session-event-reads.md | 45 +++++++++++++++++++ ...cate-synchronous-session-event-reads.zh.md | 45 +++++++++++++++++++ ...2026-07-06-recallable-compaction.i18n.yaml | 4 +- .../2026-07-06-recallable-compaction.md | 4 +- .../2026-07-06-recallable-compaction.zh.md | 4 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- 12 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md 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 5b233e16db..6578c0b2a2 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: 9adb741db1e26e3a9f750fed60524351159cfeaa -2026-06-11-dev-invariants-over-deep-readonly.zh.md: d352eca985b6594ec5d10c9b73c2ecc18ea458b5 +2026-06-11-dev-invariants-over-deep-readonly.md: 0784960397f4d198e136e284ef9232b4ed3e2c2c +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 7183813130560bf82426c8719570b668ba86b7e5 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 9adb741db1..0784960397 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, 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. +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` reads the current length without materializing an array. Synchronous historical readers are deprecated under the [event-read policy](2026-09-09-deprecate-synchronous-session-event-reads.md). 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. @@ -53,7 +53,7 @@ Detaching `deriveMessages()` would protect the most common request path but leav ## Consequences - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. -- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array. +- Existing `snapshotEvents()` and `eventAt()` callers retain immutable read results while their migration is deferred; `seq` reads the log length without copying the 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 d352eca985..7183813130 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` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。`seq` 无需物化数组即可读取当前长度。同步历史读取方法按[事件读取策略](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)弃用。种子记录在构造成功前经过相同的验证、快照与冻结边界。 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 @@ -53,7 +53,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ## 后果 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 -- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径。 +- 现有 `snapshotEvents()` 和 `eventAt()` 调用方在暂缓迁移期间仍获得不可变的读取结果;`seq` 无需复制数组即可读取日志长度。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml new file mode 100644 index 0000000000..6af464d03b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.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-09-09-deprecate-synchronous-session-event-reads.md +2026-09-09-deprecate-synchronous-session-event-reads.md: b0bc1e81ada1781587b7bd6b5687d62bc0d8e798 +2026-09-09-deprecate-synchronous-session-event-reads.zh.md: 0c8fd24bb48e6a40309fe571fce5d2fba5b99b2b diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md new file mode 100644 index 0000000000..b0bc1e81ad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md @@ -0,0 +1,45 @@ +# Agent Note: Deprecate synchronous reads of arbitrary Session events + +Status: implemented + +English | [中文](2026-09-09-deprecate-synchronous-session-event-reads.zh.md) + +## Problem + +Synchronous access to arbitrary event positions makes consumers depend on the complete Session event sequence being immediately available in memory. The storage direction is to stop retaining that complete sequence in memory. Once historical events require storage I/O, the runtime cannot preserve the same synchronous read guarantee without retaining the history or blocking on storage. + +New callers increase that dependency even when they read only one old event. Repeated history scans after resume also make ordinary domain logic depend on historical storage instead of the state it actually needs. + +## Decision + +All operations that synchronously read arbitrary positions or ranges of Session event history are deprecated, including `Session.eventAt()`, `Session.snapshotEvents()`, and `Session.ownEvents()`. Existing logic may remain unmigrated for now, but new calls are prohibited. New aliases or wrappers that expose the same synchronous historical access are prohibited as well. + +The three methods carry this rule in `@deprecated` JSDoc. This is an API-use decision; the current Session implementation still retains the complete event sequence in memory. + +### State needed after resume + +Design durable event fields and Session projections together so each domain can reconstruct the state its consumers need. Restore that state during resume, then maintain it incrementally from newly committed events. After resume, ordinary logic reads the projection or processes the delivered current event instead of looking back through historical events. Reading already-maintained projection state synchronously does not require arbitrary access to the event log. + +Historical content presented on demand uses explicit asynchronous pagination and progressive loading, with each read limited to the requested window. Loading the complete sequence behind a synchronous helper preserves the dependency this decision removes. + +### Operations that require complete history + +Fork and a small number of operations may genuinely need a complete historical sequence or inherited prefix. Their need for those records remains valid and requires an explicit storage read. It does not require the whole sequence to remain resident or grant an exception for new calls to deprecated synchronous readers. Each such consumer must establish why its result requires the complete sequence rather than projected state or a limited historical window. + +## Alternatives considered + +**Keep synchronous single-event reads while deprecating only full snapshots.** A single requested event may also be absent from memory. Restricting the result size does not remove the storage dependency, and helpers such as `ownEvents()` retain the same assumption for a suffix. + +**Keep the complete sequence resident to preserve the read APIs.** This lets consumer convenience dictate Session memory retention and prevents the intended storage design. Projections preserve required state, while explicit historical reads preserve access to the records themselves. + +**Require every existing caller to migrate immediately.** Existing logic may defer migration under this decision. Preventing new dependencies bounds the remaining work without making every existing consumer part of the same change. + +## Consequences + +New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log. + +The current `typescript/no-deprecated` lint rule reports both existing and new calls. The JSDoc records the migration deferral, but lint does not yet distinguish permitted existing callers from prohibited additions. The documentation checks verify the source-equivalent API declarations and bilingual records; they do not enforce that distinction. + +## Related decisions + +The [session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md) continues to own event ownership and freezing; this decision supersedes its recommendation to use synchronous historical readers. The [required projection reader decision](2026-08-19-session-projection-mandatory-seam.md) continues to own missing-state failures and typed state reads. The [recallable compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) retains its recall design while replacing its proposed synchronous history access with paged reads. diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md new file mode 100644 index 0000000000..0c8fd24bb4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 弃用对会话任意位置事件的同步读取 + +Status: implemented + +[English](2026-09-09-deprecate-synchronous-session-event-reads.md) | 中文 + +## 问题 + +同步访问任意事件位置,使消费方依赖完整会话事件序列在内存中随时可用。存储的演进方向是不再将完整序列保存在内存中。历史事件一旦需要存储 I/O,运行时若不继续保留历史或阻塞等待存储,就无法维持相同的同步读取保证。 + +即使只读取一个旧事件,新增调用也会加深这种依赖。恢复后反复扫描历史,还会使普通领域逻辑依赖历史存储,而不是它真正需要的状态。 + +## 决策 + +弃用所有同步读取会话事件历史任意位置或区间的操作,包括 `Session.eventAt()`、`Session.snapshotEvents()` 和 `Session.ownEvents()`。现有逻辑可以暂不迁移,但禁止新增调用。同样禁止新增暴露相同同步历史访问能力的别名或包装层。 + +这三个方法通过 `@deprecated` JSDoc 声明该规则。这是 API 使用决策;当前 Session 实现仍在内存中保留完整事件序列。 + +### 恢复后需要的状态 + +结合设计持久事件字段与会话投影,使每个领域都能重建其消费方所需的状态。在恢复期间还原这些状态,随后通过新提交的事件增量维护。恢复后,普通逻辑读取投影或处理当前收到的事件,而不是回头查找历史事件。同步读取已经维护好的投影状态,不需要任意访问事件日志。 + +按需展示的历史内容采用显式异步分页和渐进式加载,每次读取限制在请求的窗口内。在同步辅助方法背后加载完整序列,仍保留了本决策要消除的依赖。 + +### 确实需要完整历史的操作 + +fork 等少数操作可能确实需要完整历史序列或继承前缀。它们对这些记录的需求仍然成立,需要通过显式存储读取来满足。这不要求完整序列常驻内存,也不构成新增已弃用同步读取调用的例外。每个此类消费方都必须说明,为什么其结果需要完整序列,而不能只使用投影状态或局部历史窗口。 + +## 曾考虑的替代方案 + +**只弃用完整快照,保留同步单事件读取。** 单个被请求的事件也可能不在内存中。限制返回结果大小并不能消除存储依赖,`ownEvents()` 等辅助方法对后缀序列也保留着相同假设。 + +**为保留读取 API 而让完整序列常驻内存。** 这会让消费方的便利性决定会话的内存保留方式,阻碍预期的存储设计。投影保留所需状态,显式历史读取则保留对记录本身的访问能力。 + +**要求立即迁移所有现有调用方。** 本决策允许现有逻辑推迟迁移。禁止新增依赖可以限制剩余工作规模,无需将每个现有消费方都纳入同一次修改。 + +## 后果 + +新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。 + +当前 `typescript/no-deprecated` lint 规则会同时报告已有调用与新增调用。JSDoc 记录了暂缓迁移的约定,但 lint 尚不能区分允许保留的现有调用与禁止新增的调用。文档检查校验与源码一致的 API 声明及双语记录,并不强制执行这种区分。 + +## 相关决策 + +[会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)继续负责事件所有权与冻结;本决策取代其中对同步历史读取方法的使用建议。[投影读取必需性决策](2026-08-19-session-projection-mandatory-seam.zh.md)继续负责缺失状态时的失败规则与类型化状态读取。[可回溯压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.zh.md)保留其回溯设计,并将其中拟议的同步历史访问替换为分页读取。 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 fded384582..d324ff433c 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: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448 -2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e +2026-07-06-recallable-compaction.md: 07b89116b72a1c1aac2d79ee5aa41ff6e5d02a4c +2026-07-06-recallable-compaction.zh.md: 7347cb08c94665a6e5bb88655ec08d04921d08eb 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 f0cf1b0602..07b89116b7 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.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. +Both use explicit asynchronous, paged history reads under the [synchronous event-read deprecation](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md) (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 @@ -86,7 +86,7 @@ Deferred until observation calls for them: - **One summarize call emitting all outputs** — rejected: the summarize path has no structured-output enforcement; parsing one free-text response apart is the fragile boundary the fail-closed design avoids. - **Model-chosen chunk boundaries** — deferred: parse-and-validate cost against unproven value; chunk policy sits behind config. - **Model-authored pointers** — rejected: pointers must be exact; deterministic assembly is. -- **FTS/vector index sidecar** — rejected in-session: the live log is in memory and bounded, a literal scan under budget suffices; an index earns its keep at cross-session scope. +- **FTS/vector index sidecar** — the rejection based on a resident, bounded live log is superseded by the [event-read policy](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). Reassess the index choice against paged historical reads before implementing recall. - **Semantic search fallback / secondary-model extraction in the recall path** — rejected: an LLM or embedding call there breaks keyless replay determinism; recall stays a pure function of the log. - **Raw events instead of rendered transcript** — rejected: leaks log-only vocabulary and chunk noise; the model reads what a model once saw. - **Doing nothing (resume/fork as recovery)** — rejected: it makes recovery a human act. 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 8e8793f28b..7347cb08c9 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.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 +两个工具都按[同步事件读取弃用规则](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)使用显式异步分页历史读取(拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 ### 缓存与成本 @@ -86,7 +86,7 @@ Status: proposed - **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱边界。 - **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略由配置控制。 - **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。 -- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。 +- **FTS/向量索引伴随存储**:以实时日志常驻内存且大小有界为依据的否决,被[事件读取策略](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)取代。实现回溯前,需要针对分页历史读取重新评估索引选择。 - **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。 - **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。 - **什么都不做(用恢复/fork 补救)**:不予采纳,因为这会把恢复变成人工操作。 diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index ed9b39bdff..773d428da8 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: 402f3536402d78d0a70135290092cd7086706fb2 -README.zh.md: 303af9a878909f99e20dec1052b6592da7c8c415 +README.md: ebe4c1c29992b29b07ad38d7368ca5cef2253ee0 +README.zh.md: f5d0ac59f1276e4fe59c1cc449dcf6ce5a082aba diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 402f353640..ebe4c1c299 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -55,7 +55,7 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and ### 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. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new calls are prohibited. Callers that only need a length use `seq`. +`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. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new calls are prohibited ([rationale](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Callers that only need a length use `seq`. Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 303af9a878..f5d0ac59f1 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -55,7 +55,7 @@ session.deriveMessages() // the derived model history ### 读取日志 -`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增调用。只需要长度的调用方使用 `seq`。 +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增调用([理由](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md))。只需要长度的调用方使用 `seq`。 会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,品牌在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。 From 37abcb74c588b57d373a6594faa268a0c40ccb26 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 12:33:53 +0800 Subject: [PATCH 03/60] chore(session): permit existing deprecated history reads --- ...-synchronous-session-event-reads.i18n.yaml | 4 +- ...precate-synchronous-session-event-reads.md | 4 +- ...cate-synchronous-session-event-reads.zh.md | 4 +- .../headless/tests/coding-task.e2e.ts | 1 + .../profiles/headless/tests/compaction.e2e.ts | 1 + .../profiles/headless/tests/full-loop.e2e.ts | 1 + .../tests/profiles/headless/tests/ptc.e2e.ts | 3 + .../profiles/headless/tests/resume.e2e.ts | 1 + .../profiles/headless/tests/todo-write.e2e.ts | 1 + .../tests/chat-continuous-conversation.e2e.ts | 1 + apps/web/tests/chat-long-interactions.e2e.ts | 11 +++ apps/web/tests/chat-scroll-fixture.ts | 1 + apps/web/tests/clickable-links-gallery.e2e.ts | 1 + apps/web/tests/complex-history.perf.ts | 1 + apps/web/tests/feedback-release.e2e.ts | 1 + .../tests/goal-command-presentation.e2e.ts | 1 + apps/web/tests/markdown-cjk-strong.e2e.ts | 1 + apps/web/tests/markdown-images.e2e.ts | 1 + .../tests/markdown-inline-code-links.e2e.ts | 1 + apps/web/tests/markdown-wide-table.e2e.ts | 1 + apps/web/tests/math-rendering.e2e.ts | 1 + apps/web/tests/minimal-preset.snapshot.ts | 1 + apps/web/tests/preset-migration.snapshot.ts | 1 + apps/web/tests/produced-file-mentions.e2e.ts | 1 + apps/web/tests/produced-files.e2e.ts | 1 + apps/web/tests/reference-composer.e2e.ts | 2 + apps/web/tests/scaffold.ts | 2 + apps/web/tests/schedule-after.e2e.ts | 8 +++ apps/web/tests/seeded-history.e2e.ts | 1 + apps/web/tests/settings-chrome.e2e.ts | 4 ++ apps/web/tests/subagent-interrupt-ui.e2e.ts | 1 + apps/web/tests/subagent-interrupt.e2e.ts | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/acp/acp/tests/bridge.spec.ts | 2 + packages/acp/acp/tests/turns.spec.ts | 5 ++ .../api/session-controller/src/commands.ts | 2 + .../api/session-controller/src/history.ts | 1 + packages/api/session-controller/src/index.ts | 1 + .../commands-queue-attachment.host.spec.ts | 1 + .../tests/session-cold.host.spec.ts | 3 + .../tests/session-fork.host.spec.ts | 6 ++ .../tests/session-projections.host.spec.ts | 3 + .../tests/session-rename.host.spec.ts | 1 + .../tests/transport.host.spec.ts | 1 + packages/bundle/headless/src/index.ts | 1 + .../spill-policy-terminal.client.spec.ts | 1 + .../tests/command-compact.spec.ts | 1 + .../tests/loader-composition.spec.ts | 1 + .../compaction/compaction-basic/src/region.ts | 9 ++- .../tests/compaction-basic.spec.ts | 29 ++++++++ .../tests/compaction-loop-repro.spec.ts | 12 ++++ .../tests/manual-compaction.spec.ts | 29 ++++++++ .../src/index.ts | 1 + .../tests/tool-result-pruner.spec.ts | 4 ++ .../compaction/compaction/src/invariant.ts | 1 + .../compaction/compaction/src/tool-pairing.ts | 1 + .../compaction/tests/compaction.spec.ts | 3 + .../compaction/tests/invariant.spec.ts | 8 +++ .../compaction/tests/tool-pairing.spec.ts | 1 + .../context/agent-instructions/src/index.ts | 2 + .../context/agent-instructions/src/state.ts | 1 + .../tests/agent-instructions.e2e.ts | 3 + .../tests/agent-instructions.spec.ts | 34 +++++++++ .../tests/loader-composition.spec.ts | 1 + .../tests/session-reference.spec.ts | 4 ++ packages/context/time-context/src/index.ts | 1 + .../context/time-context/src/invariant.ts | 2 + .../time-context/tests/time-context.spec.ts | 13 ++++ .../tmux-context/tests/tmux-context.spec.ts | 2 + packages/core/agent-loop/src/index.ts | 1 + packages/core/agent-loop/src/invariant.ts | 1 + .../core/agent-loop/src/runtime-context.ts | 2 + .../agent-loop/tests/agent-initiator.spec.ts | 1 + packages/core/agent-loop/tests/agent.spec.ts | 4 ++ packages/core/agent-loop/tests/cancel.spec.ts | 42 +++++++++++ .../tests/contract-regressions.spec.ts | 40 +++++++++++ .../agent-loop/tests/coverage-edges.spec.ts | 18 +++++ packages/core/agent-loop/tests/inbox.spec.ts | 4 ++ .../agent-loop/tests/interception.spec.ts | 1 + packages/core/agent-loop/tests/loop.spec.ts | 69 +++++++++++++++++++ .../core/agent-loop/tests/properties.spec.ts | 4 ++ .../agent-loop/tests/request-cache.e2e.ts | 1 + .../agent-loop/tests/request-error.spec.ts | 6 ++ .../agent-loop/tests/request-freeze.spec.ts | 3 + .../tests/request-reconstruction.spec.ts | 41 +++++++++++ packages/core/agent-loop/tests/resume.spec.ts | 9 +++ .../agent-loop/tests/scope-lifecycle.spec.ts | 3 + .../tests/system-prompt-admission.spec.ts | 20 ++++++ .../core/agent-loop/tests/tool-calls.spec.ts | 1 + .../core/agent-loop/tests/tool-order.spec.ts | 8 +++ .../core/agent/tests/consumed-work.spec.ts | 13 ++++ packages/core/session/src/index.ts | 4 ++ packages/core/session/src/invariant.ts | 1 + .../session/tests/canonical-envelopes.spec.ts | 5 ++ .../core/session/tests/derived-cache.spec.ts | 1 + packages/core/session/tests/fork.spec.ts | 13 ++++ packages/core/session/tests/invariant.spec.ts | 2 + .../core/session/tests/properties.spec.ts | 8 +++ .../core/session/tests/request-header.spec.ts | 1 + .../core/session/tests/sequence-types.spec.ts | 4 ++ packages/core/session/tests/session.spec.ts | 53 ++++++++++++++ packages/core/session/tests/surface.spec.ts | 20 ++++++ packages/core/tools/src/invariant.ts | 1 + packages/core/tools/tests/invariant.spec.ts | 1 + packages/core/tools/tests/ptc.spec.ts | 1 + .../experimental/agent-team/src/mailbox.ts | 1 + .../experimental/agent-team/src/roster.ts | 2 + .../agent-team/tests/invariant.spec.ts | 2 + .../agent-team/tests/persistence.spec.ts | 3 + .../agent-team/tests/team.spec.ts | 7 ++ .../agent-team/tests/test-session-query.ts | 1 + .../tool-agent-team/tests/tool-team.spec.ts | 1 + .../tests/command-feedback.spec.ts | 10 +++ .../tests/loader-composition.spec.ts | 4 ++ .../feedback/message-feedback/src/index.ts | 2 + .../message-feedback/tests/helpers.ts | 1 + .../tests/message-feedback.spec.ts | 9 +++ packages/fs/tool-fs/tests/fs-tools.e2e.ts | 1 + .../fs/tool-present/tests/present.spec.ts | 5 ++ .../command-goal/tests/command-goal.spec.ts | 4 ++ .../goal/goal-round-driver/src/invariant.ts | 2 + .../tests/goal-round-driver.spec.ts | 10 +++ packages/goal/goal/src/invariant.ts | 1 + packages/goal/goal/tests/goal.spec.ts | 28 ++++++++ packages/goal/goal/tests/projection.spec.ts | 1 + packages/goal/tool-goal/src/authority.ts | 1 + .../goal/tool-goal/tests/tool-goal.spec.ts | 2 + .../tests/repeat-tool-reminder.spec.ts | 3 + packages/hooks/hook-protocol/src/invariant.ts | 1 + .../hooks/hook-protocol/tests/events.spec.ts | 9 +++ .../hooks-claude-code/tests/bridge.spec.ts | 1 + .../hooks-claude-code/tests/coverage-cases.ts | 1 + .../hooks/hooks-codex/tests/bridge.spec.ts | 1 + .../hooks/hooks-codex/tests/coverage-cases.ts | 1 + .../interaction/commands/src/invariant.ts | 2 + .../commands/tests/commands.spec.ts | 5 ++ .../permission-presets/src/invariant.ts | 1 + .../tests/permission-presets.spec.ts | 18 +++++ .../tests/projection.spec.ts | 4 ++ .../interaction/user-approval/src/index.ts | 2 + .../user-approval/src/invariant.ts | 1 + .../user-approval/tests/approval.spec.ts | 9 +++ packages/llm/llm-retry/src/invariant.ts | 3 + .../tests/loader-composition.spec.ts | 1 + packages/llm/llm-retry/tests/retry.spec.ts | 25 +++++++ .../tests/transport-recovery.spec.ts | 17 +++++ packages/llm/token-meter/src/index.ts | 3 +- .../context-breakdown-projection.spec.ts | 6 ++ .../llm/token-meter/tests/token-meter.spec.ts | 6 ++ packages/plan/plan-mode/src/invariant.ts | 1 + .../plan/plan-mode/tests/integration.spec.ts | 6 ++ .../plan/plan-mode/tests/plan-mode.spec.ts | 39 +++++++++++ .../plan/plan-mode/tests/projection.spec.ts | 1 + .../preset/agent-presets/tests/remote.spec.ts | 1 + .../sandbox/sandbox-policy/src/invariant.ts | 1 + .../sandbox-policy/tests/policy.spec.ts | 2 + packages/schedule/schedule/src/index.ts | 1 + packages/schedule/schedule/src/invariant.ts | 3 + packages/schedule/schedule/src/runtime.ts | 1 + packages/schedule/schedule/src/tools.ts | 1 + .../schedule/schedule/tests/invariant.spec.ts | 5 ++ .../schedule/tests/jsonl-restart.spec.ts | 2 + .../schedule/schedule/tests/runtime.spec.ts | 11 +++ .../schedule/schedule/tests/tools.spec.ts | 9 +++ .../tests/loader-composition.host.spec.ts | 1 + .../session-query-sqlite/src/index.ts | 1 + .../session-query/session-query/src/corpus.ts | 2 + .../session-query/src/observation.ts | 1 + .../session-query/tests/session-query.spec.ts | 2 + .../tests/tool-session-query.spec.ts | 1 + .../session/session-log-deepseek/src/index.ts | 3 + .../session-log-deepseek/src/invariant.ts | 1 + .../tests/feedback-composition.spec.ts | 2 + .../session-log-deepseek/tests/upload.spec.ts | 4 ++ .../tests/multi-edge-publication.spec.ts | 1 + .../tests/v2-system-migration.spec.ts | 6 ++ .../session/session-projection/src/index.ts | 3 + .../session-telemetry-otel/src/index.ts | 1 + .../session-telemetry-otel/tests/otel.spec.ts | 15 ++++ .../session-telemetry/src/coordinator.ts | 1 + .../session-telemetry/tests/redact.spec.ts | 2 + .../session-telemetry/tests/telemetry.spec.ts | 8 +++ .../tests/provider.spec.ts | 1 + .../session-title-llm/tests/llm.spec.ts | 4 ++ packages/session/session-title/src/index.ts | 2 + .../session/session-title/src/invariant.ts | 2 + .../session-title/tests/projection.spec.ts | 1 + .../session-title/tests/provider.spec.ts | 3 + .../session-title/tests/rename.spec.ts | 3 + .../tests/service-contracts.spec.ts | 6 ++ .../session-title/tests/session-title.spec.ts | 5 ++ .../shell/tool-bash/tests/integration.spec.ts | 1 + packages/shell/tool-bash/tests/tools.spec.ts | 1 + packages/skill/tool-skill/src/index.ts | 1 + .../skill/tool-skill/tests/tool-skill.spec.ts | 1 + .../subagent-fork-in-process/src/index.ts | 1 + .../tests/multi-subagent.spec.ts | 4 ++ .../tests/subagent-fork-in-process.spec.ts | 8 +++ .../subagent-in-process-driver/src/index.ts | 1 + .../tests/inheritance.spec.ts | 13 ++++ .../tests/preset-inheritance.spec.ts | 1 + .../tests/structured.spec.ts | 6 ++ .../tests/subagent-in-process-driver.spec.ts | 8 +++ .../tests/spawn-in-process.e2e.ts | 1 + .../tests/subagent-spawn-in-process.spec.ts | 3 + packages/subagent/subagent/src/lifecycle.ts | 1 + .../subagent/tests/continuation.spec.ts | 12 ++++ packages/subagent/tool-subagent/src/index.ts | 1 + .../tests/model-selection-settings.spec.ts | 2 + .../terminal-bash/tests/index.spec.ts | 2 + .../tests/agent-loop-testkit.spec.ts | 1 + .../fixtures/workspace-context-compaction.ts | 2 + packages/todo/tool-todo/src/invariant.ts | 1 + .../todo/tool-todo/tests/integration.spec.ts | 3 + .../todo/tool-todo/tests/invariant.spec.ts | 2 + .../tests/loader-composition.spec.ts | 2 + .../todo/tool-todo/tests/tool-todo.spec.ts | 10 +++ .../workflow/tool-workflow/src/invariant.ts | 1 + .../tool-workflow/tests/tool-workflow.spec.ts | 7 ++ scripts/oxlint-contract.spec.ts | 53 ++++++++++++++ .../python-snapshot-workflow-order.spec.ts | 1 + 223 files changed, 1195 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml index 6af464d03b..51e978ca70 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md -2026-09-09-deprecate-synchronous-session-event-reads.md: b0bc1e81ada1781587b7bd6b5687d62bc0d8e798 -2026-09-09-deprecate-synchronous-session-event-reads.zh.md: 0c8fd24bb48e6a40309fe571fce5d2fba5b99b2b +2026-09-09-deprecate-synchronous-session-event-reads.md: 0fcbfbc2e21792f67d1bdbe6d32ac3106338ac45 +2026-09-09-deprecate-synchronous-session-event-reads.zh.md: d1f22b483380a7a38a5cd1208815c5fc0ae25348 diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md index b0bc1e81ad..0fcbfbc2e2 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md @@ -34,11 +34,13 @@ Fork and a small number of operations may genuinely need a complete historical s **Require every existing caller to migrate immediately.** Existing logic may defer migration under this decision. Preventing new dependencies bounds the remaining work without making every existing consumer part of the same change. +**Disable deprecation lint for these methods or whole files.** Such exemptions also admit new calls. Line-scoped waivers make the existing migration debt explicit while retaining the native type-aware rule for new reads. + ## Consequences New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log. -The current `typescript/no-deprecated` lint rule reports both existing and new calls. The JSDoc records the migration deferral, but lint does not yet distinguish permitted existing callers from prohibited additions. The documentation checks verify the source-equivalent API declarations and bilingual records; they do not enforce that distinction. +Existing calls carry line-scoped `typescript/no-deprecated` waivers with a migration-deferral reason. The rule stays enabled for new calls and unrelated deprecated APIs. Remove a waiver when its call migrates; copying a waiver to a new call violates this policy. The executable lint check exercises all three readers with and without waivers, plus an unrelated deprecated API. Documentation checks verify the source-equivalent API declarations and bilingual records. ## Related decisions diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md index 0c8fd24bb4..d1f22b4833 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md @@ -34,11 +34,13 @@ fork 等少数操作可能确实需要完整历史序列或继承前缀。它们 **要求立即迁移所有现有调用方。** 本决策允许现有逻辑推迟迁移。禁止新增依赖可以限制剩余工作规模,无需将每个现有消费方都纳入同一次修改。 +**为这些方法或整个文件禁用弃用 lint。** 这种豁免也会放过新增调用。逐行豁免明确标出已有的迁移负担,同时继续由原生类型感知规则检查新增读取。 + ## 后果 新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。 -当前 `typescript/no-deprecated` lint 规则会同时报告已有调用与新增调用。JSDoc 记录了暂缓迁移的约定,但 lint 尚不能区分允许保留的现有调用与禁止新增的调用。文档检查校验与源码一致的 API 声明及双语记录,并不强制执行这种区分。 +现有调用带有逐行的 `typescript/no-deprecated` 豁免,并说明暂缓迁移的理由。该规则对新增调用及其他已弃用 API 继续生效。调用迁移后应删除豁免;将豁免复制到新增调用违反本策略。实际执行 lint 的检查覆盖三个读取方法有无豁免的情况,以及另一个已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。 ## 相关决策 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 bb0b4297b8..4b78389bb3 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -67,6 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summary = finalText(agent.session.snapshotEvents()).toLowerCase() expect(summary.length).toBeGreaterThan(0) diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 6e3f46c266..263b794778 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,6 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() // A compaction ran: the start…end bracket landed in the real log. 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 6816c60f61..74db5cc44a 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -34,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index c666871584..557b632132 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events: readonly SessionEvent[] = agent.session.snapshotEvents() // The wire contract: every request this session made offered EXACTLY ONE @@ -418,10 +419,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events: readonly SessionEvent[] = handle.agent.session.snapshotEvents() const dispatch = events.find(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index 579b082906..ce8c0bd91d 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,6 +63,7 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 4ff5fef99c..7a201279f8 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -37,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() // The model actually called the tool. diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index c539a96601..062f5bceac 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -334,6 +334,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { } if (sessionId === undefined) throw new Error('continuous conversation completed no turn') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => ( event.type === 'turn/end' && event.data.reason.kind === 'completed' ))).toHaveLength(TURN_COUNT) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 17e639814c..fbc0424c8c 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -184,12 +184,17 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker) const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) @@ -315,8 +320,11 @@ 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.inheritedEventCount).toBe(boundary.seq + 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) @@ -334,10 +342,13 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(event => ( event.type === 'user/message' && carries(event, CONTINUE_PROMPT) ))).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index 34ebcc8d1b..bd060c57ff 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -178,6 +178,7 @@ function fixtureLog(session: Session): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index ea3b86dbb5..09423cd4ac 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -287,6 +287,7 @@ function galleryFixture(imageUrl: string): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index faa0146134..bc909b45f5 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -329,6 +329,7 @@ function fixtureLog(session: Session): string { } return [ JSON.stringify(header), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index b63f3c6213..30e12833ee 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -275,6 +275,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', expect(captured()).toHaveLength(releasedCount) await input.press('Enter') const findHeaderless = () => scaffold.ctx.sessions.list().find(session => session.id !== sessionId + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && session.snapshotEvents().some(event => event.type === 'feedback/record')) // A command-only session keeps the hero view; its durable event confirms submission. await expect.poll(findHeaderless, { timeout: 10_000 }).toBeDefined() diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 70ad37a799..157dbf947e 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -124,6 +124,7 @@ describe('web e2e: /goal human transcript presentation', () => { const sessions = scaffold.ctx.sessions.list() expect(sessions).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index 237bbd9d3d..40994debcb 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -79,6 +79,7 @@ function markdownFixture(): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 b8f18c6c67..f7f369096b 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -144,6 +144,7 @@ function markdownImageFixture(remoteUrl: string, outsidePath: 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 62bbf589aa..ac7be4e205 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -76,6 +76,7 @@ function markdownFixture(linkUrl: string): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 09f980ab5c..9607969fef 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -142,6 +142,7 @@ function wideTableFixture(): string { return [ JSON.stringify(header), // Spaced event times, as the sibling markdown fixtures pin them. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 c826d5aff2..1363eb35cf 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -80,6 +80,7 @@ function mathFixture(): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 22ab1bc476..900cb34055 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -81,6 +81,7 @@ describe('minimal agent preset', () => { if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') const systemPrompt = systemPromptText(agentHandle.agent.session) if (systemPrompt === undefined) throw new Error('the minimal agent issued no system prompt') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 7fb3d881d2..a68c5c9f30 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -45,6 +45,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t if ('error' in resolved) throw resolved.error expect(scaffold.ctx.agentPresets.composedPreset(resolved.agent.ctx)).toBe('ptc') expect(resolved.agent.session.header.agentPreset).toBe('ptc') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index b8ec77c693..91919dad25 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -112,6 +112,7 @@ function mentionFixture(): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 1097ed3b1e..acb267b644 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -99,6 +99,7 @@ function producedFixture(): string { type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 39c7786296..3870715531 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -73,6 +73,7 @@ function sourceSessionFixture(): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') @@ -122,6 +123,7 @@ function targetSessionFixture(): string { isSeeded: false, delegationDepth: 0, }), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 4bc734aa4b..ada371450b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -877,6 +877,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise sessionFormatCatalog.encodeCurrentEvent(event)) const header = sessionFormatCatalog.encodeCurrentHeader({ @@ -1030,6 +1031,7 @@ async function assertReplaySession( const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { if (session.header.parentSession !== undefined) return false + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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('') diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 587a355f4f..fd7a7bf461 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -205,6 +205,7 @@ async function waitForReply( ): Promise> { const deadline = Date.now() + timeoutMs while (true) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => ( candidate.type === 'assistant/message' && assistantText(candidate) === text )) @@ -455,6 +456,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)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' @@ -470,6 +472,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const decision = acceptedAt[0] if (decision === undefined) throw new Error('missing Every decision time') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const batch = everyHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -493,6 +496,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +520,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')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const user = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'user' @@ -541,11 +546,13 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { } expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const created = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'schedule/change' && event.data.operation === 'create' @@ -560,6 +567,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { prompt: AT_PROMPT, scheduledAt, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(atHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 09110c5360..3472c80c57 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -495,6 +495,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') ?? [] diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 8ef2af7048..ed5b270188 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -144,6 +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')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) @@ -160,10 +161,12 @@ 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'read-only' }], ['sandbox/mode', { mode: 'read-only' }], @@ -181,6 +184,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')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 11d96c63fd..7fe4b6f2de 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -320,6 +320,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 71e25c5918..9031d5577b 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -159,7 +159,9 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(child).toBeDefined() expect(child!.status).toBe('idle') expect(child!.inbox.nextTurn).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastEnd = child!.session.snapshotEvents().filter(event => event.type === 'turn/end').at(-1) expect((lastEnd)?.data.reason.kind).toBe('aborted') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bfc5221538..f0cecf4512 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: 50626a7d85aa54f6d2e3cdb6140b3af8081d1461 -config-catalog.zh.md: d2ae35b91a4a83da882e8ec5af82a505aa5d5bd1 +config-catalog.md: 0b56a574cac7f42efafaac8fb80047027441e7dc +config-catalog.zh.md: ac10d61b7c2f14513819caef2faba992ca84451e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 50626a7d85..0b56a574ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3236,7 +3236,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:127`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d2ae35b91a..ac10d61b7c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3238,7 +3238,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts) diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index ad1fa30fac..218a88ada4 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -902,6 +902,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)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==') }) @@ -975,6 +976,7 @@ describe('automation-only ACP bridge', () => { sessionId, prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], })).rejects.toThrow(/inline image prompts were not advertised/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index a17223750f..8b8e38d8ab 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -216,6 +216,7 @@ describe('ACP prompt lifecycle', () => { const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) .finally(() => { settled = true }) await vi.waitFor(() => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.length > 0)).toHaveLength(2) }) @@ -316,6 +317,7 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) expect(harness.adapter.requests).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +445,7 @@ describe('ACP prompt lifecycle', () => { await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -468,12 +471,14 @@ describe('ACP prompt lifecycle', () => { source: { kind: 'plugin', plugin: 'test' }, })) await vi.waitFor(() => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) await harness.client.cancel({ sessionId }) await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 35fc113408..a564234664 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -541,6 +541,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } } const inspected = await inspectApiSession(this.ctx, sessionId) @@ -587,6 +588,7 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId } if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents().some((event) => { if (event.type !== 'user/message') return false const source = event.data.source diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index cfaf00421e..a22badc1f1 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -152,6 +152,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(snapshotCursor === undefined ? session.firstLiveSeq : SessionLogOffset(snapshotCursor + 1)) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 9cc93be179..8c4107f592 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -202,6 +202,7 @@ export class SessionController extends TypertRemoteService { return Promise.resolve({ meta: attached.header, inheritedEventCount: attached.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: attached.snapshotEvents(), }) } diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index d7559bc047..22a38f92f9 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -41,6 +41,7 @@ async function commandHarness( // never appends one: the identity folds as continuable, but from the // inherited prefix rather than this Session's own suffix. const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only' + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ? ancestor.snapshotEvents() : undefined const session = ctx.sessions.create(sessionId, { 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 eaca25062f..54003d6a65 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -203,6 +203,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = resumed.snapshotEvents().at(-1) expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) @@ -717,6 +718,7 @@ describe('sessions.prompt synchronous rejection', () => { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const initialEvents = session.snapshotEvents() const rejectedContent: readonly SessionPromptRequest['content'][] = [ [], @@ -741,6 +743,7 @@ describe('sessions.prompt synchronous rejection', () => { } expect(followup).not.toHaveBeenCalled() expect(steer).not.toHaveBeenCalled() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual(initialEvents) const queued = await remote.prompt(promptRequest({ 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 1661f505bc..355f99c0a6 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -95,6 +95,7 @@ describe('sessions.fork', () => { expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) @@ -214,12 +215,14 @@ describe('sessions.fork', () => { const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } @@ -243,10 +246,12 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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', @@ -258,6 +263,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const anchor = source.snapshotEvents().at(-1)?.seq ?? 0 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ 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 90ffb10456..9339df6779 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -172,6 +172,7 @@ describe('session.history projections block', () => { parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const inheritedEventCount = parent.seq const child = ctx.sessions.create(SessionId('wire-seed-child'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: parent.snapshotEvents(), inheritedEventCount, meta: { @@ -448,6 +449,7 @@ describe('session.history projections block', () => { expect('test/last-user' in after.projections.values).toBe(false) expect(after.projections.values.sessionListMetadata).toEqual({ blank: true, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. lastPromptAt: session.eventAt(SessionSeq(session.seq - 1))?.time, }) }) @@ -482,6 +484,7 @@ describe('session.list projections column', () => { expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) expect(row?.projections?.values.sessionListMetadata).toEqual({ blank: false, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. lastPromptAt: session.eventAt(SessionSeq(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 60987608b9..55ed1e32e0 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -76,6 +76,7 @@ describe('sessions.rename', () => { expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ebc731769e..9ef70e5d8d 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -230,6 +230,7 @@ describe('SessionHistoryController', () => { observed.resolve({ source: 'live', header: attached.header, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: attached.snapshotEvents(), cursor: attached.seq - 1, projections: { asOfSeq: attached.seq - 1, values: {} }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 50a5c664be..90f29b460a 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -67,6 +67,7 @@ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome { let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined const length = session.seq for (let seq = firstSeq; seq < length; seq++) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) if (event === undefined) { throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 0db57e2139..0716dd609f 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -70,6 +70,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl let block: ToolResultNode if (nested) { expect(result.value).toMatchObject({ result: true }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const dispatches = session.snapshotEvents().filter(event => event.type === 'tool/ptc-dispatch') expect(dispatches).toHaveLength(1) const event = dispatches[0]! diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index f2ab167d69..92013469ac 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -121,6 +121,7 @@ function expectLastLifecycle( args: string, outcome: CommandResult, ): string { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lifecycle = test.agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .slice(-2) diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index 442d8196dc..5b8402aae7 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -130,6 +130,7 @@ describe('command-compact real Loader composition', () => { text: 'Compacted 3 history items (~99 tokens).', sourceEventSeq: RESULT.summarySeq, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([ { type: 'command/run', diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index 8bdecee0cf..e8efef2b12 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -98,7 +98,8 @@ interface TransactionFailure { */ function systemHead(session: Session, headSeq: SessionSeq): SessionEvent<'system/message'> | undefined { // Surface nodes are current log seqs, so the event exists. - // oxlint-disable-next-line typescript/no-non-null-assertion + // Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const head = session.eventAt(headSeq)! return head.type === 'system/message' ? head : undefined } @@ -536,7 +537,8 @@ function buildSummarizationInput( const system = head === undefined ? null : session.deriveEventMessage(head) const regionMessages = shadowedSeqs // shadowedSeqs are current surface seqs, so each is a valid log index. - // oxlint-disable-next-line typescript/no-non-null-assertion + // Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated .map(seq => session.deriveEventMessage(session.eventAt(seq)!)) .filter((message): message is Message => message !== null) return { @@ -553,7 +555,8 @@ function inspectCompactionEntryState(session: Session): CompactionEntryState { let compactionEntryStateKnown = false let latestEndSeedSeq: SessionSeq | undefined for (let seq = session.seq - 1; seq >= 0; seq -= 1) { - // oxlint-disable-next-line typescript/no-non-null-assertion + // Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const event = session.eventAt(SessionSeq(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 bd6a48af66..df5cbce97c 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -625,6 +625,7 @@ describe('pressure measurement and retention', () => { await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() expect(session.surface.replaceGeneration).toBe(generation) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) @@ -646,6 +647,7 @@ describe('pressure measurement and retention', () => { const compact = service(compactConfig, ctx) const session = conversation(4, undefined, 'SYSTEM HEAD') const [head, firstUser] = session.surface.nodes + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(head!)?.type).toBe('system/message') const range = selectCompactableRange(session, ctx.tokenMeter.measure(session), compactConfig.retainTokens!) @@ -889,9 +891,11 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) }) @@ -918,6 +922,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, @@ -934,6 +939,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: '' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replay = Session.create(SessionId('replay'), session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) @@ -958,6 +964,7 @@ describe('compaction region transaction', () => { const compact = service() const emptyHead = conversation(3, undefined, '') const emptyNodes = emptyHead.surface.nodes + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(emptyHead.eventAt(emptyNodes[0]!)?.type).toBe('system/message') await compact.compactRegion(emptyNodes[1]!, emptyNodes[2]!, agent(emptyHead, MODEL), SIGNAL) expect(compact.calls[0]!.input.messages[0]).toMatchObject({ role: 'user' }) @@ -979,6 +986,7 @@ describe('compaction region transaction', () => { .rejects.toThrow(/node 0 holds the system prompt/) expect(session.surface.nodes).toEqual(nodes) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.type === 'compaction/end' && end.data.error).toBeDefined() }) @@ -1092,6 +1100,7 @@ describe('compaction region transaction', () => { agent(session, MODEL), )).rejects.toThrow('summary unavailable') expect(session.surface.nodes).toEqual(before) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable' }) }) @@ -1106,6 +1115,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toBe('plain failure') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'plain failure' }) }) @@ -1126,6 +1136,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) }) @@ -1145,6 +1156,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/session surface changed/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -1162,6 +1174,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/summary is not smaller/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -1435,6 +1448,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], llmStreamCall: true, @@ -1561,6 +1575,7 @@ describe('automatic listener and loader composition', () => { next: () => Promise = () => Promise.resolve(undefined), ): Promise { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', @@ -1581,10 +1596,12 @@ describe('automatic listener and loader composition', () => { }) const pressured = conversation(4) await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) const small = conversation(1) await preStep(ctx, agent(small, MODEL)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) @@ -1602,6 +1619,7 @@ describe('automatic listener and loader composition', () => { .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) @@ -1618,6 +1636,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')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -1677,6 +1696,7 @@ describe('automatic listener and loader composition', () => { expect(decision).toBe(true) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) }) @@ -1696,6 +1716,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) expect(compact.calls).toHaveLength(0) }) @@ -1714,6 +1735,7 @@ describe('automatic listener and loader composition', () => { const session = toolConversation() expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1737,7 +1759,9 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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')) @@ -1940,9 +1964,11 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) }) @@ -1955,6 +1981,7 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -1987,6 +2014,7 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await preStep(ctx, agent(session, MODEL)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -2128,6 +2156,7 @@ describe('route-priced image pressure', () => { const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL) expect(result).not.toBeNull() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summaryEvent).toBeDefined() const shadowedHeuristic = before.nodes 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 a527226c03..a5834df1c7 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -212,6 +212,7 @@ function overflowHistorySeed(): readonly SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents() } @@ -230,7 +231,9 @@ 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -247,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() @@ -279,6 +283,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( @@ -347,6 +352,7 @@ describe('token pressure after loop-admitted system prompts', () => { prompt = nextPrompt agent.followup(createUserMessage({ content: [{ type: 'text', text: 'question' }], source: { kind: 'user' } })) await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -354,11 +360,13 @@ describe('token pressure after loop-admitted system prompts', () => { expect(measured.baseline).toEqual({ kind: 'usage', tokens: 1110, usage }) expect(measured.surfaceDeltaTokens).toBe(0) expect(measured.totalTokens).toBe(1110) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replay = Session.create(SessionId('prompt-pressure-replay'), agent.session.snapshotEvents()) expect(ctx.tokenMeter.measure(replay)).toMatchObject({ baseline: measured.baseline, surfaceDeltaTokens: 0, totalTokens: 1110, nodes: measured.nodes, }) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const prompts = events.filter(event => event.type === 'system/message') expect(prompts[0]?.surfaceOp).toBe('append') @@ -428,6 +436,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, @@ -490,10 +499,13 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } })]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) .toEqual([3]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 9a118cd5a9..daef651acd 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -233,6 +233,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus } function compactEvents(session: Session): SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')) } @@ -295,13 +296,16 @@ describe('compactNow through the real loop', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(injected).toBeDefined() expect(end).toBeDefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.id === injected?.id)).toBe(false) @@ -336,6 +340,7 @@ describe('compactNow through the real loop', () => { expect(result).not.toBeNull() expect(agent.session.deriveMessages()[0]?.role).toBe('system') expect(derivedText(agent.session)[1]).toContain('checkpoint') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -356,6 +361,7 @@ describe('compactNow through the real loop', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) @@ -406,13 +412,18 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summaryEvent = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const checkpoint = session.snapshotEvents().findLast( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && isCompactCheckpointSource(event.data.source), ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } expect(start?.data).toEqual({ ...correlated, turn: null }) @@ -443,8 +454,11 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reloaded = Session.create(SessionId('stale-orphan'), original.snapshotEvents()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) @@ -462,6 +476,7 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reloaded = Session.create(SessionId('reloaded-orphan'), original.snapshotEvents()) const agent = fakeAgent(reloaded, () => () => undefined) @@ -565,6 +580,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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source))).toBe(false) }) @@ -584,6 +600,7 @@ describe('compactNow transaction and failure classification', () => { expect(causeOf(error).message).toBe('boundary rejected') vi.restoreAllMocks() expect(flushes()).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type.startsWith('compaction/'))?.type) .toBe('compaction/summary') expect(compactEvents(session).filter(event => event.type === 'compaction/start')).toHaveLength(1) @@ -650,6 +667,7 @@ describe('compactNow transaction and failure classification', () => { vi.restoreAllMocks() expect(error.code).toBe('commit') expect(released).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -686,7 +704,9 @@ describe('compactNow transaction and failure classification', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().find(event => event.type === 'compaction/start')?.data) .toEqual({ compactionId: result?.compactionId, turn: null }) }) @@ -699,8 +719,11 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) @@ -717,6 +740,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = testCase.session.snapshotEvents() const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() @@ -732,6 +756,7 @@ describe('compactNow transaction and failure classification', () => { expect(reserve).not.toHaveBeenCalled() expect(measure).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(testCase.session.snapshotEvents()).toEqual(before) vi.restoreAllMocks() } @@ -781,6 +806,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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -825,6 +851,7 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -840,7 +867,9 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(end).toBeDefined() diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index 18c752d8f1..6d8ef92298 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -136,6 +136,7 @@ export class ToolResultPruner extends Service { pruneSession(session: Session): PruneResult { const candidates: SnapshotCandidate[] = [] for (const seq of [...session.surface.nodes]) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 a09d0a3c53..7bcbd0ed62 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 @@ -186,7 +186,9 @@ describe('ToolResultPruner session transaction', () => { expect(entry).toMatchObject({ originalSeq, callId: ToolCallId('one'), charsBefore: 100 }) expect(entry.charsAfter).toBeLessThanOrEqual(50) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const original = session.snapshotEvents()[originalSeq]! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replacement = session.snapshotEvents()[entry.replacementSeq]! as SurfaceEvent expect(original).toMatchObject({ type: 'tool/result', @@ -221,6 +223,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()[entry.replacementSeq - 1]).toMatchObject({ type: 'compaction/prune', data: { @@ -256,6 +259,7 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index ce19a6670f..5682f066db 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -298,6 +298,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant surface: new SurfaceManager(surfaceEvents), } traces.set(session, trace) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events) for (const event of events) { diff --git a/packages/compaction/compaction/src/tool-pairing.ts b/packages/compaction/compaction/src/tool-pairing.ts index 56ec6a2368..ca070851ab 100644 --- a/packages/compaction/compaction/src/tool-pairing.ts +++ b/packages/compaction/compaction/src/tool-pairing.ts @@ -50,6 +50,7 @@ function extendCache( const pendingCuts: boolean[] = [] let inProgressToolCalls = cache.inProgressToolCalls for (const seq of tail) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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)`) diff --git a/packages/compaction/compaction/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts index 350d4b8308..887362b18b 100644 --- a/packages/compaction/compaction/tests/compaction.spec.ts +++ b/packages/compaction/compaction/tests/compaction.spec.ts @@ -131,6 +131,7 @@ describe('CompactionEngine seam', () => { const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const startEvent = session.snapshotEvents().find(e => e.type === 'compaction/start') expect(startEvent).toBeDefined() // Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType); @@ -142,12 +143,14 @@ 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]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ff49a3400e..314ed70ba1 100644 --- a/packages/compaction/compaction/tests/invariant.spec.ts +++ b/packages/compaction/compaction/tests/invariant.spec.ts @@ -134,8 +134,10 @@ 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'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['compaction/start', 'session/end-seed']) @@ -155,8 +157,10 @@ 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'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'compaction/start', 'session/end-seed']) @@ -177,8 +181,10 @@ describe('compaction invariants', () => { startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().map(event => event.type)).toEqual([ 'compaction/start', 'turn/start', @@ -204,8 +210,10 @@ 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'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') await ctx.plugin(InvariantRegistry) diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts index a5694d1514..a3dbb7e88c 100644 --- a/packages/compaction/compaction/tests/tool-pairing.spec.ts +++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts @@ -7,6 +7,7 @@ import type { SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/ds const SURFACE = { surfaceOp: 'append' as const } function seqOf(session: Session, type: SessionEvent['type'], nth = 0): SessionSeqType { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq } diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index f614926241..f775c30a9c 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -53,6 +53,7 @@ function visibleBaselineSource( } } for (const seq of agent.session.surface.nodes.toReversed()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(seq) if (event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' @@ -228,6 +229,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) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 fb25e262f9..8a66f7d02f 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -139,6 +139,7 @@ function visibleInstructionChanges( ): Map { const visible = new Map() for (const seq of agent.session.surface.nodes) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(seq) if (event?.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 7e35bdee21..2c256150c8 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -77,6 +77,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(finalText(live.agent.session.snapshotEvents())).toContain(PROBE) }, 120_000) @@ -89,6 +90,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(finalText(live.agent.session.snapshotEvents())).toContain(NESTED_PROBE) }, 120_000) @@ -102,6 +104,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = live.agent.session.snapshotEvents() const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index d477cf666e..259a419bb4 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -277,6 +277,7 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise event.type === 'user/message' && event.data.source.kind === 'agent-instructions' @@ -1127,6 +1128,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(derivedText(agent)).toContain('repo rule') } finally { @@ -1146,13 +1148,16 @@ describe('workspace context request injection', () => { const original = await stubAgent(root) await composeBaselinePrefix(ctx, original) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstResume = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const secondResume = await stubAgent(root, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) expect(baselineEvents(secondResume)).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { @@ -1175,10 +1180,12 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { @@ -1201,12 +1208,15 @@ describe('workspace context request injection', () => { const original = await stubAgent(cwd) await composeBaselinePrefix(ctx, original) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstResume = await stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const secondResume = await stubAgent(cwd, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1231,10 +1241,12 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) @@ -1268,6 +1280,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) @@ -1287,6 +1300,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const repeated = await stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) @@ -1321,6 +1335,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const claudeResume = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) @@ -1336,6 +1351,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const restored = await stubAgent(root, claudeResume.session.snapshotEvents()) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) @@ -1371,6 +1387,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) @@ -1385,6 +1402,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const repeated = await stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) @@ -1415,6 +1433,7 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = claimInbox(resumed, 'next-step') @@ -1431,6 +1450,7 @@ describe('workspace context request injection', () => { expect(decision.messages.map(message => message.id)).toEqual([inserted?.id]) expect(resumed.inbox.nextStep).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1461,6 +1481,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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = claimInbox(resumed, 'next-step') @@ -1514,6 +1535,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = claimInbox(resumed, 'next-step') @@ -1555,6 +1577,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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')) @@ -1589,6 +1612,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const workspaceEvents = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(workspaceEvents).toHaveLength(2) @@ -1598,6 +1622,7 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await composeBaselinePrefix(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(2) } finally { @@ -1821,6 +1846,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) // Resume announces its lifecycle start before the first step. @@ -1829,6 +1855,7 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -2010,6 +2037,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', ) @@ -2651,6 +2679,7 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })) await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', )).toHaveLength(0) @@ -2658,6 +2687,7 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })) await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -3633,6 +3663,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, agent.session.snapshotEvents()) const afterResume = await ctx.tools.execute({ @@ -3667,10 +3698,12 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') }], @@ -4701,6 +4734,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await ctx.tools.execute({ diff --git a/packages/context/session-reference/tests/loader-composition.spec.ts b/packages/context/session-reference/tests/loader-composition.spec.ts index c680e393ca..624b28de33 100644 --- a/packages/context/session-reference/tests/loader-composition.spec.ts +++ b/packages/context/session-reference/tests/loader-composition.spec.ts @@ -134,6 +134,7 @@ describe('session-reference real Loader composition', () => { ctx.sessions.get(SessionId('reference-source'))!.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'LATER_SOURCE_MUTATION' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(captured) expect(await readFile(notice.fullSnapshot.locator, 'utf8')).toBe(transcript) }) diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index dc2caa5067..cbb14be78d 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -322,6 +322,7 @@ describe('session reference spill outcomes', () => { const target = ctx.sessions.create(SessionId('target')) const source = ctx.sessions.create(SessionId('source')) for (const text of texts) appendText(source, text) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const captured = source.snapshotEvents().at(-1)?.seq const read = vi.spyOn(ctx.sessionQuery, 'readSurface') const store = ctx.spillStore as RecordingSpill @@ -371,6 +372,7 @@ describe('session reference spill outcomes', () => { const context = result.additionalContext! target.append('user/message', context, { surfaceOp: 'append' }) for (const detach of detachSources) detach() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('replayed'), target.snapshotEvents()).deriveMessages() expect(replayed).toEqual(target.deriveMessages()) expect(JSON.stringify(replayed)).toContain('memory:session-reference-1.txt') @@ -451,6 +453,7 @@ describe('session reference spill outcomes', () => { finish.resolve(undefined) await rejected await settled.promise + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(target.snapshotEvents().filter(event => event.type === 'user/message')).toEqual([]) } finally { finish.resolve(undefined); await ctx.fiber.dispose() } }) @@ -1198,6 +1201,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(before) }) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 02cf81d738..f626342546 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -80,6 +80,7 @@ function formatDuration(elapsedMs: number): string { function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const entered: UserMessage[] = [] for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(SessionSeq(seq)) if (event?.type === 'turn/start' && event.data.turn === turn) { return [...entered.reverse(), ...proposed] diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 90ec5f2ff2..2fa686a4f4 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -161,6 +161,7 @@ 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 { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() for (const [index, event] of events.entries()) { if (event.type !== 'user/message' @@ -180,6 +181,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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validateReading(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 52700e6d62..16e9a52a89 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -68,6 +68,7 @@ function openMessageTurn(session: Session, turn: number, clientTimeZone?: string function contextTexts(session: Session): string[] { const texts: string[] = [] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -168,6 +169,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.', ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') @@ -291,7 +293,9 @@ describe('durable step context', () => { const original = Session.create(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -304,14 +308,17 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = Session.create(SessionId('resumed'), original.snapshotEvents()) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const beforeSkip = resumed.snapshotEvents().length await fire(ctx, resumedAgent, 2, 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(resumed)).toHaveLength(1) @@ -334,6 +341,7 @@ describe('durable step context', () => { vi.setSystemTime(BASE + 500) openMessageTurn(first, 2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const beforeSkip = first.snapshotEvents().length await fire(ctx, firstAgent, 2, 1) @@ -341,6 +349,7 @@ describe('durable step context', () => { openMessageTurn(independent, 1) await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(first.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(first)).toHaveLength(1) expect(contextTexts(independent)).toHaveLength(1) @@ -460,6 +469,7 @@ describe('real agent-loop request history', () => { expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) @@ -482,8 +492,10 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contexts = agent.session.snapshotEvents().filter( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) @@ -508,6 +520,7 @@ describe('real agent-loop request history', () => { expect(request.messages[0]?.role).toBe('system') expect(JSON.stringify(request.messages[0])).not.toContain('Time sampled while preparing') } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(event => event.type === 'system/message') expect(JSON.stringify(systemNodes)).not.toContain('Time sampled while preparing') await ctx.fiber.dispose() diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index bc9f9f0ed5..ef1b0e930c 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -123,6 +123,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -171,6 +172,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]}', ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index aa967f87c0..6c30f04135 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -748,6 +748,7 @@ export class AgentLoop extends Service implements AgentFactory { */ private async appendUnstoredSuffix(stored: StoredSession | undefined, session: Session): Promise { if (stored === undefined) return + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount)) if (suffix.length > 0) await stored.handle.append(suffix) // Advance by what was stored, not to `session.seq`: an event appended diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index b1b2cc93c3..3b1c62d081 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -28,6 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail('a loop-built request must carry a frozen messages array') } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 daabf5bc19..af9f3fa640 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -45,6 +45,7 @@ export interface SystemPromptDecisionInput { /** Committed events from the newest backward; the restore scans stop at the first match. */ function eventsNewestFirst(session: Session): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().toReversed() } @@ -63,6 +64,7 @@ export class SystemPromptProjection { private systemNodes(): { seq: SessionSeq; text: string | undefined }[] { const nodes: { seq: SessionSeq; text: string | undefined }[] = [] for (const seq of this.session.surface.nodes) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = this.session.eventAt(seq) if (event?.type !== 'system/message') continue const content = event.data.message.content diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 0b9d32ec06..f25740593a 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -335,6 +335,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' })) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 2ec5bcf01c..1d5ea1ac41 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -35,6 +35,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced']) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) @@ -47,6 +48,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const injected = agent.session.snapshotEvents().at(-1) expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source) .toEqual({ kind: 'plugin', plugin: '' }) @@ -97,6 +99,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/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents()).toHaveLength(0) }) @@ -108,6 +111,7 @@ describe('Agent', () => { agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 2b1781083c..00d9339fa6 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -50,6 +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[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -71,6 +72,7 @@ describe('Agent.cancel()', () => { // The prompt ran: its user message is in the log and one turn completed. expect(userTexts(agent)).toEqual(['real prompt']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) }) @@ -86,12 +88,14 @@ 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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) @@ -146,6 +150,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['active', 'B']) expect(adapter.requests).toHaveLength(2) expect(agent.inbox.nextTurn).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }, @@ -196,6 +201,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(1) }) @@ -255,8 +261,11 @@ describe('Agent.cancel()', () => { await new Promise(r => setTimeout(r, 30)) expect(userTexts(agent)).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) expect(agent.status).toBe('idle') @@ -286,6 +295,7 @@ describe('Agent.cancel()', () => { await driverDone(agent) expect(agent.status).toBe('idle') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) @@ -323,6 +333,7 @@ describe('Agent.cancel()', () => { replacementObservation = agent.whenIdle().then(() => ({ status: agent.status, requests: adapter.requests.length, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. turns: agent.session.snapshotEvents().filter(event => event.type === 'turn/start').length, })) agent.cancel({ kind: 'user' }) @@ -400,6 +411,7 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) expect(userTexts(agent)).toEqual(['go']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) @@ -436,7 +448,9 @@ describe('Agent.cancel()', () => { expect(executions).toBe(0) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const call = agent.session.snapshotEvents().find(event => event.type === 'tool/call') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -477,6 +491,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toContain('second') // The second turn completed (its reply was streamed). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reasons = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(reasons.length).toBe(2) }) @@ -493,6 +508,7 @@ describe('Agent.cancel()', () => { // The prefix the user watched stream is committed as the step's message, // carrying the truncation marker and exact embedded stream that delivered it. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -503,6 +519,7 @@ describe('Agent.cancel()', () => { )) : false).toBe(true) expect(message?.sourceEventSeqs).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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')) @@ -537,12 +554,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const message = agent.session.snapshotEvents().find(event => event.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.source.replayState : undefined) .toEqual(replayState) expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) expect(() => Session.fromRestore( agent.session.id, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. structuredClone([...agent.session.snapshotEvents()]), structuredClone(agent.session.header), SessionLogOffset(0), @@ -566,6 +585,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -593,9 +613,11 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The undispatched call is dropped whole — no dangling tool_use to pair. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) }) @@ -618,7 +640,9 @@ describe('Agent.cancel()', () => { // The failed stream's prefix stays off the surface: clients reset it on // retry, and provider failures commit nothing. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') }) @@ -639,12 +663,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const failed = agent.session.snapshotEvents().find(e => e.type === 'assistant/attempt') expect(failed?.type === 'assistant/attempt' ? expandAssistantStream(failed.data.stream).some(member => ( @@ -676,10 +702,12 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const attempt = agent.session.snapshotEvents().find(event => event.type === 'assistant/attempt') expect(attempt?.type === 'assistant/attempt' ? expandAssistantStream(attempt.data.stream).map(member => member.chunk) : []).toContainEqual({ type: 'text-delta', index: 0, text: 'partial before failure' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: failure.message } } }, }) @@ -700,6 +728,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) }) @@ -729,6 +758,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' } }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) }) @@ -764,7 +794,9 @@ describe('Agent.cancel()', () => { expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) }) @@ -818,6 +850,7 @@ describe('Agent.cancel()', () => { // No turn opened, no step streamed, and a later prompt still runs (the marker // was reset). expect(streamed).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(false) }) @@ -849,6 +882,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) }) @@ -872,6 +906,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(3) }) @@ -893,9 +928,11 @@ describe('Agent.cancel()', () => { // started from the dropped steering. await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('idle') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const flat = agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.data.content) @@ -927,6 +964,7 @@ describe('Agent.cancel()', () => { status: agent.status, requests: adapter.requests.length, users: userTexts(agent), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: agent.session.snapshotEvents().map(event => event.type), })}`)) }, 1000) @@ -938,6 +976,7 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(2) expect(userTexts(agent)).toEqual(['original', 'replacement']) expect(agent.inbox.nextTurn).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reasons = agent.session.snapshotEvents() .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) @@ -965,6 +1004,7 @@ describe('Agent.cancel()', () => { const runtimeReason: unknown = adapter.requests[0]?.signal?.reason expect(runtimeReason).toEqual({ kind: 'parent' }) expect(runtimeReason).toBe(supplied) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', @@ -986,6 +1026,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await handle.dispose() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }) }) @@ -1057,6 +1098,7 @@ describe('Agent.cancel()', () => { const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 68cb93f129..841bbbd63f 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -72,6 +72,7 @@ describe('assistant replay provider and model fields', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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, @@ -111,6 +112,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -124,6 +126,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'wake') await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] @@ -174,6 +177,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() expect(events .filter(event => event.type === 'tool/result' @@ -199,9 +203,11 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data) .toEqual({ turn: 1, reason: { kind: 'completed' } }) expect(agent.inbox.nextTurn).toHaveLength(0) @@ -243,19 +249,23 @@ describe('abort during tool execution ends the turn', () => { await started.promise await fiber.dispose() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) .toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' ? [event.data.inserted.map(inboxText)] : []) .at(-1)) .toEqual(['accepted result context during disposal']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')) .toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) @@ -310,6 +320,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] @@ -362,6 +373,7 @@ describe('plugin exceptions are contained', () => { send(agent, 'first') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } }, }) @@ -398,7 +410,9 @@ describe('disposal leaves the two-state status contract balanced', () => { expect(statuses).toEqual(['running', 'idle']) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .flatMap(event => event.data.content) @@ -448,6 +462,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -510,6 +525,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { ['content', 'id', 'role', 'source'], ]) expect(targets).toEqual(['next-turn', 'next-step']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -543,6 +559,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const claims = events.flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' @@ -588,6 +605,7 @@ describe('turn numbering continues across seeded sessions', () => { const { agent: forked } = await ctx2.agents.create({ sessionId: SessionId('forked'), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -656,6 +674,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) @@ -678,6 +697,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' } }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) @@ -708,6 +728,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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = subject.snapshotEvents() const last = events.at(-1) observed.push({ @@ -744,6 +765,7 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const e = agent.session.snapshotEvents() return { turnStart: e.filter(x => x.type === 'turn/start').length, @@ -774,6 +796,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const e = agent.session.snapshotEvents() const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) @@ -807,6 +830,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'rejected') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start' || event.type === 'user/message')).toBe(false) expect(agent.inbox.nextTurn).toHaveLength(1) @@ -838,6 +862,7 @@ describe('turn and step boundary recovery', () => { stepEnd: 0, errors: 1, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } }, }) @@ -931,6 +956,7 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -964,6 +990,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -993,11 +1020,14 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastBoundary = agent.session.snapshotEvents().findLast(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') // loop survives: a second turn runs normally. @@ -1030,6 +1060,7 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const e = agent.session.snapshotEvents() const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') @@ -1064,6 +1095,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1094,6 +1126,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1103,6 +1136,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1135,6 +1169,7 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resultEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { @@ -1199,6 +1234,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1247,6 +1283,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1297,6 +1334,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1344,6 +1382,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1389,6 +1428,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 1fa24977b2..f18e662e61 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -69,12 +69,14 @@ describe('tool JSON parse', () => { await waitForIdle(ctx, agent) // tool/call event should have recorded the raw arguments string + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) @@ -101,6 +103,7 @@ describe('tool JSON parse', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) }) @@ -130,8 +133,11 @@ describe('thrown-value propagation', () => { expect(errors).toHaveLength(1) expect(errors[0]).toBe('naked string error') expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const starts = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ends = agent.session.snapshotEvents().filter(event => event.type === 'turn/end') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents().filter(event => event.type === 'user/message') expect(starts).toHaveLength(0) expect(ends).toHaveLength(0) @@ -155,6 +161,7 @@ describe('thrown-value propagation', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -180,6 +187,7 @@ describe('durable error rendering', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { @@ -236,6 +244,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -262,6 +271,7 @@ describe('request-error action edges', () => { // One failed request, no retry turn. expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ends = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(ends).toHaveLength(1) }) @@ -284,6 +294,7 @@ describe('request-error action edges', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted') }) @@ -313,6 +324,7 @@ describe('stream failure edges', () => { // No facts -> not offered to recovery; the turn fails through settle(). expect(recoveries).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) @@ -388,6 +400,7 @@ describe('tool result meta persistence', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' }) }) @@ -436,6 +449,7 @@ describe('recovery without a retry action', () => { expect(recoveries).toBe(1) expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) @@ -461,6 +475,7 @@ describe('unrenderable failure settlement', () => { send(agent, 'go') await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') { @@ -502,6 +517,7 @@ describe('driver bookkeeping edges', () => { expect(proposals).toBe(2) expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' }) }) @@ -524,8 +540,10 @@ describe('driver bookkeeping edges', () => { send(agent, 'go') await agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/end')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 028c42ca5c..021e2339f7 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -142,6 +142,7 @@ describe('ReactLoopInbox', () => { const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length) expect(childInbox.nextTurn).toEqual([inherited]) @@ -246,6 +247,7 @@ describe('ReactLoopInbox', () => { const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) agent.inbox.append('next-turn', nextTurn) agent.inbox.append('next-step', nextStep) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const beforeClear = session.snapshotEvents().length agent.inbox.clear() @@ -253,6 +255,7 @@ describe('ReactLoopInbox', () => { expect(agent.inbox.nextTurn).toEqual([]) expect(agent.inbox.nextStep).toEqual([]) expect(discarded).toEqual([nextStep, nextTurn]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' ? event.data : event.type)).toEqual([ @@ -261,6 +264,7 @@ describe('ReactLoopInbox', () => { ]) agent.inbox.clear() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index b50fb45942..3decd9499e 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -57,6 +57,7 @@ function send(agent: Agent, text: string) { } function events(agent: Agent): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() } diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb98a37e31..68b8683cb0 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -53,6 +53,7 @@ function systemOf(request: GenerateOptions | undefined): string | undefined { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -72,6 +73,7 @@ describe('agent loop', () => { if (subject !== agent) return frames.push(frame) if (frame.type === 'end' && frame.outcome.kind === 'committed') { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. committedAfterMessage = agent.session.snapshotEvents().at(-1)?.type === 'assistant/message' } }) @@ -91,7 +93,9 @@ describe('agent loop', () => { (frame): frame is Extract => frame.type === 'chunk', ) expect(chunks.map(frame => frame.index)).toEqual(chunks.map((_frame, index) => index)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => (event.type as string) === 'assistant/chunk')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const message = agent.session.snapshotEvents().findLast(event => event.type === 'assistant/message') expect(message?.type === 'assistant/message' ? expandAssistantStream(message.data.stream).map(member => member.chunk) @@ -133,6 +137,7 @@ describe('agent loop', () => { type: 'end', outcome: { kind: 'abandoned' }, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) @@ -160,9 +165,11 @@ describe('agent loop', () => { index: 2, outcome: { kind: 'abandoned' }, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => ( event.type === 'assistant/message' || event.type === 'assistant/attempt' ))).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, @@ -218,6 +225,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(frames).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, @@ -433,6 +441,7 @@ describe('agent loop', () => { expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(0) }) @@ -456,12 +465,14 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -504,6 +515,7 @@ describe('agent loop', () => { expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }]) // session log records call + result + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = agent.session.snapshotEvents().map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') @@ -566,6 +578,7 @@ describe('agent loop', () => { expect(errors.map(error => error.message)).toEqual([ 'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona-prefix")', ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' @@ -583,6 +596,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(systemOf(adapter.requests[0])).toBe('You are an AI agent powered by DeepSeek Harness.\n\nIn /rescued.') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -630,6 +644,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect('system' in adapter.requests[0]!).toBe(false) expect(adapter.requests[0]!.messages.map(message => message.role)).toEqual(['user']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toMatchObject([ { data: { message: { role: 'system', content: [] } }, surfaceOp: 'append' }, ]) @@ -644,6 +659,7 @@ describe('agent loop', () => { const firstIdle = waitForIdle(ctx, agent) send(agent, 'first') await firstIdle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const head = agent.session.snapshotEvents().find(event => event.type === 'system/message') expect(adapter.requests[0]?.messages.map(message => message.role)).toEqual(['user']) expect(head).toMatchObject({ data: { message: { content: [] } }, surfaceOp: 'append' }) @@ -656,12 +672,14 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(2) expect(systemOf(adapter.requests[1])).toBe('You are an AI agent powered by DeepSeek Harness.') expect(adapter.requests[1]?.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replacement = agent.session.snapshotEvents().findLast(event => event.type === 'system/message') expect(replacement).toMatchObject({ surfaceOp: { op: 'replace', startSeq: head?.seq, endSeq: head?.seq }, sourceEventSeqs: [head?.seq], }) expect(agent.session.surface.nodes[0]).toBe(replacement?.seq) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) } finally { @@ -681,6 +699,7 @@ describe('agent loop', () => { let mode = 'read-only' const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` }) const agent = await ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contextEvents = () => agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -722,7 +741,9 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(contextEvents()).toHaveLength(3) expect(adapter.requests.map(systemOf)).toEqual(Array(5).fill(systemOf(adapter.requests[0]))) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -735,6 +756,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -750,6 +772,7 @@ describe('agent loop', () => { send(agent, 'after compaction') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -770,6 +793,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -832,6 +856,7 @@ describe('agent loop', () => { send(agent, 'repair context') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -853,6 +878,7 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') const chunks = message?.type === 'assistant/message' ? expandAssistantStream(message.data.stream) : [] // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7 @@ -887,12 +913,14 @@ describe('agent loop', () => { send(agent, 'start') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const secondStepStart = agent.session.snapshotEvents().filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) @@ -911,11 +939,14 @@ 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ @@ -944,7 +975,9 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(1) expect(agent.inbox.nextStep).toHaveLength(1) @@ -952,6 +985,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) @@ -964,7 +998,9 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'agent/inbox/spliced', data: { @@ -979,6 +1015,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -994,6 +1031,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) @@ -1020,6 +1058,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' } })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, @@ -1032,9 +1071,12 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result')! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1079,6 +1121,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) @@ -1124,6 +1167,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) @@ -1150,6 +1194,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1179,6 +1224,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header') expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) @@ -1218,6 +1264,7 @@ describe('agent loop', () => { let boundaryOpen = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (subject === agent) boundaryOpen = subject.session.snapshotEvents().at(-1)?.type === 'step/start' return next() }) @@ -1250,13 +1297,16 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastTurnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) @@ -1303,6 +1353,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Assert the durable row, not only the live listener. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) @@ -1407,6 +1458,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(executions).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages().slice(1)).toEqual([{ id: expect.any(String) as unknown, @@ -1417,6 +1469,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toMatchObject({ turn: 1, @@ -1457,6 +1510,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'max-tokens' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toMatchObject({ turn: 1, @@ -1492,6 +1546,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'completed' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toMatchObject({ turn: 1, @@ -1535,6 +1590,7 @@ describe('agent loop', () => { send(agent, 'continue') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. @@ -1603,6 +1659,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) @@ -1624,7 +1681,9 @@ describe('agent loop', () => { send(agent, 'outer message') await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) @@ -1643,7 +1702,9 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const sources = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.source) @@ -1699,9 +1760,11 @@ describe('agent loop', () => { send(agent, 'outer message') await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(messages).toEqual([ [{ type: 'text', text: 'outer message' }], @@ -1732,6 +1795,7 @@ describe('agent loop', () => { }) expect(reasons[0]).toMatchObject({ kind: 'error' }) // The durable failure and live relay describe the same failed turn. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' }) }) @@ -1785,6 +1849,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]?.reasoningEffort).toBe(effort) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) @@ -1823,11 +1888,15 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. agent.session.snapshotEvents().map(e => e.type)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 68e170f27e..bf3c723153 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -72,18 +72,21 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () } function userMessageTexts(agent: Agent): string[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'turn/start') .map(e => e.data.turn) } function turnEndNumbers(agent: Agent): number[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'turn/end') .map(e => (e.data as { turn: number }).turn) @@ -91,6 +94,7 @@ function turnEndNumbers(agent: Agent): number[] { function userMessageCountsByTurn(agent: Agent): number[] { const counts: number[] = [] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ceaa2b0b6d..5a1c912bc0 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const usages = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 616a817082..f0ae238d80 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -92,12 +92,14 @@ describe('agent/request-error', () => { code: 'SERVICE_UNAVAILABLE', }, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -115,7 +117,9 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, @@ -137,7 +141,9 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index 560be88627..82261178fe 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -58,6 +58,7 @@ describe('loop-owned request freezing', () => { }), stream: [], }, { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = structuredClone(seed.snapshotEvents()) const userEvent = events.find(event => event.type === 'user/message')! const assistantEvent = events.find(event => event.type === 'assistant/message')! @@ -198,6 +199,7 @@ describe('loop-owned request freezing', () => { expect(() => request.stop!.push('mutate')).toThrow(TypeError) if (!registered) expect(request.stop).toBe(stops[index]) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(agent.session.requestHeader()!.tools).not.toBe(requests[0]!.tools) expect(agent.session.requestHeader()!.config.stop).not.toBe(requests[0]!.stop) @@ -221,6 +223,7 @@ describe('loop-owned request freezing', () => { await agent.whenIdle() expect(request.signal!.aborted).toBe(true) expect(request.signal!.reason).toEqual({ kind: 'user' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 2450a21999..e5ec05cb60 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -94,6 +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). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +112,7 @@ describe('request stability across the loop', () => { expect(adapter.requests).toHaveLength(2) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -132,6 +134,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -157,6 +160,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ @@ -194,6 +198,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -223,6 +228,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ ReasoningEffortId('high'), @@ -242,6 +248,7 @@ describe('request stability across the loop', () => { const resumedCtx = await harness(resumedAdapter) const resumedHandle = await resumedCtx.agents.create({ sessionId: SessionId(`effort-${model}`), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: structuredClone(agent.session.snapshotEvents()), agentOptions: { provider: 'mock', model }, }) @@ -250,6 +257,7 @@ describe('request stability across the loop', () => { expect(resumedAdapter.requests[0]?.model).toBe(model) expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +276,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests[0]?.maxTokens).toBe(256_000) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -299,6 +308,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(256_000) expect(other.requests[0]?.maxTokens).toBe(8_192) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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([ @@ -333,6 +343,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(4_096) expect(other.requests[0]?.maxTokens).toBe(4_096) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +396,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ]) expect(second.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) }) @@ -423,6 +435,7 @@ describe('request stability across the loop', () => { expect(signal.aborted).toBe(true) expect(handle.agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(handle.agent.session.snapshotEvents().some(event => event.type === 'request/header')).toBe(false) }) @@ -446,6 +459,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: failure instanceof LlmError @@ -521,6 +535,7 @@ describe('request stability across the loop', () => { // The rewritten history: summary replaces turn 1's user+assistant pair behind the system prompt. expect(second.messages[0]!.role).toBe('system') expect(second.messages[1]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ @@ -559,6 +574,7 @@ describe('request stability across the loop', () => { expect(adapter.requests[1]?.messages[1]?.content).toContainEqual({ type: 'text', text: '[summary for retry]', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -572,6 +588,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) @@ -579,8 +596,10 @@ describe('request stability across the loop', () => { send(agent, 'third') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const snapshots = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots.map(event => event.data.reason)).toEqual(['initial', 'series']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) @@ -610,8 +629,10 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) // No new series: the header stays, node 0 stays, and the prompt update follows the cached prefix. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toBe('append') @@ -625,6 +646,7 @@ describe('request stability across the loop', () => { // An unchanged prompt adds nothing on the next step. send(agent, 'fourth') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(e => e.type === 'system/message')).toHaveLength(2) expectPrefixExtension(adapter.requests[2]!, adapter.requests[3]!) }) @@ -648,9 +670,11 @@ describe('request stability across the loop', () => { startSeries = true send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. let systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) @@ -660,6 +684,7 @@ describe('request stability across the loop', () => { disposeSection = ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'newer guidance' }) send(agent, 'third') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(3) expect(systemNodes[2]?.surfaceOp).toBe('append') @@ -670,6 +695,7 @@ describe('request stability across the loop', () => { ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'newest guidance' }) send(agent, 'fourth') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(5) expect(systemNodes[3]?.data.message.content).toEqual([]) @@ -703,10 +729,12 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) expect(adapter.requests[1]!.messages.map(message => message.role)).toEqual(['system', 'user', 'user']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -725,8 +753,10 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headers = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(headers.map(event => [event.data.reason, event.data.startsSeries])).toEqual([['initial', undefined], ['change', true]]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) @@ -752,6 +782,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') @@ -779,6 +810,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -798,6 +830,7 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -805,11 +838,13 @@ describe('request stability across the loop', () => { send(agent2, 'second') await waitForIdle(ctx2, agent2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') // Identical header and an unchanged system node across the restart: byte-identical continuation. expect(adapter2.requests[0]!.messages[0]).toEqual(adapter.requests[0]!.messages[0]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent2.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!) }) @@ -838,6 +873,7 @@ describe('request stability across the loop', () => { // The second turn reuses the same series and header; the session's own // fold remains immutable state. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) @@ -864,6 +900,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(3) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const stepStarts = events.filter(e => e.type === 'step/start') expect(stepStarts).toHaveLength(3) @@ -920,6 +957,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) @@ -945,6 +983,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data.contextWindow)).toEqual([64_000, 256_000]) @@ -957,6 +996,7 @@ describe('request/context capacity records', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }]) @@ -977,6 +1017,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([ diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 271849eba3..838c3e7004 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -484,6 +484,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // Lifecycle 2: resume repairs the tail and stores the repair durably. const ctx2 = await mountPersistentHarness(root, new MockAdapter([])) const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(handle.agent.session.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'turn/end', 'session/end-seed']) await handle.dispose() @@ -552,6 +553,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // closers immediately after the committed prefix — no gap, no fragment. const ctx2 = await mountPersistentHarness(root, new MockAdapter([]), 'none') const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(handle.agent.session.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'turn/end', 'session/end-seed']) await handle.dispose() @@ -639,6 +641,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setup: async (agentCtx, agent) => { expect(agent.id).toBe(sessionId) // The two persisted events plus the end-seed marker. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(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')) @@ -691,6 +694,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed']) const second = await ctx.agents.resume({ resumeSessionId: sessionId }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(second.agent.session.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'turn/end', 'session/end-seed']) await second.dispose() @@ -983,6 +987,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = h1.agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events1 = a1.session.snapshotEvents() const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous @@ -996,8 +1001,10 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(a2.session.snapshotEvents().length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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()) @@ -1005,8 +1012,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // …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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const allSeqs = a2.session.snapshotEvents().map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 81bfc64abd..851701a646 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -809,6 +809,7 @@ describe('agent scope lifecycle', () => { expect(statuses).toEqual([]) expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -1114,8 +1115,10 @@ 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const eventsAfter = agent.session.snapshotEvents().length await new Promise(resolve => setTimeout(resolve, 30)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().length).toBe(eventsAfter) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index d3702147f6..e57df3e855 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -39,6 +39,7 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () => ctx.on('llm/stream', (request, next) => { // Rebuild from copied source events, not the live projection's cached state. const subject = ctx.agents.get(request.sessionId!)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replay = Session.create(subject.id, subject.session.snapshotEvents()) expect(request.messages).toEqual(replay.deriveMessages()) expect(request.system).toBeUndefined() @@ -83,21 +84,26 @@ describe('prepared-route prompt admission', () => { expect(systemTexts(cleared)).toEqual([]) expect(toPiContext(cleared).systemPrompt).toBeUndefined() expect(JSON.stringify(toPiContext(cleared))).not.toContain('prompt ') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const clearEvents = h.agent.session.snapshotEvents().filter(event => event.type === 'system/message').filter(event => event.data.turn === 4) expect(clearEvents).toHaveLength(3) for (const event of clearEvents) { expect(event.data.message.content).toEqual([]) expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: event.sourceEventSeqs?.[0], endSeq: event.sourceEventSeqs?.[0] }) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const count = h.agent.session.snapshotEvents().filter(event => event.type === 'system/message').length await send(h.agent, 'still clear') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(h.agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) expect(systemTexts(adapter.requests.at(-1)!)).toEqual([]) const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('cleared-resume'), agentOptions: { provider, model: 'model' }, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: [...h.agent.session.snapshotEvents()], }) await send(resumed, 'resume clear') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) expect(systemTexts(adapter.requests.at(-1)!)).toEqual([]) h.setPrompt('restored instruction') @@ -124,6 +130,7 @@ describe('prepared-route prompt admission', () => { } await send(h.agent, 'third') expectPlain(h.capable.requests[2]!, 'prompt two') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const header = h.agent.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1) expect(header?.data.reason).toBe(reason === 'explicit' ? 'series' : 'change') if (reason === 'tools') expect(header?.data.startsSeries).toBe(true) @@ -147,6 +154,7 @@ describe('prepared-route prompt admission', () => { expect(failure.code).toBe('CONTEXT_LENGTH') if (attempts++ === 0) { const nodes = h.agent.session.surface.nodes + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const latest = nodes.findLast(seq => h.agent.session.eventAt(seq)?.type === 'system/message')! const start = retainOlder ? latest : nodes[1]! const replaced = nodes.slice(nodes.indexOf(start), nodes.indexOf(latest) + 1) @@ -166,6 +174,7 @@ describe('prepared-route prompt admission', () => { expectPlain(adapter.requests[3]!, 'prompt three') expect(adapter.requests[4]!.messages).toEqual(adapter.requests[3]!.messages) expect(adapter.requests[3]!.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'third' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = h.agent.session.snapshotEvents() expect(events.filter(event => event.type === 'step/start')).toHaveLength(3) expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(3) @@ -183,6 +192,7 @@ describe('prepared-route prompt admission', () => { if (changed) h.setPrompt('prompt three') await send(h.agent, 'third') expectPlain(h.plain.requests[0]!, changed ? 'prompt three' : 'prompt two') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = h.agent.session.snapshotEvents() const replacements = events.filter(event => event.type === 'system/message').filter(event => event.surfaceOp !== 'append') expect(replacements).toHaveLength(2) @@ -191,6 +201,7 @@ describe('prepared-route prompt admission', () => { expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: event.sourceEventSeqs?.[0], endSeq: event.sourceEventSeqs?.[0] }) } await send(h.agent, 'fourth') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(h.agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(4) expectPlain(h.plain.requests[1]!, changed ? 'prompt three' : 'prompt two') }) @@ -214,6 +225,7 @@ describe('prepared-route prompt admission', () => { expect(messages.slice(-2).map(message => message.content)).toEqual([ [{ type: 'text', text: 'second' }], notice, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const notices = h.agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'model-selection') expect(notices).toHaveLength(1) @@ -230,10 +242,12 @@ describe('prepared-route prompt admission', () => { await send(h.agent, 'second') const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('resumed-capable'), agentOptions: { provider: 'capable', model: 'model' }, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: [...h.agent.session.snapshotEvents()], }) h.ctx.on('agent/pre-step', ({ agent }, next) => { if (agent === resumed && replace) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seq = agent.session.surface.nodes.find(seq => agent.session.eventAt(seq)?.type === 'user/message')! agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'test-compaction' }, @@ -245,6 +259,7 @@ describe('prepared-route prompt admission', () => { expect(systemTexts(h.capable.requests[2]!)).toEqual(replace ? [[{ type: 'text', text: 'prompt two' }]] : [[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }]]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').map(event => event.data.reason)) .toEqual(['initial', 'resume']) }) @@ -254,10 +269,12 @@ describe('prepared-route prompt admission', () => { await send(h.agent, 'first') h.setPrompt('prompt two') await send(h.agent, 'second') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = h.agent.session.snapshotEvents() const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('resumed'), agentOptions: { provider: 'plain', model: 'model' }, seed: [...seed] }) await send(resumed, 'resume') expectPlain(h.plain.requests[0]!, 'prompt two') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1)?.data.reason).toBe('resume') }) @@ -266,6 +283,7 @@ describe('prepared-route prompt admission', () => { const entered = Promise.withResolvers() const release = Promise.withResolvers() const observe = async () => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = h.agent.session.snapshotEvents().map(event => event.type) expect(types).toContain('step/start') expect(types).not.toContain('system/message') @@ -285,6 +303,7 @@ describe('prepared-route prompt admission', () => { release.resolve(undefined) await h.agent.whenIdle() expect(h.capable.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = h.agent.session.snapshotEvents().map(event => event.type) expect(types.filter(type => type === 'step/start' || type === 'step/end')).toEqual(['step/start', 'step/end']) expect(types.filter(type => ['system/message', 'user/message', 'request/header'].includes(type))).toEqual([]) @@ -303,6 +322,7 @@ describe('prepared-route prompt admission', () => { expect(h.plain.requests).toHaveLength(0) expect(h.capable.requests[1]).toMatchObject({ provider: 'capable', temperature: 0.5, maxTokens: 100 }) expect(systemTexts(h.capable.requests[1]!)).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(h.agent.session.snapshotEvents().filter(event => event.type === 'user/message')).toHaveLength(2) }) }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 74871d4a0d..dc6f783139 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -42,6 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() } diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 287f5fe761..e4c05be289 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -69,6 +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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -82,6 +83,7 @@ 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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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)) @@ -89,6 +91,7 @@ describe('loop-level canonical tool order', () => { 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]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -103,10 +106,15 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldRequestHeader(agent.session.snapshotEvents())).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'step/start')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'step/end')).toBe(false) }) }) diff --git a/packages/core/agent/tests/consumed-work.spec.ts b/packages/core/agent/tests/consumed-work.spec.ts index 26808c5c81..221a5f7d0f 100644 --- a/packages/core/agent/tests/consumed-work.spec.ts +++ b/packages/core/agent/tests/consumed-work.spec.ts @@ -40,6 +40,7 @@ describe('foldConsumedWork', () => { const session = Session.create(SessionId('empty')) accept(session, 'queued') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents())).toEqual({ droppedUnrun: false }) }) @@ -48,6 +49,7 @@ describe('foldConsumedWork', () => { steppedTurn(session, 1, { kind: 'completed' }) steppedTurn(session, 2, { kind: 'max-tokens' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data) .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) }) @@ -61,6 +63,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) @@ -71,6 +74,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) @@ -85,6 +89,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) @@ -97,6 +102,7 @@ describe('foldConsumedWork', () => { // Rejection does not retain the claimed messages, so the `blocked` end is // the only account of input that will never run. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) @@ -109,6 +115,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) @@ -120,6 +127,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) @@ -130,7 +138,9 @@ describe('foldConsumedWork', () => { cancelPending(session) // No turn opened over it, so only the cancellation says the work was cut short. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. end: session.snapshotEvents().find(event => event.type === 'turn/end'), droppedUnrun: true, }) @@ -143,6 +153,7 @@ describe('foldConsumedWork', () => { target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).droppedUnrun).toBe(false) }) @@ -152,7 +163,9 @@ describe('foldConsumedWork', () => { cancelPending(session) steppedTurn(session, 2, { kind: 'completed' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. end: session.snapshotEvents().findLast(event => event.type === 'turn/end'), droppedUnrun: false, }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 68577578dd..dbf124893e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -649,6 +649,7 @@ export class Session { * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return this.snapshotEvents(this.inheritedEventCount) } @@ -1214,6 +1215,7 @@ export class SessionStore extends Service { } private _forkSeed(session: Session, requestedBoundary: SessionSeq | undefined): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastEvent = session.snapshotEvents().at(-1) let boundary: SessionSeq if (requestedBoundary !== undefined) { @@ -1236,6 +1238,7 @@ export class SessionStore extends Service { ) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundaryEvent = session.eventAt(boundary) if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) { throw new SessionForkError( @@ -1243,6 +1246,7 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1)) const lastTurnBoundary = events .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 6ed0b6b3c5..be4b214c86 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -210,6 +210,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seedSession = (session: Session): SessionTrace => { const trace = freshTrace() traces.set(session, trace) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { applyTransition(trace, validateEvent(trace, event, fail)) } diff --git a/packages/core/session/tests/canonical-envelopes.spec.ts b/packages/core/session/tests/canonical-envelopes.spec.ts index 123852762d..3177f333d6 100644 --- a/packages/core/session/tests/canonical-envelopes.spec.ts +++ b/packages/core/session/tests/canonical-envelopes.spec.ts @@ -121,6 +121,7 @@ describe('canonical event payload acceptance', () => { expect(copy).toEqual(event) if (path === 'adopt' || path === 'snapshot') expect(accept(copy)).toEqual(event) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(id, [event]).eventAt(SessionSeq(0))).toEqual(event) } }) @@ -178,6 +179,7 @@ describe('canonical event payload acceptance', () => { const prepared = ctx.sessions.prepare(id, { seed: [event], meta: { ...header }, inheritedEventCount: SessionLogOffset(0), eventState, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(prepared.eventAt(SessionSeq(0))).toBe(event) expect(prepared.requestHeader()).toEqual({ config }) expect(ctx.sessions.get(id)).toBeUndefined() @@ -203,6 +205,7 @@ describe('canonical event payload acceptance', () => { const published = vi.fn() ctx.on('session/created', created) ctx.on('session/event', published) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() const messages = session.deriveMessages() for (const { event } of invalidData) { @@ -210,6 +213,7 @@ describe('canonical event payload acceptance', () => { expect(() => ctx.sessions.create(SessionId('invalid-seed'), { seed: [event] })).toThrow() expect(ctx.sessions.get(SessionId('invalid-seed'))).toBeUndefined() expect(session.seq).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toBe(events) expect(session.surface.nodes).toEqual([]) expect(session.surface.replaceGeneration).toBe(0) @@ -279,6 +283,7 @@ describe('canonical event-local surface metadata', () => { expect(() => accept(structuredClone(event))).not.toThrow() } const session = Session.create(id, [event]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(0))).toEqual(event) expect(session.surface.nodes).toEqual([]) expect(session.deriveMessages()).toEqual([]) diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 21d2b7153b..35bf0fa15d 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,6 +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 { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), session.snapshotEvents()).deriveMessages() } diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index d67538ded8..a62019e23c 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -47,6 +47,7 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m } function lastSeq(session: Session): SessionSeq { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().at(-1) if (event === undefined) throw new Error('missing last event') return event.seq @@ -54,6 +55,7 @@ function lastSeq(session: Session): SessionSeq { /** A seeded child's fork-inherited prefix. */ function inherited(session: Session): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents(SessionLogOffset(0), session.inheritedEventCount) } @@ -81,13 +83,19 @@ describe('SessionStore.fork', () => { const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(inherited(child)).toEqual(source.snapshotEvents()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.snapshotEvents()).not.toBe(source.snapshotEvents()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.snapshotEvents()[1]).not.toBe(source.snapshotEvents()[1]) expect(() => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. firstUserMessage(child.snapshotEvents()).data.content[0] = { type: 'text', text: 'child mutation' } }).toThrow(TypeError) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), @@ -106,6 +114,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('log-only-child')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(inherited(child)).toEqual(source.snapshotEvents()) expect(inherited(child).at(-1)).toMatchObject({ type: 'test/log-only', @@ -123,6 +132,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(inherited(child)).toEqual(source.snapshotEvents().slice(0, firstBoundary + 1)) expect(child.inheritedEventCount).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ @@ -166,9 +176,12 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.snapshotEvents().at(-1)).toBe(open) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = child.snapshotEvents().at(-1) expect(boundary).toMatchObject({ type: 'session/end-seed' }) expect(boundary!.seq).toBeGreaterThan(open.seq) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 9b8b281cf5..778e4b560f 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -83,6 +83,7 @@ describe('session-log invariants', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('later dispatch veto') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) expect(() => { session.append('turn/start', { turn: 1 }) @@ -428,6 +429,7 @@ describe('session-log invariants', () => { const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, ] }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 })) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 07bf37ac02..200a1107f0 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -107,6 +107,7 @@ describe('Session properties', () => { it('seq is strictly monotonic and zero-based contiguous', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents().forEach((event, i) => { expect(event.seq).toBe(i) }) expect(session.seq).toBe(events.length) })) @@ -115,9 +116,11 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) })) @@ -126,9 +129,12 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const once = Session.create(SessionId(`idem-a-${counter++}`), original.snapshotEvents()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const twice = Session.create(SessionId(`idem-b-${counter++}`), once.snapshotEvents()) // Lazy resume makes browsing a pickup, so this must not grow per open. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) }) @@ -162,6 +168,7 @@ describe('Session properties', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) const messages = session.deriveMessages() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = structuredClone(session.snapshotEvents()) for (const m of messages) { expect(['user', 'assistant', 'system']).toContain(m.role) @@ -170,6 +177,7 @@ describe('Session properties', () => { expect(Object.isFrozen(m)).toBe(true) expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 c7b2719b6e..5aa4a5695b 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -80,6 +80,7 @@ describe('foldRequestHeader', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('request/header', { header: { config: { provider: 'mock', model: 'other' } }, reason: 'change' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldRequestHeader(session.snapshotEvents())).toEqual({ config: { provider: 'mock', model: 'other' } }) }) }) diff --git a/packages/core/session/tests/sequence-types.spec.ts b/packages/core/session/tests/sequence-types.spec.ts index d20d2c721f..b6aaee8a16 100644 --- a/packages/core/session/tests/sequence-types.spec.ts +++ b/packages/core/session/tests/sequence-types.spec.ts @@ -69,11 +69,13 @@ describe('Session log positions', () => { isSeeded: true, } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = Session.create(id, source.snapshotEvents(), header, source.seq) expect(child.header.isSeeded).toBe(true) expect('seedLength' in child.header).toBe(false) expect(child.inheritedEventCount).toBe(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.ownEvents().map(event => event.type)).toEqual(['session/end-seed']) expect(child.isOwnSeq(SessionSeq(1))).toBe(false) expect(child.isOwnSeq(SessionSeq(2))).toBe(true) @@ -90,6 +92,7 @@ describe('Session log positions', () => { parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const id = SessionId('suffix-child') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = Session.create(id, parent.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id, @@ -98,6 +101,7 @@ describe('Session log positions', () => { }, parent.seq) child.append('request/context', { provider: 'provider', model: 'model' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.ownEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'request/context', diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d6762ba147..ed5dfe46d4 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -71,6 +71,7 @@ describe('Session', () => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -81,8 +82,11 @@ 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' } } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.snapshotEvents())) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, -1)).toEqual(session.snapshotEvents()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = replayed.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) @@ -115,6 +119,7 @@ describe('Session', () => { session.append('user/message', message, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual([message]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents()[0] expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) }) @@ -139,9 +144,11 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) expect(replayed.firstLiveSeq).toBe(original.seq) @@ -149,16 +156,20 @@ describe('Session', () => { it('marks an explicitly empty seed without marking a fresh session', () => { const fresh = Session.create(SessionId('fresh-empty')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) expect(resumed.firstLiveSeq).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reopened = Session.create(SessionId('reopened-empty'), resumed.snapshotEvents()) expect(reopened.firstLiveSeq).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(reopened.snapshotEvents()).toEqual(resumed.snapshotEvents()) }) @@ -188,6 +199,7 @@ describe('Session', () => { const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).snapshotEvents().slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -250,6 +262,7 @@ describe('Session', () => { SessionLogOffset(0), 'detached', ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(restored.eventAt(SessionSeq(0))).toBe(mismatchedMessage) expect(Object.isFrozen(mismatchedMessage)).toBe(false) }) @@ -519,6 +532,7 @@ describe('Session', () => { reason: 'initial', }, } as const + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('reasoning-effort'), [valid]).snapshotEvents()[0]) .toEqual(valid) @@ -549,6 +563,7 @@ describe('Session', () => { reason: 'initial', }, } as const + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('adapter-defaults'), [valid]).snapshotEvents()[0]).toEqual(valid) for (const adapterDefaults of [ @@ -579,6 +594,7 @@ describe('Session', () => { isError: false, }), }, { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = structuredClone(session.snapshotEvents()) // A misbehaving consumer tries to mutate the messages it was handed. @@ -595,6 +611,7 @@ describe('Session', () => { messages.reverse() // The log is unchanged: deep-equal to the snapshot taken before mutation. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -624,6 +641,7 @@ describe('Session', () => { cyclic['self'] = cyclic expect(bad(cyclic)).toThrow(/non-JSON-serializable/) // The rejected appends never entered the log. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(0) }) @@ -638,12 +656,14 @@ describe('Session', () => { }))) .toThrow(/surface-eligible and requires a surfaceOp marker/) // The rejected append never entered the log (only turn/start is present). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(1) }) @@ -688,6 +708,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(0, 3)).toEqual(goodSeed) expect(session.firstLiveSeq).toBe(3) }) @@ -713,6 +734,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(0, 1)).toEqual([accepted]) }) @@ -730,6 +752,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-nested-drift'), seed) expect(reads).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()[0]!.data).toEqual({ value: 'accepted' }) }) @@ -791,6 +814,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-null-prototype'), [event]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(0, 1)).toEqual([{ ...event }]) }) @@ -823,6 +847,7 @@ describe('Session', () => { }] as unknown as SessionEvent[] const session = Session.create(SessionId('seed-unstable-metadata'), seed) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents()[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') @@ -883,6 +908,7 @@ describe('Session', () => { 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -901,6 +927,7 @@ describe('Session', () => { // make session.snapshotEvents() diverge from the value that passed validation. data.content[0]!.text = 'HACKED' ;(data as Record)['injected'] = 1n + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -923,6 +950,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.data).toEqual({ value: 'accepted' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([event]) }) @@ -936,6 +964,7 @@ describe('Session', () => { }), { surfaceOp: { op: 'replace', startSeq: 1n, endSeq: 2 } } as never, )).toThrow(/non-JSON-serializable surface metadata/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -954,6 +983,7 @@ describe('Session', () => { }), { surfaceOp: new ReplaceOp() }, )).toThrow(/non-JSON-serializable surface metadata/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -985,6 +1015,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([source, event]) }) @@ -1006,6 +1037,7 @@ describe('Session', () => { surfaceOp: 'append', sourceEventSeqs: [0, -1], })).toThrow(/non-negative safe integers/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -1029,6 +1061,7 @@ describe('Session', () => { data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -1039,6 +1072,7 @@ describe('Session', () => { time: 1, data: { turn: 1 }, }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1059,20 +1093,24 @@ 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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents() const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const after = session.snapshotEvents() expect(before).toHaveLength(1) expect(after).toHaveLength(2) expect(after).not.toBe(before) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toBe(after) }) @@ -1081,10 +1119,14 @@ describe('Session', () => { const start = session.append('turn/start', { turn: 1 }) const end = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(0))).toBe(start) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(1))).toBe(end) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(2))).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const range = session.snapshotEvents(SessionLogOffset(1), SessionLogOffset(2)) expect(range).toEqual([end]) expect(Object.isFrozen(range)).toBe(true) @@ -1230,6 +1272,7 @@ describe('Session', () => { const marked = Session.create(SessionId('ignorable-envelope'), [ { ...base, ignorable: true } as SessionEvent, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(marked.snapshotEvents()[0]?.ignorable).toBe(true) }) }) @@ -1274,6 +1317,7 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const forked = ctx.sessions.create(SessionId('fork'), { seed: a.snapshotEvents() }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -1537,6 +1581,7 @@ describe('SessionStore', () => { const heard: SessionEvent[] = [] let committedBeforeNotify = false ctx.on('session/event', (observedSession, event) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. committedBeforeNotify = observedSession.snapshotEvents().at(-1) === event throw new Error('sync event observer') }) @@ -1550,6 +1595,7 @@ describe('SessionStore', () => { }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) await Promise.resolve() @@ -1573,6 +1619,7 @@ describe('SessionStore', () => { const [observedSession, event] = args as [Session, SessionEvent] validations.push({ event, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. logLength: observedSession.snapshotEvents().length, frozen: Object.isFrozen(event) && Object.isFrozen(event.data), }) @@ -1586,6 +1633,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('reject first candidate') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) @@ -1598,6 +1646,7 @@ describe('SessionStore', () => { ]) expect(validations.map(({ event }) => event.seq)).toEqual([0, 0]) expect(validations[1]!.event).toBe(appended) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([appended]) expect(observed).toEqual([appended]) }) @@ -1629,6 +1678,7 @@ describe('SessionStore', () => { sourceEventSeqs: [SessionSeq(2)], })).toThrow('reject surface candidate') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(3) expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) @@ -1654,6 +1704,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('dispatch instrumentation rejected the carrier') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) }) @@ -1673,6 +1724,7 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) expect(warnings).toEqual([ @@ -1704,6 +1756,7 @@ describe('SessionStore', () => { turn: 1, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 6d10e22335..5436777e22 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -328,6 +328,7 @@ describe('SurfaceManager', () => { sourceEventSeqs: sourceSeqs(2, 1), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const folded = foldSurface(s.snapshotEvents()) expect(folded.nodes).toEqual(s.surface.nodes) expect(folded.replacements).toEqual([ @@ -337,7 +338,9 @@ describe('SurfaceManager', () => { folded.nodes[0] = SessionSeq(99) folded.replacements[0]!.shadowedSeqs.push(SessionSeq(99)) expect(s.surface.nodes).toEqual([3]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0]) }) @@ -354,6 +357,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(s.snapshotEvents()).replacements).toEqual([ { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, ]) @@ -388,6 +392,7 @@ describe('SurfaceManager', () => { const surface = s.surface const nodes = surface.nodes + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) expect(surface.replaceGeneration).toBe(0) @@ -408,10 +413,12 @@ describe('SurfaceManager', () => { { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }) }, )).toThrow(/missing 0/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(s.snapshotEvents()).toHaveLength(1) expect(s.surface).toBe(surface) expect(surface.nodes).toEqual([0]) expect(surface.replaceGeneration).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) s.append('user/message', createUserMessage({ @@ -420,6 +427,7 @@ describe('SurfaceManager', () => { expect(surface.nodes).toBe(nodes) expect(surface.nodes).toEqual([0, 1]) expect(surface.replaceGeneration).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) }) @@ -493,6 +501,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('replay'), original.snapshotEvents()) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) @@ -594,6 +603,7 @@ describe('SurfaceManager', () => { // Mutate caller's array after append. sources.push(SessionSeq(1)) sources[0] = SessionSeq(99) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([0]) }) @@ -629,6 +639,7 @@ describe('SurfaceManager', () => { }) // Mutate caller's object after append. op.startSeq = SessionSeq(99) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 }) }) @@ -719,7 +730,9 @@ describe('Session.append surface opts', () => { expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append') }) @@ -753,7 +766,9 @@ 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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[0] as SessionEvent).sourceEventSeqs).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[0] as SessionEvent).surfaceOp).toBeUndefined() }) @@ -806,12 +821,14 @@ describe('surface type guards', () => { it('isSurfaceEvent narrows a fully-formed surface event', () => { const s = surfaceSession() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! expect(isSurfaceEvent(turnStart)).toBe(false) }) @@ -835,7 +852,9 @@ describe('surface type guards', () => { s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' }, }), { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const appended = s.snapshotEvents().find(e => e.type === 'user/message')! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replacement = s.snapshotEvents().at(-1)! expect(isAppendSurfaceEvent(appended)).toBe(true) @@ -846,6 +865,7 @@ describe('surface type guards', () => { it('rejects log-only and markerless events from both marker guards', () => { const s = surfaceSession() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 2fb5b5878c..d5edf99697 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -58,6 +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()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { validateDispatch(session, event) commitDispatch(session, event) diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 202963379e..795bef2ad8 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -152,6 +152,7 @@ describe('tool-pipeline invariants', () => { name: 'echo', arguments: {}, })).toThrow(/parentCallId child does not belong to rootCallId another-root/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'tool/ptc-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 11bf14e01a..2f2dfebd1a 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -1484,6 +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' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const dispatch = session.snapshotEvents().find(event => event.type === 'tool/ptc-dispatch') if (dispatch === undefined) throw new Error('expected a durable tool/ptc-dispatch event') const logged = dispatch.data.arguments as { nested: JsonValue } diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index acd842308a..6afc7f441b 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -299,6 +299,7 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(session.inheritedEventCount) 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 3d0ca8372e..ae52c741c2 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -375,6 +375,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(session.inheritedEventCount) if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue @@ -482,6 +483,7 @@ export class TeamRoster { /** Whether a Session's own suffix identifies a provider-owned subagent child. */ private subagentDescriptor(agent: Agent): boolean { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.inheritedEventCount)) !== undefined } } diff --git a/packages/experimental/agent-team/tests/invariant.spec.ts b/packages/experimental/agent-team/tests/invariant.spec.ts index 5dd8dba256..df9ec3b3a7 100644 --- a/packages/experimental/agent-team/tests/invariant.spec.ts +++ b/packages/experimental/agent-team/tests/invariant.spec.ts @@ -44,6 +44,7 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(invalid.snapshotEvents()).toEqual([]) }) @@ -69,6 +70,7 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 5237fc0301..c7d4414792 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -31,6 +31,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected @@ -158,6 +159,7 @@ async function persistedChild( // Live sessions persist only through an attached agent-loop writer; this // bare fixture session seeds its durable log directly for the cold restart. const handle = await ctx.sessionPersistence.create(child.header) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await handle.append(child.snapshotEvents()) await handle.close() return child @@ -393,6 +395,7 @@ for (const backend of backends) { const checkpointEntered = Promise.withResolvers() const releaseCheckpoint = Promise.withResolvers() const delayedCheckpoint = vi.spyOn(second.ctx.sessions, 'flush').mockImplementation(async (session) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (session.id === rootId && session.snapshotEvents().some(event => event.type === 'team/message/delivered' && event.data.messageId === messageId)) { checkpointEntered.resolve(undefined) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 79354a6427..d19f1522b7 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -35,6 +35,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected @@ -270,6 +271,7 @@ describe('Team identity and provisioning', () => { // Live sessions persist only through an attached agent-loop writer; this // bare fixture session seeds its durable log directly for the cold reread. const persisted = await ctx.sessionPersistence.create(liveSession.header) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await persisted.append(liveSession.snapshotEvents()) await persisted.close() await liveFiber.dispose() @@ -469,6 +471,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'), + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: lead.session.snapshotEvents(), meta: { parentSession: lead.id, isSeeded: true }, inheritedEventCount: SessionLogOffset(lead.session.seq), @@ -951,6 +954,7 @@ describe('Team mailbox and waiting', () => { }) await expect(teamInternals(ctx).mailbox.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(lead.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(input => input.source.kind === 'team-message' && input.source.messageId === message.id))).toBe(true) @@ -995,12 +999,14 @@ describe('Team mailbox and waiting', () => { 'team/message/delivered', ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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([]) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1202,6 +1208,7 @@ describe('Team mailbox and waiting', () => { expect(later.status).toBe('accepted') const target = await waitRunning(ctx, started.member.id) await vi.waitFor(() => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const accepted = target.session.snapshotEvents().flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.flatMap(message => message.source.kind === 'team-message' ? [message.source.messageId] diff --git a/packages/experimental/agent-team/tests/test-session-query.ts b/packages/experimental/agent-team/tests/test-session-query.ts index 233c493ad2..3b7554873a 100644 --- a/packages/experimental/agent-team/tests/test-session-query.ts +++ b/packages/experimental/agent-team/tests/test-session-query.ts @@ -33,6 +33,7 @@ export class TestSessionQuery extends SessionQueryEngine { options: SessionObservationOptions = {}, ): Promise { const live = this.ctx.sessions.get(sessionId) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (live !== undefined) return cut('live', live.header, live.snapshotEvents()) const handle = await this.ctx.sessionPersistence.open( sessionId, diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 6ae2fddca3..74a8175fcd 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -148,6 +148,7 @@ describe('dsh-tool-team', () => { expect(childAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) .toEqual(TOOL_NAMES) expect(renderPrompt(childAssembly)).toContain('Your Team role is teammate; your Team name is tool-worker') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const initialPrompt = child.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') expect(initialPrompt?.type === 'user/message' diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index e59020bfc6..40ef1755b4 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -73,6 +73,7 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s /** Authoritative feedback payloads in log order. */ function feedbackTexts(session: Session): string[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents() .filter(event => event.type === 'feedback/record') .map(event => event.data.text) @@ -107,14 +108,17 @@ describe('/feedback human command', () => { text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run') expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ') }) @@ -125,6 +129,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'feedback/record', 'command/done', ]) @@ -161,10 +166,12 @@ 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of test.session.snapshotEvents()) { expect('surfaceOp' in event).toBe(false) expect(test.session.deriveEventMessage(event)).toBeNull() } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([]) expect(test.session.surface.nodes).toEqual([]) expect(test.session.deriveMessages()).toEqual([]) @@ -180,8 +187,10 @@ describe('/feedback human command', () => { await expect(run(test, ' \n\t ')).resolves.toEqual(expected) expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const done = test.session.snapshotEvents().filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of test.session.snapshotEvents()) { if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) } @@ -193,6 +202,7 @@ 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 6ab826e516..6867625302 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -102,12 +102,16 @@ describe('/feedback real Loader composition through cordis.yml', () => { }) // The domain event owns the payload; generic command bookkeeping omits it. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().map(event => event.type)) .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const run = owner.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(JSON.stringify(owner.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts index 7530ccaa70..2b2fc5ccee 100644 --- a/packages/feedback/message-feedback/src/index.ts +++ b/packages/feedback/message-feedback/src/index.ts @@ -226,10 +226,12 @@ export class MessageFeedbackService extends TypertRemoteService { } const live = this.ctx.sessions.get(sessionId) if (live !== undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return operation(live.snapshotEvents(), async (event) => { if (event !== undefined) { live.append(event.type, event.data) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const last = live.snapshotEvents().at(-1) if (!(await this.ctx.sessions.flush(live))) { throw new Error( diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index b2ac2f1943..8f3f6c3bb8 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -186,6 +186,7 @@ class TestPersistence extends SessionPersistence { } persist(session: Session): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. this.durable.set(session.id, { meta: session.header, events: [...session.snapshotEvents()] }) } diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 7a94fefca2..039c0b1388 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,6 +62,7 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) const corruption = new Error('stored log checksum mismatch') persistence.readFailure = corruption @@ -414,6 +415,7 @@ describe('MessageFeedbackService item concurrency', () => { const replacement = Session.create( old.session.id, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. old.session.snapshotEvents(), { ...old.session.header, createdAt: 20, cwd: '/new' }, ) @@ -493,6 +495,7 @@ describe('canonical message feedback history', () => { const sessionId = fixture.session.id const messageId = fixture.assistantMessageIds[0] persistence.persist(fixture.session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const prefix = fixture.session.snapshotEvents() const lifecycle: string[] = [] ctx.on('session/created', () => { lifecycle.push('created') }) @@ -538,6 +541,7 @@ describe('canonical message feedback history', () => { const { ctx, persistence } = await harness() const session = ctx.sessions.create(SessionId('live-log')) const fixture = appendMessageFixture(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents().length const failure = new Error('disk unavailable') let fail = true @@ -552,7 +556,9 @@ describe('canonical message feedback history', () => { const item = listed.value.items[0]! fail = false expectItem(await ctx.messageFeedback.put({ ...request, ifVersion: item.version })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(before + 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(persistence.durable.get(session.id)?.events).toEqual(session.snapshotEvents()) expect(persistence.openCalls).toEqual(['read']) }) @@ -563,6 +569,7 @@ describe('canonical message feedback history', () => { const session = ctx.sessions.create(SessionId('mismatched-checkpoint')) const fixture = appendMessageFixture(session) ctx.on('session/flush', () => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = [...session.snapshotEvents()] if (kind === 'missing-tail') events.pop() if (kind === 'different-tail') events[events.length - 1] = { ...events.at(-1)!, time: 0 } @@ -587,6 +594,7 @@ describe('canonical message feedback history', () => { expectItem(await ctx.messageFeedback.put({ sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null, })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(persistence.durable.get(session.id)!.events.length).toBe(session.snapshotEvents().length - 1) }) @@ -624,6 +632,7 @@ describe('canonical message feedback history', () => { ])('rejects malformed durable feedback payload %#', async (record) => { const { ctx, persistence } = await harness() const fixture = messageFixture('invalid-feedback') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = fixture.session.snapshotEvents() persistence.setDurable({ meta: fixture.session.header, events: [...events, { ...record, seq: SessionSeq(events.length), time: 1, diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 9e15589025..79bddaa090 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,6 +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). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const calls = agent.session.snapshotEvents().filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') diff --git a/packages/fs/tool-present/tests/present.spec.ts b/packages/fs/tool-present/tests/present.spec.ts index 95606e09dc..4c805c2395 100644 --- a/packages/fs/tool-present/tests/present.spec.ts +++ b/packages/fs/tool-present/tests/present.spec.ts @@ -84,6 +84,7 @@ describe('present', () => { if (result.isError) throw new Error('present failed') const files = (result.value as unknown as { files: PresentedFile[] }).files expect(files).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files) expect(files).toEqual([{ path: '报告.docx', description: 'Report' }]) expect(read).not.toHaveBeenCalled() @@ -109,6 +110,7 @@ describe('present', () => { execute: async () => ({ turn: 1, files: [] }), })) expect((await execute([])).isError).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')).toEqual([]) }) @@ -117,6 +119,7 @@ describe('present', () => { await owner.ctx.plugin(Present, { maxFiles: 2 }) await writeFile(join(root, 'a'), 'a') expect((await execute([{ path: 'a' }])).isError).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented') expect(deliveries).toHaveLength(1) expect(deliveries[0]?.data.files[0]?.path).toBe('a') @@ -130,6 +133,7 @@ describe('present', () => { return { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] } }) expect((await execute([{ path: 'a' }])).isError).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) @@ -141,6 +145,7 @@ describe('present', () => { const result = await execute(files) expect(result.isError, JSON.stringify(files)).toBe(true) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1c1761cf74..94f42cdfe8 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -59,15 +59,19 @@ async function harness(): Promise { /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */ function domainEvents(session: Session): readonly SessionEvent[] { const lifecycle = new Set() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents()[event.seq - 1] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter(event => !lifecycle.has(event.seq)) } diff --git a/packages/goal/goal-round-driver/src/invariant.ts b/packages/goal/goal-round-driver/src/invariant.ts index 22ca5c39a1..e716422f27 100644 --- a/packages/goal/goal-round-driver/src/invariant.ts +++ b/packages/goal/goal-round-driver/src/invariant.ts @@ -61,6 +61,7 @@ function validateEvent( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { const prior: SessionEvent[] = [] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { validateEvent(prior, event, fail) prior.push(event) @@ -70,6 +71,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] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 2ec805d71b..4732334ddc 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 @@ -197,6 +197,7 @@ describe('same-session goal driving', () => { }) expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. @@ -207,6 +208,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -262,6 +264,7 @@ describe('same-session goal driving', () => { message: 'Goal round was rejected before entering its step.', }) expect(test.adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) @@ -299,6 +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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) @@ -377,6 +381,7 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ phase: 'paused', roundsStarted: 1 }) expect(test.adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEndKinds = test.agent.session.snapshotEvents().flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []) expect(turnEndKinds).toContain('completed') @@ -394,6 +399,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('') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -431,6 +437,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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' @@ -817,6 +824,7 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) @@ -991,6 +999,7 @@ describe('same-session goal driving', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } })) await handle.agent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -1096,6 +1105,7 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) expect(test.adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 3d8e6a46f0..8c0004f42a 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -43,6 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): GoalFoldState => { const state = emptyGoalFoldState() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ac32cd3765..7281859055 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -27,6 +27,7 @@ const sessionStubs = new WeakMap() /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } @@ -131,7 +132,9 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const context = session.snapshotEvents()[0] expect(context?.type).toBe('goal/change') if (context?.type !== 'goal/change') throw new Error('expected durable goal change') @@ -140,6 +143,7 @@ describe('GoalService creation and replay', () => { expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) expect(agent.inbox.nextStep).toEqual([]) expect(session.deriveMessages()).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() }) @@ -192,6 +196,7 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) ctx.agents.register(resumed.agent) expect(ctx.goals.get(resumed.agent)).toMatchObject({ @@ -240,12 +245,14 @@ describe('GoalService creation and replay', () => { expect(activations.map(entry => entry.activation)).toEqual(['armed', 'disarmed', 'armed']) expect(activations.map(entry => entry.id)).toEqual([goal.id, goal.id, goal.id]) expect(activations.map(entry => entry.revision)).toEqual([1, 1, 2]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents().length expect(ctx.goals.disarm(agent)).toMatchObject({ id: goal.id, @@ -253,6 +260,7 @@ describe('GoalService creation and replay', () => { phase: 'active', activation: 'disarmed', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(before) expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) }) @@ -416,6 +424,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) @@ -432,11 +441,13 @@ describe('GoalService mutations', () => { expect(goal.updatedAt).toBe(100) vi.setSystemTime(80) ctx.goals.clear(agent, goal) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).not.toThrow() vi.useRealTimers() }) @@ -458,10 +469,12 @@ 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' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'goal/change', 'goal/change', 'goal/change', ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) @@ -482,6 +495,7 @@ describe('GoalService mutations', () => { expect(observed).toEqual(created) expect(ctx.goals.get(stub.agent)).toEqual(created) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(stub.session.snapshotEvents())).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) @@ -499,6 +513,7 @@ describe('GoalService mutations', () => { revision: 1, }) expect(stub.agent.inbox.nextStep).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(stub.session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) }) @@ -584,6 +599,7 @@ describe('goal replay validation', () => { function oneChange(change: GoalChangeMeta) { const session = Session.create(SessionId(`validation-${Math.random()}`)) appendChange(session, change) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents() } @@ -615,6 +631,7 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('inbox-independent-change')) appendChange(session, change) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) const message = createUserMessage({ content: [{ type: 'text', text: 'unrelated pending context' }], @@ -623,6 +640,7 @@ describe('goal replay validation', () => { const inbox = stubAgentForSession(session).agent.inbox inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) }) @@ -630,6 +648,7 @@ describe('goal replay validation', () => { const session = Session.create(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) appendChange(session, second) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldGoal(session.snapshotEvents()) } @@ -641,6 +660,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'other' }], source: { kind: 'plugin', plugin: 'test' }, })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) @@ -649,6 +669,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) }) @@ -656,6 +677,7 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('other-goal-round'), oneChange(change)) appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).toThrow('not the next admitted round') }) @@ -732,6 +754,7 @@ describe('goal replay validation', () => { appendRound(session, base.goal, 2) appendChange(session, { ...paused, roundsStarted: 2 }) appendChange(session, exhausted) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).toThrow('exhausted round budget') }) @@ -756,6 +779,7 @@ describe('goal replay validation', () => { appendChange(completedSession, base) appendChange(completedSession, complete) appendChange(completedSession, sameCurrentId) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(completedSession.snapshotEvents())).toThrow('fresh active revision-one') const second = snapshotChange({ @@ -770,6 +794,7 @@ describe('goal replay validation', () => { appendChange(nonAdjacentReuse, second) appendChange(nonAdjacentReuse, secondComplete) appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(nonAdjacentReuse.snapshotEvents())).toThrow('fresh active revision-one') const clear: GoalChangeMeta = { @@ -779,6 +804,7 @@ describe('goal replay validation', () => { appendChange(clearedSession, base) appendChange(clearedSession, clear) appendChange(clearedSession, sameCurrentId) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(clearedSession.snapshotEvents())).toThrow('fresh active revision-one') }) @@ -791,6 +817,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).toThrow('goal message source is invalid') }) @@ -838,6 +865,7 @@ describe('goal replay validation', () => { clearedAt: 20, } appendChange(session, clear) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 45cd1ddb37..77c2e23818 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -137,6 +137,7 @@ describe('goal projection unit', () => { }) expect(bench.tailValues().goal).toBeNull() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined() }) diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 34d3108e5d..eadc0bd8f9 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -30,6 +30,7 @@ function openTurnEvents( ctx: Context, agent: Agent, ): Pick { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary === undefined || boundary.openTurnStartSeq === null) { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index b4b4d54808..00b2feabe2 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -68,6 +68,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St /** Open one message-triggered turn with its accepted model-visible input. */ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turn = stub.session.snapshotEvents() .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 @@ -282,6 +283,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const forkSession = Session.create(forkId, root.session.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id: forkId, 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 4c505d6cf0..e83b99d1ec 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 @@ -37,6 +37,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 }[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ @@ -337,6 +338,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -361,6 +363,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 660241f8fd..73a817f7de 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -74,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index 45aeca1050..5da0f91911 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,6 +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' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { @@ -25,6 +26,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) @@ -37,6 +39,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +51,7 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const sparse = session2.snapshotEvents().find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) @@ -63,6 +67,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' }) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const decisions = session.snapshotEvents() .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) @@ -75,6 +80,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') @@ -87,6 +93,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) @@ -98,7 +105,9 @@ 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' }) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const invoked = session.snapshotEvents().find(e => e.type === 'hook/invoked') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index 68f6c8319d..ec6a9c2453 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -71,6 +71,7 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { } function events(agent: Agent): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 8627a970cd..1edfcb2500 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -52,6 +52,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } +// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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). */ diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 1ef87de7c1..2ffb062a0c 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -54,6 +54,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } +// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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). */ diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 12a3c370ab..91956f33a5 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -42,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } +// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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). */ diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index 7bce241224..5eb8194b1a 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -35,6 +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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const sourceEvent = source === undefined ? undefined : session.eventAt(source) if (source !== undefined && (event.data.kind !== 'success' @@ -46,6 +47,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } } for (const session of ctx.sessions.list()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(session, event) } ctx.on('internal/dispatch', (_mode, eventName, args) => { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 92a12d921d..aef91060c8 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -33,6 +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 }> { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => ({ type: event.type, data: event.data })) @@ -316,6 +317,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'command/done', ]) @@ -354,6 +356,7 @@ describe('CommandRuntime', () => { await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +431,7 @@ describe('CommandRuntime', () => { const signal = new AbortController().signal await ctx.commands.execute(agent, 'not a command', [], signal) await ctx.commands.execute(agent, '/missing', [], signal) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents()).toEqual([]) }) @@ -437,6 +441,7 @@ describe('CommandRuntime', () => { ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) diff --git a/packages/interaction/permission-presets/src/invariant.ts b/packages/interaction/permission-presets/src/invariant.ts index dee9db2451..700b42ba6c 100644 --- a/packages/interaction/permission-presets/src/invariant.ts +++ b/packages/interaction/permission-presets/src/invariant.ts @@ -21,6 +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()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(ctx, event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 3da5749e2b..552373474f 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -151,6 +151,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], @@ -162,6 +163,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-noop') ctx.permissionPresets.set(session, 'workspace-write') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(0) }) @@ -173,6 +175,7 @@ describe('PermissionPresetService', () => { // the changed knob. session.append('sandbox/mode', { mode: 'read-only' }) ctx.permissionPresets.set(session, 'danger-full-access') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const tail = session.snapshotEvents().slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], @@ -208,6 +211,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(0) expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) @@ -217,6 +221,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')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(first.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'workspace-write' }], ['sandbox/mode', { mode: 'workspace-write' }], @@ -230,6 +235,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(second.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -243,8 +249,10 @@ describe('new-session default', () => { const legacy = freshSession('legacy-source') legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.snapshotEvents() }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents().slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -257,6 +265,7 @@ describe('new-session default', () => { }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -274,9 +283,11 @@ describe('new-session default', () => { }) ctx.provide('approval', { config: { policy: 'ask' } }) const existing = ctx.sessions.create(SessionId('existing-before-permission')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents()).toEqual([]) await ctx.plugin(PermissionPresetService, {}) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -302,6 +313,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). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'sandbox/mode', 'approval/policy', ]) @@ -313,7 +325,9 @@ describe('new-session default', () => { const partial = freshSession('partial-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) partial.append('approval/policy', { policy: 'ask' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.snapshotEvents() }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'permission/preset', data: { preset: 'workspace-write' }, @@ -322,8 +336,10 @@ describe('new-session default', () => { const custom = freshSession('custom-source') custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.snapshotEvents() }) expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(unmatched.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) @@ -331,7 +347,9 @@ describe('new-session default', () => { const ctx = await mountedStore({ approvalDefault: undefined }) const partial = freshSession('approval-fallback-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.snapshotEvents() }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 53022e9089..9a7ef5d0d3 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -99,6 +99,7 @@ describe('/permission command', () => { text: 'The approval policy changed from "ask" to "never" (changed by the user).', }], }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const run = session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) }) @@ -111,12 +112,14 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -127,6 +130,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 8e1a8d05d4..8a5f506bf1 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -76,6 +76,7 @@ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may */ function hasOpenTurn(session: Session): boolean { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const type = session.eventAt(SessionSeq(seq))?.type if (type === 'turn/start') return true if (type === 'turn/end') return false @@ -243,6 +244,7 @@ export class ApprovalService extends Service { */ overrideOf(session: Session): ApprovalPolicy | undefined { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) if (event?.type === 'approval/policy') return event.data.policy } diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index 8ff7a9953e..d356ca3698 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -64,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 70e1abcf4e..c0fce76425 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -132,8 +132,11 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -155,8 +158,11 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) @@ -376,6 +382,7 @@ describe('approval policy (the approval/policy fold)', () => { setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') expect(service.overrideOf(session)).toBe('ask') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) @@ -415,7 +422,9 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(e => e.type === 'approval/asked')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(e => e.type === 'approval/decided')).toHaveLength(1) }) diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 8655f6d661..afd1be1ccd 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -147,6 +147,7 @@ function validateStarted( /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() for (const [index, event] of events.entries()) { if (event.type === 'llm/retry') validateRetry(events.slice(0, index), event, fail) @@ -161,7 +162,9 @@ 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] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (event.type === 'llm/retry') validateRetry(session.snapshotEvents(), event, fail) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 5c5a177040..7b95f6982d 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -112,6 +112,7 @@ describe('real Loader composition', () => { await agent.whenIdle() expect(adapter.requests).toBe(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index e7f0ededb2..cf61fcd4fa 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -215,6 +215,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -250,6 +251,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message').map(event => ({ turn: event.data.turn, step: event.data.step, @@ -286,7 +288,9 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(500) await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const failedAttempts = agent.session.snapshotEvents().filter((event): event is SessionEvent<'assistant/attempt'> => event.type === 'assistant/attempt' && retryEvent !== undefined @@ -294,12 +298,14 @@ describe('provider-routed retry policy', () => { ) expect(failedAttempts).toHaveLength(1) expect(expandAssistantStream(failedAttempts[0]!.data.stream)).toHaveLength(7) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistantMessages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') expect(assistantMessages.map(event => ({ turn: event.data.turn, step: event.data.step, }))).toEqual([{ turn: 1, step: 1 }]) expect(assistantMessages[0]?.sourceEventSeqs).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'tool/call')).toBe(false) expect(toolExecutions).toBe(0) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ @@ -337,7 +343,9 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(3) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } }, @@ -393,6 +401,7 @@ describe('provider-routed retry policy', () => { rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await rejectedIdle expect(rejected.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(rejectedAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) @@ -431,6 +440,7 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await idle expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -450,7 +460,9 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', @@ -479,6 +491,7 @@ describe('provider-routed retry policy', () => { const normalIdle = waitForIdle(context, normalAgent) normalAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } })) await normalIdle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(normalAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) const alwaysAgent = await context.agentLoop.create(SessionId('retry-provider-always'), { @@ -565,6 +578,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => ({ provider: event.data.provider, retry: event.data.retry, @@ -676,6 +690,7 @@ describe('provider-routed retry policy', () => { await vi.runAllTimersAsync() await idle + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(adapter.requests).toHaveLength(5) expect(events.map(event => ({ @@ -720,6 +735,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry' && event.data.failure.message === diagnostic, )).toBe(true) @@ -742,6 +758,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) @@ -793,6 +810,7 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(60_000) expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) @@ -834,6 +852,7 @@ describe('provider-routed retry policy', () => { expect(order[0]).toBe('downstream') expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) @@ -872,6 +891,7 @@ describe('provider-routed retry policy', () => { expect(order).toEqual(['downstream', 'idle']) expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -909,6 +929,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -966,6 +987,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -995,7 +1017,9 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -1019,6 +1043,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ba218842de..b14db6e47e 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -102,9 +102,11 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server).toBeDefined() expect(server?.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -131,7 +133,9 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const failedAttempts = agent.session.snapshotEvents().filter((event): event is SessionEvent<'assistant/attempt'> => event.type === 'assistant/attempt' && retryEvent !== undefined @@ -139,9 +143,11 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { ) expect(failedAttempts).toHaveLength(1) expect(expandAssistantStream(failedAttempts[0]!.data.stream)).toHaveLength(failedChunkCount) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('recovered response') @@ -162,11 +168,14 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['EMPTY_RESPONSE']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -189,10 +198,14 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const attempt = agent.session.snapshotEvents().find(event => event.type === 'assistant/attempt' && event.data.turn === 1) expect(attempt?.type === 'assistant/attempt' ? expandAssistantStream(attempt.data.stream) : []).toHaveLength(3) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +228,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']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -233,8 +247,11 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(3) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 348213ef4d..2693af5913 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -229,7 +229,8 @@ export class TokenMeter extends Service { } while (state.consumedEvents < session.seq) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log + // Contiguous session seqs index the durable log; existing Session history read, migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const event = session.eventAt(SessionSeq(state.consumedEvents))! this._foldEvent(state, event) state.consumedEvents = SessionLogOffset(state.consumedEvents + 1) diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 769f00e0ec..35f64858f9 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -292,6 +292,7 @@ describe('contextBreakdown session projection', () => { const first = appendUser(session, 'first message') const last = appendUser(session, 'last message') const definition = contextBreakdownProjectionDefinition + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const state = session.snapshotEvents().reduce(definition.apply, definition.init()) const before = JSON.stringify(state) const replacement = session.append('user/message', createUserMessage({ @@ -382,8 +383,10 @@ describe('contextBreakdown session projection', () => { const { session } = await harness() const head = appendSystem(session, 'head') const definition = contextBreakdownProjectionDefinition + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const state = session.snapshotEvents().reduce(definition.apply, definition.init()) replaceSystem(session, head, 'same') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const next = definition.apply(state, session.snapshotEvents().at(-1)!) expect(next).not.toBe(state) expect(definition.wire.view(next)).toBe(definition.wire.view(state)) @@ -421,6 +424,7 @@ describe('contextBreakdown session projection', () => { stale, [replacement], SessionLogOffset(replacement.seq), session.header, session.inheritedEventCount, )).toThrow('re-read from seq 0') const replayed = ctx.sessionProjections.restore( + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. stale, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, ) expect(replayed.snapshot.values.contextBreakdown).toEqual(projected(ctx, session)) @@ -433,6 +437,7 @@ describe('contextBreakdown session projection', () => { }, } expect(() => ctx.sessionProjections.restore( + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. invalid, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, )).toThrow() }) @@ -452,6 +457,7 @@ describe('contextBreakdown session projection', () => { expect.soft(ctx.sessionProjections.viewCheckpoint(checkpoint)).not.toHaveProperty('contextBreakdown') expect.soft(ctx.sessionProjections.restoreFloor(checkpoint)).toBe(0) const restored = ctx.sessionProjections.restore( + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. checkpoint, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, ) expect(restored.snapshot.values.contextBreakdown).toEqual({ diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e795ea5507..bd8218beb0 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -230,6 +230,7 @@ describe('TokenMeter pricing', () => { const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(result.logRevision).toBe(session.snapshotEvents().length) expectSurfaceTotal(result) }) @@ -423,6 +424,7 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'new tail' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seeded = Session.create(SessionId('surface-seeded'), original.snapshotEvents()) const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) @@ -436,7 +438,9 @@ describe('replay anchors and surface folds', () => { }), { surfaceOp: { op: 'replace', startSeq: first, endSeq: first }, sourceEventSeqs: [first] }) const after = service.measure(seeded) expect(after.nodes).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(after.nodes[0]!.seq).toBe(seeded.snapshotEvents().length - 1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(after.logRevision).toBe(seeded.snapshotEvents().length) expect(Object.isFrozen(after.nodes)).toBe(true) expect(Object.isFrozen(after.nodes[0])).toBe(true) @@ -444,6 +448,7 @@ describe('replay anchors and surface folds', () => { expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) // The earlier snapshot still reports the log it measured: seed + boundary. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(before.logRevision).toBe(original.snapshotEvents().length + 1) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -455,6 +460,7 @@ describe('replay anchors and surface folds', () => { durableText: '', }) const measurement = meter().measure(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 72a319fc45..60389c8879 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -28,6 +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 => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(event, fail) } for (const session of ctx.sessions.list()) seed(session) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index ac82e45822..4b0eb97f01 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -105,6 +105,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const header = findEvent(log, 'request/header') @@ -138,8 +139,10 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const first = findEvent(agent.session.snapshotEvents(), 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstSystem = findEvent(agent.session.snapshotEvents(), 'system/message') expect(systemText(agent)).not.toContain(PLAN_CONFIG.section) @@ -147,6 +150,7 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -196,6 +200,7 @@ describe('plan mode through the agent loop', () => { expect(requestSystem(adapter.requests[1])).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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const nextIdle = waitForIdle(ctx, agent) @@ -205,6 +210,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(3) expect(requestSystem(adapter.requests[2])).toContain(PLAN_CONFIG.section) expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 2e3bd89949..9bd0e7c87c 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -142,6 +142,7 @@ function header(session: Session): void { } function noticeTexts(session: Session): string[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,10 +206,12 @@ describe('resolveConfig', () => { describe('foldPlanMode', () => { it('folds an empty log to inactive and takes the last plan/mode otherwise', () => { const session = Session.create(SessionId('fold')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents())).toBe(false) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) session.append('plan/mode', { active: true }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents())).toBe(true) }) @@ -216,7 +219,9 @@ describe('foldPlanMode', () => { const session = Session.create(SessionId('fold-prefix')) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents(), 1)).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents(), 0)).toBe(false) }) }) @@ -281,13 +286,16 @@ describe('ctx.planMode: get/set', () => { const ctx = await setup() const agent = await agentWithSession(ctx, 'agent-idle') expect(ctx.planMode.set(agent, true)).toBe('committed') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) // A later boundary finds nothing pending — no double append. await boundary(ctx, agent, 'step-start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'plan/mode')).toHaveLength(2) }) @@ -299,6 +307,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(ctx.planMode.get(agent)).toEqual({ active: false }) }) @@ -319,6 +328,7 @@ describe('the boundary flush', () => { const service = ctx.planMode as unknown as { onBoundary(session: Session): void } expect(() => { service.onBoundary(agent.session) }).not.toThrow() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) @@ -328,6 +338,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'pre-step') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) @@ -343,6 +354,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) await fiber.dispose() await boundary(ctx, agent, 'pre-step') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) @@ -351,6 +363,7 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -362,6 +375,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) await boundary(ctx, agent, 'pre-step') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -403,6 +417,7 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -430,6 +445,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) @@ -674,6 +690,7 @@ describe('/plan', () => { expect(enteringSteer).not.toHaveBeenCalled() await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(entering.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const active = await agentWithSession(ctx, 'active-plan-command', { active: true }) @@ -698,9 +715,11 @@ 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.' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) @@ -864,6 +883,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -873,6 +893,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -881,6 +902,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -901,6 +923,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(child.session.snapshotEvents())).toBe(true) }) @@ -913,9 +936,11 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) await boundary(ctx, agent, 'step-start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) @@ -966,6 +991,7 @@ describe('exit_plan_mode', () => { question: 'Approve this plan and leave plan mode?', detail: plan, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'tool/ptc-dispatch')?.data).toMatchObject({ name: EXIT_PLAN_MODE, arguments: { plan }, @@ -981,11 +1007,13 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) @@ -997,6 +1025,7 @@ describe('exit_plan_mode', () => { header(agent.session) await callExit(ctx, agent) await boundary(ctx, agent, 'step-start') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -1006,6 +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; their feedback: consider the resume path' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1021,6 +1051,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1029,6 +1060,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.' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1037,6 +1069,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1051,6 +1084,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.' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1083,6 +1117,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.' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1095,6 +1130,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1135,6 +1171,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1144,6 +1181,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' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1195,6 +1233,7 @@ 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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 f4d46d5b62..1e15df94b4 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -155,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index b1a685d019..f02651d2f5 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -98,6 +98,7 @@ async function agentOn(ctx: Context, id: string, presetId?: string): Promise + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. agent.session.snapshotEvents().findLast(event => event.type === 'agent-preset/selected')?.data describe('the roster a client reads', () => { diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 6f3d5ca654..62ae2446ae 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -23,6 +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()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 608abeeade..543fbff319 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -207,6 +207,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = Session.create(active.id, active.snapshotEvents(), active.header) const ctx = await promptMounted({ mode: 'read-only' }) @@ -232,6 +233,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 9364fc53e0..7049246bdf 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -55,6 +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 }) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 248e7df710..0d5a89f926 100644 --- a/packages/schedule/schedule/src/invariant.ts +++ b/packages/schedule/schedule/src/invariant.ts @@ -30,15 +30,18 @@ function validate(events: readonly SessionEvent[], fail: InvariantFailure): void /** 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()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validate(session.ownEvents(), fail) } ctx.on('session/created', (session) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validate(session.ownEvents(), 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validate([...session.ownEvents(), event], fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index 5b141f5728..1574574c3d 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -205,6 +205,7 @@ export class ScheduleRuntime { /** Fold the current exact runtime suffix and contain a corrupt durable stream. */ private readFolded(): FoldedSchedules | undefined { try { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldScheduleEvents(this.agent.session.ownEvents()) } catch (error: unknown) { this.faulted = true diff --git a/packages/schedule/schedule/src/tools.ts b/packages/schedule/schedule/src/tools.ts index c4ac651634..83162ae2a9 100644 --- a/packages/schedule/schedule/src/tools.ts +++ b/packages/schedule/schedule/src/tools.ts @@ -221,6 +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 { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldScheduleEvents(agent.session.ownEvents()) } 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 f595bfa081..5600ea2635 100644 --- a/packages/schedule/schedule/tests/invariant.spec.ts +++ b/packages/schedule/schedule/tests/invariant.spec.ts @@ -53,6 +53,7 @@ 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')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(2) expect(() => session.append('schedule/change', { @@ -60,9 +61,11 @@ describe('Schedule package invariant', () => { operation: 'delete', id: ScheduleId('missing'), })).toThrow(InvariantError) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(2) session.append('schedule/change', { version: 1, operation: 'dispatch', id: ScheduleId('schedule-1') }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(3) await ctx.fiber.dispose() }) @@ -82,6 +85,7 @@ describe('Schedule package invariant', () => { id: ScheduleId('schedule-every'), acceptedAt: '2026-08-05T12:17:34.000Z', }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(2) await ctx.fiber.dispose() }) @@ -118,6 +122,7 @@ describe('Schedule package invariant', () => { }) const fiber = await ctx.plugin(scheduleInvariant) child.append('schedule/change', create('child')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 baf02e4702..3a86c940d6 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -107,6 +107,7 @@ describe('Schedule production JSONL restart', () => { ) pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord }) const seed = await first.sessionPersistence.create(pending.header) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await seed.append(pending.snapshotEvents()) await seed.close() await disposeContext(first) @@ -143,6 +144,7 @@ describe('Schedule production JSONL restart', () => { await replayed.sessions.flush(replayHandle.agent.session) expect(replayAdapter.requests).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayHandle.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) const replayedStored = await readStored(replayed, sessionId) diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index f216babd7d..a1daefbef8 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -173,6 +173,7 @@ describe('Schedule timer and admission runtime', () => { await settle() expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().find(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toBeDefined() await runtime.dispose() @@ -223,6 +224,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.followed).toEqual([]) expect(test.controls.whenIdleCount).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) runtime.requestDrive() @@ -295,12 +297,14 @@ describe('Schedule timer and admission runtime', () => { ].join('\n'), }]) expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'schedule' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +374,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.controls.releaseCount).toBe(1) expect(test.followed).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'delete' }) runtime.requestDrive() await settle() @@ -429,6 +434,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.releaseCount).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) await runtime.dispose() @@ -460,6 +466,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) runtime.requestDrive() @@ -487,6 +494,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' )).map(event => event.data)).toEqual([{ @@ -495,6 +503,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { id: 'schedule-first', acceptedAt: '2026-08-05T12:00:00.000Z', }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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,6 +554,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.flushCount).toBe(1) expect(test.followed).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await runtime.dispose() @@ -607,6 +617,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { } await settle() expect(test.followed).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) }) diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index 35e4dc1724..467bc4f288 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -160,6 +160,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -233,6 +234,7 @@ describe('Schedule tool protocol', () => { expect.objectContaining({ id: 'schedule-1', kind: 'at' }), expect.objectContaining({ id: 'schedule-2', kind: 'at' }), ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const changes = test.agent.session.snapshotEvents() .filter(event => event.type === 'schedule/change' && event.data.operation === 'create') expect(changes.map((change) => { @@ -301,6 +303,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be strictly in the future.', }) expect(test.flushes.count).toBe(3) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -313,6 +316,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const internal = await harness() @@ -439,6 +443,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -463,6 +468,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -486,6 +492,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(3) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')) .toHaveLength(1) expect(value(await execute(test, 'schedule_list', {}))) @@ -497,6 +504,7 @@ 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' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(createTest.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const deleteTest = await harness() @@ -504,6 +512,7 @@ describe('Schedule persistence failure boundaries', () => { deleteTest.flushes.outcomes.push('reject') expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(deleteTest.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) }) 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 1d61e0ef3a..afb9125448 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,6 +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.' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 0fd390ee6a..e0b24b3c39 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -871,6 +871,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return observeSession(session.header, session.inheritedEventCount, session.snapshotEvents()) } diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index a6e44c85ad..cd01ca8234 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -243,6 +243,7 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return { header: session.header, events: session.snapshotEvents() } } @@ -298,6 +299,7 @@ function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), inheritedEventCount: session.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 7ef86cebd4..8c08108775 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -289,6 +289,7 @@ export class SessionObservationReader { header: session.header, inheritedEventCount: session.inheritedEventCount, get events() { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. materialized ??= session.snapshotEvents(SessionLogOffset(0), seq) return materialized }, 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 ea89275ef3..b1cfa90c28 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1034,6 +1034,7 @@ describe('session-query exact reads', () => { }).toThrow() Object.assign(snapshot.session, { cwd: '/mutated' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.eventAt(SessionSeq(4)) expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) expect(session.header.cwd).toBe('/work') @@ -1071,6 +1072,7 @@ describe('session-query exact reads', () => { (result.events[0]!.data as { content: unknown[] }).content = [] }).toThrow() expect(session.header.createdAt).not.toBe(-1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.eventAt(SessionSeq(1)) expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) 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 c127c2a127..0ca1de7052 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 @@ -2006,6 +2006,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(text(result)).toContain(new Date(session.snapshotEvents()[0]?.time ?? 0).toISOString()) }) diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index 4c35021c8d..1147927bd5 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -120,6 +120,7 @@ export function acceptedThrough(session: Session): SessionSeqCursor { const length = session.seq const start = previous?.scannedEvents ?? SessionLogOffset(0) for (let index = start; index < length; index++) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(index)) if (event === undefined) { throw new Error(`session-log-deepseek: missing event ${String(index)} below captured length ${String(length)}`) @@ -164,9 +165,11 @@ export function apply(ctx: Context, config: Config): void { if (session === undefined) return undefined const afterSeq = acceptedThrough(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const snapshot = session.snapshotEvents() const throughSeq = snapshot.at(-1)?.seq if (throughSeq === undefined) return undefined + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1)) const value: DeepSeekSessionLogExtension = { version: 1, diff --git a/packages/session/session-log-deepseek/src/invariant.ts b/packages/session/session-log-deepseek/src/invariant.ts index c855666ded..f01d5aef9e 100644 --- a/packages/session/session-log-deepseek/src/invariant.ts +++ b/packages/session/session-log-deepseek/src/invariant.ts @@ -44,6 +44,7 @@ function validateDeliveryAccepted(session: Session, event: SessionEvent<'session /** Validate acceptance watermarks already present in one Session. */ function validateSession(session: Session, fail: InvariantFailure): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/feedback-composition.spec.ts b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts index 76702f4c38..31eb4d215f 100644 --- a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts +++ b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts @@ -84,6 +84,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged recordFeedback(session, ' The session needs a clearer explanation. ') const created = await ctx.messageFeedback.put({ sessionId: session.id, messageId: assistant.id, rating: 'negative', note: 'Explain the result.', ifVersion: null }) if (!created.ok) throw new Error(created.error.code) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const initialPrefix = session.snapshotEvents() const request = async () => { const chunks = [] @@ -132,6 +133,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged ] }) } await ctx.sessions.flush(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((await handle.read()).events).toEqual(session.snapshotEvents()) } finally { await handle.close() diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index e92df32921..dc4eb3f1c3 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -117,6 +117,7 @@ describe('incremental DeepSeek session-log upload', () => { turn: 1, step: 3, message: createSystemMessage('new head', 'fixture'), }, { surfaceOp: { op: 'replace', startSeq: head.seq, endSeq: head.seq }, sourceEventSeqs: [head.seq] }) const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: session.id }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) @@ -176,6 +177,7 @@ describe('incremental DeepSeek session-log upload', () => { expect(firstPayload?.events).toHaveLength(2) await first.accept() expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().at(-1)?.data).toEqual({ sessionId: session.id, throughSeq: 1, @@ -197,6 +199,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = first.session.snapshotEvents() const resumed = await harness('parent', seed) @@ -361,6 +364,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 }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) diff --git a/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts b/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts index 9be35ec1bf..814da947a6 100644 --- a/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts @@ -176,6 +176,7 @@ function assertMigrated(result: Awaited>) { expect(session.firstLiveSeq).toBe(23) expect(session.isOwnSeq(SessionSeq(8))).toBe(false) expect(session.isOwnSeq(SessionSeq(9))).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.ownEvents()).toEqual([ ...events.slice(9), expect.objectContaining({ type: 'session/end-seed', seq: 23, data: {} }), diff --git a/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts index b8a7fad132..3c40b96b20 100644 --- a/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts @@ -131,6 +131,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () = const session = await restore(reader) expect(messages(session)).toEqual([human]) expect(session.surface.nodes).toEqual([8, 3]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(8))?.type).toBe('system/message') } finally { await reader.close() @@ -207,6 +208,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () = expect(session.firstLiveSeq).toBe(15) expect(session.surface.nodes).toEqual([4, 8]) expect(messages(session)).toEqual([prompt('seed prompt'), { role: 'user', content: [{ type: 'text', text: 'summary' }] }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.ownEvents()).toEqual([prepared[14]]) } finally { await reader.close() @@ -227,7 +229,9 @@ describe('V2 system prompts through current Session and JSONL persistence', () = }, { surfaceOp: { op: 'replace', startSeq: SessionSeq(4), endSeq: SessionSeq(4) }, sourceEventSeqs: [SessionSeq(4)] }) session.append('step/end', { turn: 2, step: 1 }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expected = session.snapshotEvents() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await writer.append(session.snapshotEvents(session.firstLiveSeq)) await writer.flush() } finally { @@ -286,6 +290,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () = }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await writer.append(session.snapshotEvents()) await writer.flush() } finally { @@ -294,6 +299,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () = const reopened = await mount() const reader = await reopened.sessionPersistence.open(id, 'read') try { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((await reader.read()).events).toEqual(session.snapshotEvents()) const restored = await restore(reader) expect(restored.surface.nodes).toEqual([2, 3, 4]) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index d29c5bb021..6d28631ddc 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -619,6 +619,7 @@ export class SessionProjectionRegistry extends Service { registration.def, session.header, session.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents(), ) registration.cells.set(session, cell) @@ -637,6 +638,7 @@ export class SessionProjectionRegistry extends Service { ): void { if (cell.observedSeq >= throughSeq) return for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) @@ -663,6 +665,7 @@ export class SessionProjectionRegistry extends Service { registration.def, session.header, session.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(event.seq)), ) registration.cells.set(session, cell) diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 43c93fb8ec..0722845a3b 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -246,6 +246,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { ctx.on('session/event', (session, event) => { if (!isFeedback(session, event)) return // Only the canonical appended event authorizes this exact prefix. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (session.eventAt(event.seq) !== event) { ctx.logger.warn(NON_CANONICAL_EVENT_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 b8919024bd..35c2b89e11 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -175,6 +175,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents().find(event => event.type === 'turn/start')!.time) * 1_000_000n) expect(start?.record.attributes).toContainEqual({ key: 'session.format_version', @@ -240,6 +241,7 @@ describe('OpenTelemetrySessionBackend wire', () => { }, }) expect(eventTypes(captures)).not.toContain('manual') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) expect(ops).toHaveLength(0) }) @@ -493,6 +495,7 @@ describe('OpenTelemetrySessionBackend wire', () => { session.append('turn/start', { turn: 1 }) expect(captures).toEqual([]) recordFeedback(session, 'explicit report') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const submitted = session.snapshotEvents().map(event => event.type) await expect.poll(() => eventTypes(captures)).toEqual(submitted) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -518,6 +521,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { session.append('turn/start', { turn: 1 }) expect(captures).toEqual([]) recordFeedback(session, 'explicit report') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const expected = session.snapshotEvents().map(event => event.type) await expect.poll(() => eventTypes(captures)).toEqual(expected) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -535,6 +539,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { await ctx.plugin(SessionStore) const donor = Session.create(SessionId('stored-feedback')) recordFeedback(donor, 'old feedback is not a submission') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const restored = ctx.sessions.create(donor.id, { seed: donor.snapshotEvents(), meta: donor.header }) try { const first = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } }) @@ -545,8 +550,10 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'change' }) } const child = ctx.sessions.fork(restored, undefined, SessionId('child')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const inherited = child.snapshotEvents().find(event => event.type === 'feedback/record')! ctx.emit('session/event', child, inherited) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const opened = ctx.sessions.create(SessionId('opened'), { seed: donor.snapshotEvents() }) ctx.emit('session/created', opened) await first.dispose() @@ -581,6 +588,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { expect(captures).toEqual([]) const put = await ctx.messageFeedback.put(request) if (!put.ok) throw new Error('live put failed') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) const throughPut = session.seq await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version }) @@ -588,8 +596,10 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { expect(session.seq).toBe(throughPut) const edit = await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version, note: 'edited note' }) if (!edit.ok) throw new Error('live note failed') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) await ctx.messageFeedback.delete({ sessionId: session.id, messageId: message.id, ifVersion: edit.value.version }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) const submitted = eventTypes(captures) const throughDelete = session.seq @@ -612,6 +622,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { const { ctx, fiber } = await boot(url) const session = Session.create(SessionId('cold-not-submitted')) const notify = async () => ctx.parallel('feedback/committed', { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. meta: session.header, events: session.snapshotEvents(), inheritedEventCount: session.inheritedEventCount, }) await notify() @@ -623,6 +634,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { child.append('feedback/message-delete', { sessionId: session.id, messageId: message.id }) await ctx.parallel('feedback/committed', { meta: { ...session.header, id: SessionId('inherited-cold'), parentSession: session.id, isSeeded: true }, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(1)), inheritedEventCount: SessionLogOffset(1), }) await fiber.dispose() @@ -642,12 +654,14 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { parent.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) const message = createAssistantMessage({ content: [{ type: 'text', text: 'inherited answer' }], source: { provider: 'mock', model: 'm' } }) parent.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = Session.create(SessionId('cold-child'), parent.snapshotEvents(), { ...parent.header, id: SessionId('cold-child'), parentSession: parent.id, isSeeded: true, }, parent.seq) recordFeedback(child, 'child-owned stored feedback') const handle = await ctx.sessionPersistence.create(child.header, { inheritedEventCount: child.inheritedEventCount }) try { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await handle.append(child.snapshotEvents()) } finally { await handle.close() @@ -695,6 +709,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { session.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' }) const handle = await ctx.sessionPersistence.create(session.header) try { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await handle.append(session.snapshotEvents()) } finally { await handle.close() diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index 773aecb923..14c9916c64 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -153,6 +153,7 @@ export class SessionTelemetryCoordinator { ?? (this.options.includeHistory === true || session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1)) // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents(SessionLogOffset(cursor + 1))) { if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index b563d9c469..7ecdc624a0 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -72,6 +72,7 @@ describe('session-telemetry/record waterfall', () => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.snapshotEvents()[0]!.data as { content: { text: string }[] } expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) }) @@ -124,6 +125,7 @@ describe('session-telemetry/record waterfall', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) expect(backend.records).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ac9ffdcfc0..8a5b50bbe7 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -128,11 +128,13 @@ describe('SessionTelemetryCoordinator capture', () => { 'event.type': 'turn/start', 'event.seq': 0, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.snapshotEvents()[1] as SessionEvent<'user/message'> expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) }) @@ -244,6 +246,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) appendAssistantMessage(session, 1, 1, ['first'], 100) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstBoundary = session.snapshotEvents()[2]!.seq appendAssistantMessage(session, 1, 2, ['second'], 200) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -299,6 +302,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { }) const parent = liveSession(ctx, 'history-parent') appendTurn(parent) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = ctx.sessions.create(SessionId('history-child'), { seed: [...parent.snapshotEvents()] }) const boundary = child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) child.append('turn/start', { turn: 2 }) @@ -398,6 +402,7 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.snapshotEvents()], meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) @@ -427,6 +432,7 @@ describe('SessionTelemetryCoordinator adoption', () => { // telemetry therefore receives a current-format object with the complete // migrated canonical seed. const resumed = ctx.sessions.prepare(SessionId('resumed'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: structuredClone(donor.snapshotEvents()) as SessionEvent[], meta: { version: SESSION_FORMAT_VERSION, @@ -460,7 +466,9 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: [...parent.snapshotEvents()], + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. inheritedEventCount: SessionLogOffset(parent.snapshotEvents().length), meta: { parentSession: SessionId('stitch-parent'), isSeeded: true }, }) 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 429f1f5bd3..ef37a9d131 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,6 +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'), { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: seeded.snapshotEvents(), inheritedEventCount: seeded.seq, meta: { parentSession: seeded.id, isSeeded: true }, diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index 240ddefa2b..bd9ce65dc4 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -126,6 +126,7 @@ describe('generateSessionTitleWithLlm', () => { const providerRequest = request(ctx) let requestWasLoggedAtDispatch = false const adapter = new RecordingAdapter(SCRIPT, () => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. requestWasLoggedAtDispatch = providerRequest.session.snapshotEvents() .some(event => event.type === 'session/title-llm-request') }) @@ -162,6 +163,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('第二个问题') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(providerRequest.session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data) .toEqual({ titleProvider: TITLE_PROVIDER, @@ -193,6 +195,7 @@ describe('generateSessionTitleWithLlm', () => { await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER)) .rejects.toThrow(/input.*bytes.*maxInputBytes/i) expect(adapter.requests).toEqual([]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(oversized.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false) const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 }) @@ -261,6 +264,7 @@ describe('generateSessionTitleWithLlm', () => { providerRequest.messages, TITLE_PROVIDER, )).rejects.toMatchObject({ message, code }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(true) }) diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 3e36f1d588..014310b529 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -383,6 +383,7 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldSessionTitle(session.snapshotEvents()) } @@ -593,6 +594,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq) const result = await work.registration.provider.generate({ session, diff --git a/packages/session/session-title/src/invariant.ts b/packages/session/session-title/src/invariant.ts index e12cd6ca25..986b95f81d 100644 --- a/packages/session/session-title/src/invariant.ts +++ b/packages/session/session-title/src/invariant.ts @@ -46,6 +46,7 @@ function validate( fail(`session/title event ${String(event.seq)} repeats message seq ${checked}`) } seen.add(checked) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const cited = checked < event.seq ? session.eventAt(checked) : undefined if (cited?.type !== 'user/message' || cited.data.source.kind !== 'user') { fail(`session/title event ${String(event.seq)} message seq ${checked} must name an earlier human user/message`) @@ -55,6 +56,7 @@ function validate( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const validateExisting = (session: Session): void => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'session/title') validate(session, event, fail) } diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index df2b5f1f91..90f6a207bb 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -17,6 +17,7 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi } function appendTitle(session: Session, title: string): SessionSeqType { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messageSeq = session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user')?.seq ?? session.append('user/message', createUserMessage({ diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index d9632ea3b6..2708d51e2c 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -66,7 +66,9 @@ describe('SessionTitleService Provider lifecycle', () => { const child = ctx.sessions.fork(parent, undefined, SessionId('title-child')) expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.snapshotEvents().find(event => event.type === 'session/title')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. .toEqual(parent.snapshotEvents().find(event => event.type === 'session/title')) const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ @@ -377,6 +379,7 @@ describe('SessionTitleService Provider lifecycle', () => { }))) await settle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(requests).toHaveLength(2) expect(requests[1]).toMatchObject({ diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 9654c18edc..706511d2e3 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -46,6 +46,7 @@ describe('SessionTitleService.rename', () => { messageSeqs: [], source: { kind: 'user' }, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.data).toEqual({ title: 'Hand picked name', @@ -53,6 +54,7 @@ describe('SessionTitleService.rename', () => { source: { kind: 'user' }, }) // foldSessionTitle round-trips the third source kind. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSessionTitle(session.snapshotEvents())?.source).toEqual({ kind: 'user' }) }) @@ -164,6 +166,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 c6e0dc93c3..c0b13910fe 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -171,6 +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' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.snapshotEvents() }) const results = await Promise.all([ @@ -179,7 +180,9 @@ describe('SessionTitleService configuration and refresh boundaries', () => { ]) expect(results[0]).toEqual(results[1]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', @@ -204,6 +207,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) @@ -266,6 +270,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await fiber.dispose() await settle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) const inactiveError = await lifecycle.inactiveRefresh expect(inactiveError).toBeInstanceOf(Error) @@ -285,6 +290,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await Promise.resolve() await fiber.dispose() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 69edbb84ff..b5cbf31c3c 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -53,6 +53,7 @@ describe('SessionTitleService', () => { await settleTitles() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const titleEvent = session.snapshotEvents().findLast(event => event.type === 'session/title') expect(titleEvent).toMatchObject({ type: 'session/title', @@ -140,6 +141,7 @@ describe('SessionTitleService', () => { expect(first?.messageSeqs).toEqual([eligible.seq]) expect(ctx.sessionTitle.get(session)).toEqual(first) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) @@ -160,6 +162,7 @@ describe('SessionTitleService', () => { }, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSessionTitle(seed.snapshotEvents())).toEqual({ title: 'Later', messageSeqs: [1, 4], @@ -169,6 +172,7 @@ describe('SessionTitleService', () => { model: { provider: 'mock', model: 'title-model' }, }, eventSeq: 1, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. updatedAt: seed.snapshotEvents()[1]?.time, }) }) @@ -176,6 +180,7 @@ describe('SessionTitleService', () => { it('folds an empty or title-less log to undefined', () => { expect(foldSessionTitle([])).toBeUndefined() const empty = Session.create(SessionId('no-title')) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSessionTitle(empty.snapshotEvents())).toBeUndefined() }) }) diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index cf2a4e1d2e..d561bed780 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -58,6 +58,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() } diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 1dfbd27b81..b70b02691c 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -631,6 +631,7 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 3465cd843c..0099332fa2 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -362,6 +362,7 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool const visible = new Set(agent.session.surface.nodes) let published = false for (let index = agent.session.seq - 1; index >= 0; index -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(SessionSeq(index)) if (event === undefined) { throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 603166de04..7fe649b1a8 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -124,6 +124,7 @@ async function proposeStep( } function catalogMessages(session: Session): Extract[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter((event): event is Extract => event.type === 'user/message' && event.data.source.kind === 'skill-catalog') } diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 0e0311ebb5..0e69915b60 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -46,6 +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[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = parent.session.snapshotEvents() const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] 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 35c8af4df3..04238d479b 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 @@ -67,6 +67,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentPrefixLen = parent.session.snapshotEvents().length // Delegate to a fresh spawn child. @@ -88,6 +89,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(forkChild.session.snapshotEvents().slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) await spawnRun.dispose() @@ -96,12 +98,14 @@ 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 6f7080cd31..2e557bebb0 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 @@ -84,6 +84,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) expect(child.session.header.isSeeded).toBe(false) expect(child.session.inheritedEventCount).toBe(0) @@ -96,6 +97,7 @@ describe('dsh-subagent-fork-in-process', () => { await parent.whenIdle() parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })) await parent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) @@ -103,7 +105,9 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! expect(child.session.header.isSeeded).toBe(true) expect(child.session.inheritedEventCount).toBe(parentPrefixLen) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) @@ -112,6 +116,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) @@ -121,8 +126,10 @@ 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().length).toBeGreaterThan(parentPrefixLen) // The seeded prefix carried the parent's user message. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seededUser = child.session.snapshotEvents().slice(0, parentPrefixLen).find(e => e.type === 'user/message') expect(seededUser).toBeDefined() // Lineage stamped. @@ -153,6 +160,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). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index c0886dc104..91348b2da9 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -215,6 +215,7 @@ function readResult( cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 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 d3fc067a5a..72d3426a8a 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -69,6 +69,7 @@ function spawnRequest(parent: Agent) { } function toolResultTexts(agent: Agent): string[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') .map(event => event.data.message.content @@ -86,6 +87,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentLogLength = parent.session.snapshotEvents().length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -100,6 +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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }, @@ -109,12 +112,15 @@ describe('in-process policy inheritance', () => { expect(child.session.inheritedEventCount).toBe(0) expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') expect(ctx.approval.overrideOf(child.session)).toBe('never') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const request = child.session.snapshotEvents().find( (event): event is SessionEvent<'request/header'> => event.type === 'request/header', ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNode = child.session.snapshotEvents().find( (event): event is SessionEvent<'system/message'> => event.type === 'system/message', ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const runtimeContext = child.session.snapshotEvents().find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -142,6 +148,7 @@ describe('in-process policy inheritance', () => { .join('\n') expect(systemText).not.toContain('Approval prompts are disabled') expect(systemText).not.toContain('You are a delegated subagent') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength) } finally { await run.dispose() @@ -153,6 +160,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = parent.session.snapshotEvents() setSandboxMode(parent.session, 'read-only') script.push( @@ -169,6 +177,7 @@ describe('in-process policy inheritance', () => { expect(child.session.inheritedEventCount).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) // seq 1 is the constructor's end-seed marker. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }, @@ -215,7 +224,9 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([ { seq: 0, data: { policy: 'never', source: 'delegation' } }, ]) @@ -255,9 +266,11 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const asked = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const decided = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', ) 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 106b9e3eaa..e4ec2605b0 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 @@ -85,6 +85,7 @@ describe('a child agent composed in-process', () => { const run = await startInProcessRun(spawnRequest(parent), {}) await run.result + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(run.localAgent?.session.snapshotEvents().some(event => event.type === 'system/message' && JSON.stringify(event.data.message.content).includes('section for preset_only'))).toBe(true) 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 fa4bfdb22b..70398a2f9f 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -196,6 +196,7 @@ describe('in-process structured output', () => { expect(result.structured).toEqual({ answer: 5 }) expect(sideEffectRan).toBe(false) const child = ctx.agents.get(run.id) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -240,6 +241,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)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -258,6 +260,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)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) @@ -323,6 +326,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)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -368,6 +372,7 @@ describe('in-process structured output', () => { expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') const child = ctx.agents.get(run.id) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -440,6 +445,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') expect(adapter.requests).toHaveLength(2) const child = ctx.agents.get(run.id)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) 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 d11fa73b05..e274ac7cb4 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 @@ -150,6 +150,7 @@ describe('startInProcessRun', () => { let injected = false ctx.on('session/flush', (session) => { if (injected || session.header.parentSession === undefined) return + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastEnd = session.snapshotEvents().findLast(event => event.type === 'turn/end') if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return injected = true @@ -164,6 +165,7 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(injected).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().findLast(event => event.type === 'turn/end')) .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) expect(result.stopReason).toBe('max-tokens') @@ -199,6 +201,7 @@ 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = parent.session.snapshotEvents() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result @@ -206,11 +209,13 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(child.session.header.isSeeded).toBe(true) expect(child.session.inheritedEventCount).toBe(seed.length) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) // The seeded `system/message` stays surface node 0: the child renders the // same prompt, so its loop appends no second system node. const seededSystem = seed.find(event => event.type === 'system/message') expect(seededSystem).toBeDefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) expect(child.session.surface.nodes[0]).toBe(seededSystem?.seq) expect(child.session.deriveMessages()[0]).toEqual(parent.session.deriveMessages()[0]) @@ -221,6 +226,7 @@ describe('startInProcessRun', () => { const { ctx, parent, adapter } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = parent.session.snapshotEvents() const seededSystem = seed.find(event => event.type === 'system/message') if (seededSystem === undefined) throw new Error('the parent log lacks a system node') @@ -229,6 +235,7 @@ describe('startInProcessRun', () => { const run = await startInProcessRun(request(parent), { seed }) await run.result const child = ctx.agents.get(run.id)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = child.session.snapshotEvents().filter(event => event.type === 'system/message') expect(systemNodes.map(event => event.seq)).toEqual([seededSystem.seq, systemNodes[1]?.seq]) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: seededSystem.seq, endSeq: seededSystem.seq }) @@ -360,6 +367,7 @@ describe('startInProcessRun', () => { }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index 3e764070f7..c031772384 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,6 +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). + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = parent.session.snapshotEvents() const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) 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 b678f5c263..957496e492 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 @@ -118,6 +118,7 @@ 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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentEventCount = parent.session.snapshotEvents().length expect(parentEventCount).toBeGreaterThan(0) @@ -125,6 +126,7 @@ describe('dsh-subagent-spawn-in-process', () => { 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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstUser = child.session.snapshotEvents().find(e => e.type === 'user/message') expect(firstUser).toBeDefined() await run.dispose() @@ -439,6 +441,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)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index ba2db8e62c..adeba106f6 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -198,6 +198,7 @@ export function createActivationObserver( emit('subagent/start', identity, parent) }, capture: (child: Agent): void => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const own = child.session.snapshotEvents(boundary) const output = finalAssistantOutput(own) captured = { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index f2bbd16ef5..ecaa72a347 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -258,6 +258,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.snapshotEvents(), 'child task') }) }) @@ -450,6 +451,7 @@ describe('SubagentRuntime.startContinuable', () => { })).rejects.toBe(catalogFailure) await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().filter(event => event.type === 'subagent/catalog')).toEqual([]) }) @@ -529,6 +531,7 @@ describe('SubagentRuntime.startContinuable', () => { expect(found).toBeDefined() return found! }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const descriptor = child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor') expect(descriptor?.data).toEqual({ @@ -564,6 +567,7 @@ describe('SubagentRuntime.startContinuable', () => { return found! }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor')?.data) .toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, @@ -1155,6 +1159,7 @@ describe('continuable durability and teardown', () => { child.followup(createUserMessage({ content: message('accepted during flush'), source: { kind: 'user' } })) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(hasAssistantText(child.session.snapshotEvents(), 'late answer')).toBe(true) }) await child.whenIdle() @@ -1882,6 +1887,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)! + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = child.session.snapshotEvents().length const controller = new AbortController() @@ -2343,6 +2349,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 }[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 [] @@ -2405,6 +2412,7 @@ describe('continuable adjacent-Agent delivery', () => { await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const delivered = parent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'agent-message' ? [event.data] : [])[0] expect(delivered?.id).toBe(messageId) @@ -2699,6 +2707,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( @@ -2867,7 +2876,9 @@ describe('continuable settlement delivery', () => { `Background subagent ${started.childId} was stopped before it finished.` + '\nIt left no closing message.', ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(parent.status).toBe('idle') }) @@ -2884,6 +2895,7 @@ describe('continuable settlement delivery', () => { await drained expect(settlementNotices(parent)).toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 5ca770d7d7..4e8cc4b6b1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -619,6 +619,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { } const selectForSession = (target: Session): ModelSelectionPolicy | undefined => { const freshSession = target.firstLiveSeq === 0 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target) if (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 f5921a3fab..2af3149716 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -347,6 +347,7 @@ describe('SubagentModelSelectionConfig', () => { const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.snapshotEvents() }) expect(selectable(ctx, resumedEnabled)).toBe(true) @@ -359,6 +360,7 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, resumedEmpty)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index af707d8269..78216805a3 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -618,6 +618,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', ) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(1) const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -628,6 +629,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() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(2) }) diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index f4235aad18..f65454023a 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -96,6 +96,7 @@ describe('dsh-agent-loop-testkit', () => { { id: step.id, turn: 3 }, { id: turn.id, turn: 3 }, ]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'agent/inbox/spliced', 'agent/inbox/spliced', 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 f80929880d..31292f374c 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,11 +19,13 @@ 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 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. .map(seq => agent.session.snapshotEvents()[seq]) .find(event => event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const openTurn = agent.session.snapshotEvents().findLast(event => event.type === 'turn/start') if (openTurn?.type !== 'turn/start') throw new Error('workspace snapshot compaction has no open turn') const compactionId = CompactionId('workspace-context-fixture') diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 21970cbcb9..d92563a06f 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -60,6 +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 } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 3384372a65..cde02c8482 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -63,6 +63,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -91,8 +92,10 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const todoEvents = agent.session.snapshotEvents().filter(e => e.type === 'todo/write') expect(todoEvents).toHaveLength(2) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 4b41b6a301..0501982bdb 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,9 +66,11 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents() expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual(before) }) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index b99909fa2a..039ba4dc1f 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -112,6 +112,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') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }, 30_000) @@ -129,6 +130,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { agent: owner, }) expect(result.isError).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index a3d60f5b63..b045128eac 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -82,6 +82,7 @@ describe('dsh-tool-todo', () => { }) expect(text(result)).toContain('1 pending, 1 in progress, 0 completed') + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual(todos) }) @@ -92,6 +93,7 @@ describe('dsh-tool-todo', () => { const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent }) expect(result.isError).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual([{ content: 'plan the work', status: 'pending' }]) }) @@ -105,6 +107,7 @@ describe('dsh-tool-todo', () => { { content: 'b', status: 'in_progress' }, ] }, { agent }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const current = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'a', status: 'completed' }, @@ -139,6 +142,7 @@ describe('dsh-tool-todo', () => { todos, counts: { pending: 1, inProgress: 2, completed: 0 }, }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) @@ -155,6 +159,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. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }) @@ -247,6 +252,7 @@ describe('todo/write event', () => { ] session.append('todo/write', { todos }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.type).toBe('todo/write') expect(event.data.todos).toEqual(todos) @@ -268,6 +274,7 @@ describe('todo/write event', () => { { content: 'second', status: 'in_progress' }, ] }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const current = session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'first', status: 'completed' }, @@ -293,10 +300,13 @@ 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' } }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('t4-replay'), original.snapshotEvents()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents(SessionLogOffset(0), original.seq)).toEqual(original.snapshotEvents()) expect(replayed.firstLiveSeq).toBe(original.seq) }) diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index d36174b94f..1d41b5c216 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -135,6 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 e3404d6c12..e752ae307c 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -146,6 +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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], ['tool-workflow/agent-start', { @@ -166,9 +167,11 @@ describe('dsh-tool-workflow', () => { value: null, stopReason: 'completed', agentsStarted: 0, }) await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual(['tool-workflow/run-start']) barrier.resolve(undefined) expect((await pending).isError).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'tool-workflow/run-start', 'tool-workflow/run-end', ]) @@ -190,8 +193,10 @@ 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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/agent-start')) .toHaveLength(1) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) .toEqual([ { runId: 'run-1', stopReason: 'completed' }, @@ -208,6 +213,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) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -240,6 +246,7 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain(failedType) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = session.snapshotEvents().map(event => event.type) const expectedPrefixes = { 'tool-workflow/run-start': [], diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 1141067ad2..f358f71d9c 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -254,6 +254,59 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + } }, 90_000) + it('allows waived Session history reads while rejecting new reads and unrelated deprecations', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) + const existing = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session' + +export function existingReads(session: Session): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + session.snapshotEvents() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + session.eventAt(SessionSeq(0)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + session.ownEvents() +} +` + const added = ` +/** @deprecated Use the replacement API. */ +function oldApi(): void {} + +export function newReads(session: Session): void { + session.snapshotEvents() + session.eventAt(SessionSeq(0)) + session.ownEvents() + oldApi() +} +` + + try { + await writeFile(path, existing) + const args = ['--config', relative(repositoryRoot, configPath), '--format', 'unix', relative(repositoryRoot, path)] + const allowed = runRepositoryOxlint(args) + expect(allowed.error).toBeUndefined() + expect(allowed.signal).toBeNull() + expect(allowed.status, normalizedOutput(allowed)).toBe(0) + + await writeFile(path, existing + added) + const rejected = runRepositoryOxlint(args) + const output = normalizedOutput(rejected) + expect(rejected.error).toBeUndefined() + expect(rejected.signal).toBeNull() + expect(rejected.status, output).toBe(1) + expect(output.match(/typescript\(no-deprecated\)/g), output).toHaveLength(4) + for (const method of ['snapshotEvents', 'eventAt', 'ownEvents', 'oldApi']) { + expect(output).toContain(`\`${method}\` is deprecated`) + } + } finally { + await Promise.all([ + rm(path, { force: true }), + rm(configPath, { force: true }), + ]) + } + }, 90_000) + it('accepts an ignored-only staged selection', () => { const result = runOxlint([ '--fix', diff --git a/scripts/python-snapshot-workflow-order.spec.ts b/scripts/python-snapshot-workflow-order.spec.ts index 964244f5da..84f401c51b 100644 --- a/scripts/python-snapshot-workflow-order.spec.ts +++ b/scripts/python-snapshot-workflow-order.spec.ts @@ -82,6 +82,7 @@ describe('advanced Python snapshot workflow ordering', () => { const child = await entered.promise expect(child.id).toBe(run.id) expect(adapter.requests).toHaveLength(0) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false) parent.session.append('tool-workflow/agent-start', { runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id, From 8f986c8da63a212bfa6c64e4d58bbcafbc03a63e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 12:55:32 +0800 Subject: [PATCH 04/60] chore(session): scope history reader exemptions to tests --- ...-synchronous-session-event-reads.i18n.yaml | 4 +- ...precate-synchronous-session-event-reads.md | 8 ++- ...cate-synchronous-session-event-reads.zh.md | 8 ++- .oxlintrc.json | 13 ++++ .../headless/tests/coding-task.e2e.ts | 1 - .../profiles/headless/tests/compaction.e2e.ts | 1 - .../profiles/headless/tests/full-loop.e2e.ts | 1 - .../tests/profiles/headless/tests/ptc.e2e.ts | 3 - .../profiles/headless/tests/resume.e2e.ts | 1 - .../profiles/headless/tests/todo-write.e2e.ts | 1 - .../tests/chat-continuous-conversation.e2e.ts | 1 - apps/web/tests/chat-long-interactions.e2e.ts | 11 --- apps/web/tests/chat-scroll-fixture.ts | 1 - apps/web/tests/clickable-links-gallery.e2e.ts | 1 - apps/web/tests/complex-history.perf.ts | 1 - apps/web/tests/feedback-release.e2e.ts | 1 - .../tests/goal-command-presentation.e2e.ts | 1 - apps/web/tests/markdown-cjk-strong.e2e.ts | 1 - apps/web/tests/markdown-images.e2e.ts | 1 - .../tests/markdown-inline-code-links.e2e.ts | 1 - apps/web/tests/markdown-wide-table.e2e.ts | 1 - apps/web/tests/math-rendering.e2e.ts | 1 - apps/web/tests/minimal-preset.snapshot.ts | 1 - apps/web/tests/preset-migration.snapshot.ts | 1 - apps/web/tests/produced-file-mentions.e2e.ts | 1 - apps/web/tests/produced-files.e2e.ts | 1 - apps/web/tests/reference-composer.e2e.ts | 2 - apps/web/tests/scaffold.ts | 2 - apps/web/tests/schedule-after.e2e.ts | 8 --- apps/web/tests/seeded-history.e2e.ts | 1 - apps/web/tests/settings-chrome.e2e.ts | 4 -- apps/web/tests/subagent-interrupt-ui.e2e.ts | 1 - apps/web/tests/subagent-interrupt.e2e.ts | 2 - packages/acp/acp/tests/bridge.spec.ts | 2 - packages/acp/acp/tests/turns.spec.ts | 5 -- .../commands-queue-attachment.host.spec.ts | 1 - .../tests/session-cold.host.spec.ts | 3 - .../tests/session-fork.host.spec.ts | 6 -- .../tests/session-projections.host.spec.ts | 3 - .../tests/session-rename.host.spec.ts | 1 - .../tests/transport.host.spec.ts | 1 - .../spill-policy-terminal.client.spec.ts | 1 - .../tests/command-compact.spec.ts | 1 - .../tests/loader-composition.spec.ts | 1 - .../tests/compaction-basic.spec.ts | 29 -------- .../tests/compaction-loop-repro.spec.ts | 12 ---- .../tests/manual-compaction.spec.ts | 29 -------- .../tests/tool-result-pruner.spec.ts | 4 -- .../compaction/tests/compaction.spec.ts | 3 - .../compaction/tests/invariant.spec.ts | 8 --- .../compaction/tests/tool-pairing.spec.ts | 1 - .../tests/agent-instructions.e2e.ts | 3 - .../tests/agent-instructions.spec.ts | 34 --------- .../tests/loader-composition.spec.ts | 1 - .../tests/session-reference.spec.ts | 4 -- .../time-context/tests/time-context.spec.ts | 13 ---- .../tmux-context/tests/tmux-context.spec.ts | 2 - .../agent-loop/tests/agent-initiator.spec.ts | 1 - packages/core/agent-loop/tests/agent.spec.ts | 4 -- packages/core/agent-loop/tests/cancel.spec.ts | 42 ----------- .../tests/contract-regressions.spec.ts | 40 ----------- .../agent-loop/tests/coverage-edges.spec.ts | 18 ----- packages/core/agent-loop/tests/inbox.spec.ts | 4 -- .../agent-loop/tests/interception.spec.ts | 1 - packages/core/agent-loop/tests/loop.spec.ts | 69 ------------------- .../core/agent-loop/tests/properties.spec.ts | 4 -- .../agent-loop/tests/request-cache.e2e.ts | 1 - .../agent-loop/tests/request-error.spec.ts | 6 -- .../agent-loop/tests/request-freeze.spec.ts | 3 - .../tests/request-reconstruction.spec.ts | 41 ----------- packages/core/agent-loop/tests/resume.spec.ts | 9 --- .../agent-loop/tests/scope-lifecycle.spec.ts | 3 - .../tests/system-prompt-admission.spec.ts | 20 ------ .../core/agent-loop/tests/tool-calls.spec.ts | 1 - .../core/agent-loop/tests/tool-order.spec.ts | 8 --- .../core/agent/tests/consumed-work.spec.ts | 13 ---- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- .../session/tests/canonical-envelopes.spec.ts | 5 -- .../core/session/tests/derived-cache.spec.ts | 1 - packages/core/session/tests/fork.spec.ts | 13 ---- packages/core/session/tests/invariant.spec.ts | 2 - .../core/session/tests/properties.spec.ts | 8 --- .../core/session/tests/request-header.spec.ts | 1 - .../core/session/tests/sequence-types.spec.ts | 4 -- packages/core/session/tests/session.spec.ts | 53 -------------- packages/core/session/tests/surface.spec.ts | 20 ------ packages/core/tools/tests/invariant.spec.ts | 1 - packages/core/tools/tests/ptc.spec.ts | 1 - .../agent-team/tests/invariant.spec.ts | 2 - .../agent-team/tests/persistence.spec.ts | 3 - .../agent-team/tests/team.spec.ts | 7 -- .../agent-team/tests/test-session-query.ts | 1 - .../tool-agent-team/tests/tool-team.spec.ts | 1 - .../tests/command-feedback.spec.ts | 10 --- .../tests/loader-composition.spec.ts | 4 -- .../message-feedback/tests/helpers.ts | 1 - .../tests/message-feedback.spec.ts | 9 --- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 1 - .../fs/tool-present/tests/present.spec.ts | 5 -- .../command-goal/tests/command-goal.spec.ts | 4 -- .../tests/goal-round-driver.spec.ts | 10 --- packages/goal/goal/tests/goal.spec.ts | 28 -------- packages/goal/goal/tests/projection.spec.ts | 1 - .../goal/tool-goal/tests/tool-goal.spec.ts | 2 - .../tests/repeat-tool-reminder.spec.ts | 3 - .../hooks/hook-protocol/tests/events.spec.ts | 9 --- .../hooks-claude-code/tests/bridge.spec.ts | 1 - .../hooks-claude-code/tests/coverage-cases.ts | 1 - .../hooks/hooks-codex/tests/bridge.spec.ts | 1 - .../hooks/hooks-codex/tests/coverage-cases.ts | 1 - .../commands/tests/commands.spec.ts | 5 -- .../tests/permission-presets.spec.ts | 18 ----- .../tests/projection.spec.ts | 4 -- .../user-approval/tests/approval.spec.ts | 9 --- .../tests/loader-composition.spec.ts | 1 - packages/llm/llm-retry/tests/retry.spec.ts | 25 ------- .../tests/transport-recovery.spec.ts | 17 ----- .../context-breakdown-projection.spec.ts | 6 -- .../llm/token-meter/tests/token-meter.spec.ts | 6 -- .../plan/plan-mode/tests/integration.spec.ts | 6 -- .../plan/plan-mode/tests/plan-mode.spec.ts | 39 ----------- .../plan/plan-mode/tests/projection.spec.ts | 1 - .../preset/agent-presets/tests/remote.spec.ts | 1 - .../sandbox-policy/tests/policy.spec.ts | 2 - .../schedule/schedule/tests/invariant.spec.ts | 5 -- .../schedule/tests/jsonl-restart.spec.ts | 2 - .../schedule/schedule/tests/runtime.spec.ts | 11 --- .../schedule/schedule/tests/tools.spec.ts | 9 --- .../tests/loader-composition.host.spec.ts | 1 - .../session-query/tests/session-query.spec.ts | 2 - .../tests/tool-session-query.spec.ts | 1 - .../tests/feedback-composition.spec.ts | 2 - .../session-log-deepseek/tests/upload.spec.ts | 4 -- .../tests/multi-edge-publication.spec.ts | 1 - .../tests/v2-system-migration.spec.ts | 6 -- .../session-telemetry-otel/tests/otel.spec.ts | 15 ---- .../session-telemetry/tests/redact.spec.ts | 2 - .../session-telemetry/tests/telemetry.spec.ts | 8 --- .../tests/provider.spec.ts | 1 - .../session-title-llm/tests/llm.spec.ts | 4 -- .../session-title/tests/projection.spec.ts | 1 - .../session-title/tests/provider.spec.ts | 3 - .../session-title/tests/rename.spec.ts | 3 - .../tests/service-contracts.spec.ts | 6 -- .../session-title/tests/session-title.spec.ts | 5 -- .../shell/tool-bash/tests/integration.spec.ts | 1 - packages/shell/tool-bash/tests/tools.spec.ts | 1 - .../skill/tool-skill/tests/tool-skill.spec.ts | 1 - .../tests/multi-subagent.spec.ts | 4 -- .../tests/subagent-fork-in-process.spec.ts | 8 --- .../tests/inheritance.spec.ts | 13 ---- .../tests/preset-inheritance.spec.ts | 1 - .../tests/structured.spec.ts | 6 -- .../tests/subagent-in-process-driver.spec.ts | 8 --- .../tests/spawn-in-process.e2e.ts | 1 - .../tests/subagent-spawn-in-process.spec.ts | 3 - .../subagent/tests/continuation.spec.ts | 12 ---- .../tests/model-selection-settings.spec.ts | 2 - .../terminal-bash/tests/index.spec.ts | 2 - .../tests/agent-loop-testkit.spec.ts | 1 - .../fixtures/workspace-context-compaction.ts | 2 - .../todo/tool-todo/tests/integration.spec.ts | 3 - .../todo/tool-todo/tests/invariant.spec.ts | 2 - .../tests/loader-composition.spec.ts | 2 - .../todo/tool-todo/tests/tool-todo.spec.ts | 10 --- .../tool-workflow/tests/tool-workflow.spec.ts | 7 -- scripts/oxlint-contract.spec.ts | 54 +++++++++++---- .../python-snapshot-workflow-order.spec.ts | 1 - 170 files changed, 72 insertions(+), 1070 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml index 51e978ca70..7a61bc540e 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md -2026-09-09-deprecate-synchronous-session-event-reads.md: 0fcbfbc2e21792f67d1bdbe6d32ac3106338ac45 -2026-09-09-deprecate-synchronous-session-event-reads.zh.md: d1f22b483380a7a38a5cd1208815c5fc0ae25348 +2026-09-09-deprecate-synchronous-session-event-reads.md: 1420227daf9a410e7db6a7a685bcf4433d7d7b0b +2026-09-09-deprecate-synchronous-session-event-reads.zh.md: e546337bcb15cf6682935729de37aab0c579d2a2 diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md index 0fcbfbc2e2..1420227daf 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md @@ -16,6 +16,8 @@ All operations that synchronously read arbitrary positions or ranges of Session The three methods carry this rule in `@deprecated` JSDoc. This is an API-use decision; the current Session implementation still retains the complete event sequence in memory. +Repository test files may call these three readers to inspect emitted events and exercise Session history behavior. Their existing lint override allows `snapshotEvents`, `eventAt`, and `ownEvents`; all other deprecated names remain errors. This allowance also covers unrelated declarations with the same three names under the current linter. It does not apply to production source or ordinary repository scripts. + ### State needed after resume Design durable event fields and Session projections together so each domain can reconstruct the state its consumers need. Restore that state during resume, then maintain it incrementally from newly committed events. After resume, ordinary logic reads the projection or processes the delivered current event instead of looking back through historical events. Reading already-maintained projection state synchronously does not require arbitrary access to the event log. @@ -34,13 +36,15 @@ Fork and a small number of operations may genuinely need a complete historical s **Require every existing caller to migrate immediately.** Existing logic may defer migration under this decision. Preventing new dependencies bounds the remaining work without making every existing consumer part of the same change. -**Disable deprecation lint for these methods or whole files.** Such exemptions also admit new calls. Line-scoped waivers make the existing migration debt explicit while retaining the native type-aware rule for new reads. +**Disable deprecation lint throughout test files or exempt production readers by name.** Tests only need the three reader names; other deprecated APIs must remain errors. Production code retains the prohibition on new synchronous reads. + +**Add a separate test-only reader API.** The three existing readers already expose the observations these tests need. Wrapping them adds an API and production-import checks without changing those observations. ## Consequences New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log. -Existing calls carry line-scoped `typescript/no-deprecated` waivers with a migration-deferral reason. The rule stays enabled for new calls and unrelated deprecated APIs. Remove a waiver when its call migrates; copying a waiver to a new call violates this policy. The executable lint check exercises all three readers with and without waivers, plus an unrelated deprecated API. Documentation checks verify the source-equivalent API declarations and bilingual records. +Existing calls outside test files carry line-scoped `typescript/no-deprecated` waivers with a migration-deferral reason. Remove a waiver when its call migrates; copying a waiver to a new production call violates this policy. The executable lint check accepts test reads and existing waived reads, rejects unwaived production reads, and rejects unrelated deprecated APIs in tests. Documentation checks verify the source-equivalent API declarations and bilingual records. ## Related decisions diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md index d1f22b4833..e546337bcb 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md @@ -16,6 +16,8 @@ Status: implemented 这三个方法通过 `@deprecated` JSDoc 声明该规则。这是 API 使用决策;当前 Session 实现仍在内存中保留完整事件序列。 +仓库测试文件可以调用这三个读取方法,以检查已发出的事件并验证 Session 历史行为。现有测试 lint override 允许 `snapshotEvents`、`eventAt` 和 `ownEvents`,其他弃用名称仍报错。在当前 linter 下,此豁免也覆盖使用这三个名称的其他声明。它不适用于生产源码或普通仓库脚本。 + ### 恢复后需要的状态 结合设计持久事件字段与会话投影,使每个领域都能重建其消费方所需的状态。在恢复期间还原这些状态,随后通过新提交的事件增量维护。恢复后,普通逻辑读取投影或处理当前收到的事件,而不是回头查找历史事件。同步读取已经维护好的投影状态,不需要任意访问事件日志。 @@ -34,13 +36,15 @@ fork 等少数操作可能确实需要完整历史序列或继承前缀。它们 **要求立即迁移所有现有调用方。** 本决策允许现有逻辑推迟迁移。禁止新增依赖可以限制剩余工作规模,无需将每个现有消费方都纳入同一次修改。 -**为这些方法或整个文件禁用弃用 lint。** 这种豁免也会放过新增调用。逐行豁免明确标出已有的迁移负担,同时继续由原生类型感知规则检查新增读取。 +**在整个测试文件中禁用弃用 lint,或按名称豁免生产代码中的读取方法。** 测试只需要这三个读取方法名;其他弃用 API 必须继续报错。生产代码仍禁止新增同步读取。 + +**添加单独的测试专用读取 API。** 三个现有读取方法已经提供这些测试所需的观察结果。包装它们会增加一个 API 及生产代码导入检查,却不会改变这些观察结果。 ## 后果 新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。 -现有调用带有逐行的 `typescript/no-deprecated` 豁免,并说明暂缓迁移的理由。该规则对新增调用及其他已弃用 API 继续生效。调用迁移后应删除豁免;将豁免复制到新增调用违反本策略。实际执行 lint 的检查覆盖三个读取方法有无豁免的情况,以及另一个已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。 +测试文件之外的现有调用带有逐行的 `typescript/no-deprecated` 豁免,并说明暂缓迁移的理由。调用迁移后应删除豁免;将豁免复制到新增生产调用违反本策略。实际执行 lint 的检查允许测试读取和已有豁免调用,拒绝未豁免的生产读取,并拒绝测试中其他已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。 ## 相关决策 diff --git a/.oxlintrc.json b/.oxlintrc.json index 1f11a9e989..348b057bc3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -203,6 +203,19 @@ "scripts/**/*.spec.{ts,tsx}" ], "rules": { + // Session assertions inspect the log; other deprecated APIs remain errors. + "typescript/no-deprecated": [ + "error", + { + "allow": [ + { + "from": "file", + "name": ["snapshotEvents", "eventAt", "ownEvents"], + "path": "packages/core/session/src/index.ts" + } + ] + } + ], "typescript/no-invalid-void-type": "error", "typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence. "typescript/no-unnecessary-condition": "off", 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 4b78389bb3..bb0b4297b8 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,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summary = finalText(agent.session.snapshotEvents()).toLowerCase() expect(summary.length).toBeGreaterThan(0) diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 263b794778..6e3f46c266 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,7 +56,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() // A compaction ran: the start…end bracket landed in the real log. 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 74db5cc44a..6816c60f61 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,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 557b632132..c666871584 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,7 +366,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events: readonly SessionEvent[] = agent.session.snapshotEvents() // The wire contract: every request this session made offered EXACTLY ONE @@ -419,12 +418,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events: readonly SessionEvent[] = handle.agent.session.snapshotEvents() const dispatch = events.find(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index ce8c0bd91d..579b082906 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,7 +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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 7a201279f8..4ff5fef99c 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,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() // The model actually called the tool. diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 062f5bceac..c539a96601 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -334,7 +334,6 @@ describe('web e2e: continuous conversation grown through the composer', () => { } if (sessionId === undefined) throw new Error('continuous conversation completed no turn') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => ( event.type === 'turn/end' && event.data.reason.kind === 'completed' ))).toHaveLength(TURN_COUNT) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index fbc0424c8c..17e639814c 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -184,17 +184,12 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker) const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) @@ -320,11 +315,8 @@ 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.inheritedEventCount).toBe(boundary.seq + 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) @@ -342,13 +334,10 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(event => ( event.type === 'user/message' && carries(event, CONTINUE_PROMPT) ))).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index bd060c57ff..34ebcc8d1b 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -178,7 +178,6 @@ function fixtureLog(session: Session): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 09423cd4ac..ea3b86dbb5 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -287,7 +287,6 @@ function galleryFixture(imageUrl: string): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index bc909b45f5..faa0146134 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -329,7 +329,6 @@ function fixtureLog(session: Session): string { } return [ JSON.stringify(header), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index 30e12833ee..b63f3c6213 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -275,7 +275,6 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', expect(captured()).toHaveLength(releasedCount) await input.press('Enter') const findHeaderless = () => scaffold.ctx.sessions.list().find(session => session.id !== sessionId - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && session.snapshotEvents().some(event => event.type === 'feedback/record')) // A command-only session keeps the hero view; its durable event confirms submission. await expect.poll(findHeaderless, { timeout: 10_000 }).toBeDefined() diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 157dbf947e..70ad37a799 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -124,7 +124,6 @@ describe('web e2e: /goal human transcript presentation', () => { const sessions = scaffold.ctx.sessions.list() expect(sessions).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index 40994debcb..237bbd9d3d 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -79,7 +79,6 @@ function markdownFixture(): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 f7f369096b..b8f18c6c67 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -144,7 +144,6 @@ function markdownImageFixture(remoteUrl: string, outsidePath: 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 ac7be4e205..62bbf589aa 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -76,7 +76,6 @@ function markdownFixture(linkUrl: string): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 9607969fef..09f980ab5c 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -142,7 +142,6 @@ function wideTableFixture(): string { return [ JSON.stringify(header), // Spaced event times, as the sibling markdown fixtures pin them. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 1363eb35cf..c826d5aff2 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -80,7 +80,6 @@ function mathFixture(): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 900cb34055..22ab1bc476 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -81,7 +81,6 @@ describe('minimal agent preset', () => { if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') const systemPrompt = systemPromptText(agentHandle.agent.session) if (systemPrompt === undefined) throw new Error('the minimal agent issued no system prompt') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index a68c5c9f30..7fb3d881d2 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -45,7 +45,6 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t if ('error' in resolved) throw resolved.error expect(scaffold.ctx.agentPresets.composedPreset(resolved.agent.ctx)).toBe('ptc') expect(resolved.agent.session.header.agentPreset).toBe('ptc') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index 91919dad25..b8ec77c693 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -112,7 +112,6 @@ function mentionFixture(): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 acb267b644..1097ed3b1e 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -99,7 +99,6 @@ function producedFixture(): string { type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...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 3870715531..39c7786296 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -73,7 +73,6 @@ function sourceSessionFixture(): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') @@ -123,7 +122,6 @@ function targetSessionFixture(): string { isSeeded: false, delegationDepth: 0, }), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ada371450b..4bc734aa4b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -877,7 +877,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise sessionFormatCatalog.encodeCurrentEvent(event)) const header = sessionFormatCatalog.encodeCurrentHeader({ @@ -1031,7 +1030,6 @@ async function assertReplaySession( const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { if (session.header.parentSession !== undefined) return false - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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('') diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index fd7a7bf461..587a355f4f 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -205,7 +205,6 @@ async function waitForReply( ): Promise> { const deadline = Date.now() + timeoutMs while (true) { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => ( candidate.type === 'assistant/message' && assistantText(candidate) === text )) @@ -456,7 +455,6 @@ 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)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' @@ -472,7 +470,6 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const decision = acceptedAt[0] if (decision === undefined) throw new Error('missing Every decision time') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const batch = everyHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -496,7 +493,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -520,7 +516,6 @@ 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')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const user = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'user' @@ -546,13 +541,11 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { } expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const created = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'schedule/change' && event.data.operation === 'create' @@ -567,7 +560,6 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { prompt: AT_PROMPT, scheduledAt, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(atHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 3472c80c57..09110c5360 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -495,7 +495,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') ?? [] diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index ed5b270188..8ef2af7048 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -144,7 +144,6 @@ 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')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) @@ -161,12 +160,10 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'read-only' }], ['sandbox/mode', { mode: 'read-only' }], @@ -184,7 +181,6 @@ 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')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 7fe4b6f2de..11d96c63fd 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -320,7 +320,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 9031d5577b..71e25c5918 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -159,9 +159,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(child).toBeDefined() expect(child!.status).toBe('idle') expect(child!.inbox.nextTurn).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastEnd = child!.session.snapshotEvents().filter(event => event.type === 'turn/end').at(-1) expect((lastEnd)?.data.reason.kind).toBe('aborted') diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 218a88ada4..ad1fa30fac 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -902,7 +902,6 @@ 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)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==') }) @@ -976,7 +975,6 @@ describe('automation-only ACP bridge', () => { sessionId, prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], })).rejects.toThrow(/inline image prompts were not advertised/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 8b8e38d8ab..a17223750f 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -216,7 +216,6 @@ describe('ACP prompt lifecycle', () => { const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) .finally(() => { settled = true }) await vi.waitFor(() => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.length > 0)).toHaveLength(2) }) @@ -317,7 +316,6 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) expect(harness.adapter.requests).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents() ?? [] expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) }) @@ -445,7 +443,6 @@ describe('ACP prompt lifecycle', () => { await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -471,14 +468,12 @@ describe('ACP prompt lifecycle', () => { source: { kind: 'plugin', plugin: 'test' }, })) await vi.waitFor(() => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) await harness.client.cancel({ sessionId }) await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 22a38f92f9..d7559bc047 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -41,7 +41,6 @@ async function commandHarness( // never appends one: the identity folds as continuable, but from the // inherited prefix rather than this Session's own suffix. const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only' - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ? ancestor.snapshotEvents() : undefined const session = ctx.sessions.create(sessionId, { 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 54003d6a65..eaca25062f 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -203,7 +203,6 @@ describe('attached updatedAt tracks human prompts', () => { meta: { cwd: '/proj', createdAt: 500 }, }) ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = resumed.snapshotEvents().at(-1) expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) @@ -718,7 +717,6 @@ describe('sessions.prompt synchronous rejection', () => { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const initialEvents = session.snapshotEvents() const rejectedContent: readonly SessionPromptRequest['content'][] = [ [], @@ -743,7 +741,6 @@ describe('sessions.prompt synchronous rejection', () => { } expect(followup).not.toHaveBeenCalled() expect(steer).not.toHaveBeenCalled() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual(initialEvents) const queued = await remote.prompt(promptRequest({ 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 355f99c0a6..1661f505bc 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -95,7 +95,6 @@ describe('sessions.fork', () => { expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) @@ -215,14 +214,12 @@ describe('sessions.fork', () => { const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } @@ -246,12 +243,10 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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', @@ -263,7 +258,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const anchor = source.snapshotEvents().at(-1)?.seq ?? 0 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ 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 9339df6779..90ffb10456 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -172,7 +172,6 @@ describe('session.history projections block', () => { parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const inheritedEventCount = parent.seq const child = ctx.sessions.create(SessionId('wire-seed-child'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: parent.snapshotEvents(), inheritedEventCount, meta: { @@ -449,7 +448,6 @@ describe('session.history projections block', () => { expect('test/last-user' in after.projections.values).toBe(false) expect(after.projections.values.sessionListMetadata).toEqual({ blank: true, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. lastPromptAt: session.eventAt(SessionSeq(session.seq - 1))?.time, }) }) @@ -484,7 +482,6 @@ describe('session.list projections column', () => { expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) expect(row?.projections?.values.sessionListMetadata).toEqual({ blank: false, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. lastPromptAt: session.eventAt(SessionSeq(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 55ed1e32e0..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,6 @@ describe('sessions.rename', () => { expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 9ef70e5d8d..ebc731769e 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -230,7 +230,6 @@ describe('SessionHistoryController', () => { observed.resolve({ source: 'live', header: attached.header, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: attached.snapshotEvents(), cursor: attached.seq - 1, projections: { asOfSeq: attached.seq - 1, values: {} }, diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 0716dd609f..0db57e2139 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -70,7 +70,6 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl let block: ToolResultNode if (nested) { expect(result.value).toMatchObject({ result: true }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const dispatches = session.snapshotEvents().filter(event => event.type === 'tool/ptc-dispatch') expect(dispatches).toHaveLength(1) const event = dispatches[0]! diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 92013469ac..f2ab167d69 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -121,7 +121,6 @@ function expectLastLifecycle( args: string, outcome: CommandResult, ): string { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lifecycle = test.agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .slice(-2) diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index 5b8402aae7..442d8196dc 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -130,7 +130,6 @@ describe('command-compact real Loader composition', () => { text: 'Compacted 3 history items (~99 tokens).', sourceEventSeq: RESULT.summarySeq, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([ { type: 'command/run', diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index df5cbce97c..bd6a48af66 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -625,7 +625,6 @@ describe('pressure measurement and retention', () => { await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() expect(session.surface.replaceGeneration).toBe(generation) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) @@ -647,7 +646,6 @@ describe('pressure measurement and retention', () => { const compact = service(compactConfig, ctx) const session = conversation(4, undefined, 'SYSTEM HEAD') const [head, firstUser] = session.surface.nodes - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(head!)?.type).toBe('system/message') const range = selectCompactableRange(session, ctx.tokenMeter.measure(session), compactConfig.retainTokens!) @@ -891,11 +889,9 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) }) @@ -922,7 +918,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, @@ -939,7 +934,6 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replay = Session.create(SessionId('replay'), session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) @@ -964,7 +958,6 @@ describe('compaction region transaction', () => { const compact = service() const emptyHead = conversation(3, undefined, '') const emptyNodes = emptyHead.surface.nodes - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(emptyHead.eventAt(emptyNodes[0]!)?.type).toBe('system/message') await compact.compactRegion(emptyNodes[1]!, emptyNodes[2]!, agent(emptyHead, MODEL), SIGNAL) expect(compact.calls[0]!.input.messages[0]).toMatchObject({ role: 'user' }) @@ -986,7 +979,6 @@ describe('compaction region transaction', () => { .rejects.toThrow(/node 0 holds the system prompt/) expect(session.surface.nodes).toEqual(nodes) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.type === 'compaction/end' && end.data.error).toBeDefined() }) @@ -1100,7 +1092,6 @@ describe('compaction region transaction', () => { agent(session, MODEL), )).rejects.toThrow('summary unavailable') expect(session.surface.nodes).toEqual(before) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable' }) }) @@ -1115,7 +1106,6 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toBe('plain failure') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'plain failure' }) }) @@ -1136,7 +1126,6 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) }) @@ -1156,7 +1145,6 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/session surface changed/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -1174,7 +1162,6 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/summary is not smaller/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -1448,7 +1435,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], llmStreamCall: true, @@ -1575,7 +1561,6 @@ describe('automatic listener and loader composition', () => { next: () => Promise = () => Promise.resolve(undefined), ): Promise { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', @@ -1596,12 +1581,10 @@ describe('automatic listener and loader composition', () => { }) const pressured = conversation(4) await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) const small = conversation(1) await preStep(ctx, agent(small, MODEL)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) @@ -1619,7 +1602,6 @@ describe('automatic listener and loader composition', () => { .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) @@ -1636,7 +1618,6 @@ 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')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -1696,7 +1677,6 @@ describe('automatic listener and loader composition', () => { expect(decision).toBe(true) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) }) @@ -1716,7 +1696,6 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) expect(compact.calls).toHaveLength(0) }) @@ -1735,7 +1714,6 @@ describe('automatic listener and loader composition', () => { const session = toolConversation() expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1759,9 +1737,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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')) @@ -1964,11 +1940,9 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) }) @@ -1981,7 +1955,6 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -2014,7 +1987,6 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -2156,7 +2128,6 @@ describe('route-priced image pressure', () => { const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL) expect(result).not.toBeNull() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summaryEvent).toBeDefined() const shadowedHeuristic = before.nodes 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 a5834df1c7..a527226c03 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -212,7 +212,6 @@ function overflowHistorySeed(): readonly SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents() } @@ -231,9 +230,7 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -250,7 +247,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() @@ -283,7 +279,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( @@ -352,7 +347,6 @@ describe('token pressure after loop-admitted system prompts', () => { prompt = nextPrompt agent.followup(createUserMessage({ content: [{ type: 'text', text: 'question' }], source: { kind: 'user' } })) await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -360,13 +354,11 @@ describe('token pressure after loop-admitted system prompts', () => { expect(measured.baseline).toEqual({ kind: 'usage', tokens: 1110, usage }) expect(measured.surfaceDeltaTokens).toBe(0) expect(measured.totalTokens).toBe(1110) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replay = Session.create(SessionId('prompt-pressure-replay'), agent.session.snapshotEvents()) expect(ctx.tokenMeter.measure(replay)).toMatchObject({ baseline: measured.baseline, surfaceDeltaTokens: 0, totalTokens: 1110, nodes: measured.nodes, }) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const prompts = events.filter(event => event.type === 'system/message') expect(prompts[0]?.surfaceOp).toBe('append') @@ -436,7 +428,6 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, @@ -499,13 +490,10 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } })]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) .toEqual([3]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 daef651acd..9a118cd5a9 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -233,7 +233,6 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus } function compactEvents(session: Session): SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')) } @@ -296,16 +295,13 @@ describe('compactNow through the real loop', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(injected).toBeDefined() expect(end).toBeDefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.id === injected?.id)).toBe(false) @@ -340,7 +336,6 @@ describe('compactNow through the real loop', () => { expect(result).not.toBeNull() expect(agent.session.deriveMessages()[0]?.role).toBe('system') expect(derivedText(agent.session)[1]).toContain('checkpoint') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -361,7 +356,6 @@ describe('compactNow through the real loop', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) @@ -412,18 +406,13 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const summaryEvent = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const checkpoint = session.snapshotEvents().findLast( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && isCompactCheckpointSource(event.data.source), ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } expect(start?.data).toEqual({ ...correlated, turn: null }) @@ -454,11 +443,8 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reloaded = Session.create(SessionId('stale-orphan'), original.snapshotEvents()) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) @@ -476,7 +462,6 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reloaded = Session.create(SessionId('reloaded-orphan'), original.snapshotEvents()) const agent = fakeAgent(reloaded, () => () => undefined) @@ -580,7 +565,6 @@ 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']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source))).toBe(false) }) @@ -600,7 +584,6 @@ describe('compactNow transaction and failure classification', () => { expect(causeOf(error).message).toBe('boundary rejected') vi.restoreAllMocks() expect(flushes()).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().findLast(event => event.type.startsWith('compaction/'))?.type) .toBe('compaction/summary') expect(compactEvents(session).filter(event => event.type === 'compaction/start')).toHaveLength(1) @@ -667,7 +650,6 @@ describe('compactNow transaction and failure classification', () => { vi.restoreAllMocks() expect(error.code).toBe('commit') expect(released).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -704,9 +686,7 @@ describe('compactNow transaction and failure classification', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().find(event => event.type === 'compaction/start')?.data) .toEqual({ compactionId: result?.compactionId, turn: null }) }) @@ -719,11 +699,8 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) @@ -740,7 +717,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = testCase.session.snapshotEvents() const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() @@ -756,7 +732,6 @@ describe('compactNow transaction and failure classification', () => { expect(reserve).not.toHaveBeenCalled() expect(measure).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(testCase.session.snapshotEvents()).toEqual(before) vi.restoreAllMocks() } @@ -806,7 +781,6 @@ 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']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) @@ -851,7 +825,6 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -867,9 +840,7 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(end).toBeDefined() 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 7bcbd0ed62..a09d0a3c53 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 @@ -186,9 +186,7 @@ describe('ToolResultPruner session transaction', () => { expect(entry).toMatchObject({ originalSeq, callId: ToolCallId('one'), charsBefore: 100 }) expect(entry.charsAfter).toBeLessThanOrEqual(50) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const original = session.snapshotEvents()[originalSeq]! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replacement = session.snapshotEvents()[entry.replacementSeq]! as SurfaceEvent expect(original).toMatchObject({ type: 'tool/result', @@ -223,7 +221,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()[entry.replacementSeq - 1]).toMatchObject({ type: 'compaction/prune', data: { @@ -259,7 +256,6 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts index 887362b18b..350d4b8308 100644 --- a/packages/compaction/compaction/tests/compaction.spec.ts +++ b/packages/compaction/compaction/tests/compaction.spec.ts @@ -131,7 +131,6 @@ describe('CompactionEngine seam', () => { const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const startEvent = session.snapshotEvents().find(e => e.type === 'compaction/start') expect(startEvent).toBeDefined() // Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType); @@ -143,14 +142,12 @@ 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]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 314ed70ba1..ff49a3400e 100644 --- a/packages/compaction/compaction/tests/invariant.spec.ts +++ b/packages/compaction/compaction/tests/invariant.spec.ts @@ -134,10 +134,8 @@ 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'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['compaction/start', 'session/end-seed']) @@ -157,10 +155,8 @@ 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'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'compaction/start', 'session/end-seed']) @@ -181,10 +177,8 @@ describe('compaction invariants', () => { startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().map(event => event.type)).toEqual([ 'compaction/start', 'turn/start', @@ -210,10 +204,8 @@ 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'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: source.snapshotEvents(), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') await ctx.plugin(InvariantRegistry) diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts index a3dbb7e88c..a5694d1514 100644 --- a/packages/compaction/compaction/tests/tool-pairing.spec.ts +++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts @@ -7,7 +7,6 @@ import type { SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/ds const SURFACE = { surfaceOp: 'append' as const } function seqOf(session: Session, type: SessionEvent['type'], nth = 0): SessionSeqType { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq } diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 2c256150c8..7e35bdee21 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -77,7 +77,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(finalText(live.agent.session.snapshotEvents())).toContain(PROBE) }, 120_000) @@ -90,7 +89,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(finalText(live.agent.session.snapshotEvents())).toContain(NESTED_PROBE) }, 120_000) @@ -104,7 +102,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = live.agent.session.snapshotEvents() const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 259a419bb4..d477cf666e 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -277,7 +277,6 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise event.type === 'user/message' && event.data.source.kind === 'agent-instructions' @@ -1128,7 +1127,6 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(derivedText(agent)).toContain('repo rule') } finally { @@ -1148,16 +1146,13 @@ describe('workspace context request injection', () => { const original = await stubAgent(root) await composeBaselinePrefix(ctx, original) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstResume = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const secondResume = await stubAgent(root, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) expect(baselineEvents(secondResume)).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { @@ -1180,12 +1175,10 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { @@ -1208,15 +1201,12 @@ describe('workspace context request injection', () => { const original = await stubAgent(cwd) await composeBaselinePrefix(ctx, original) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstResume = await stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const secondResume = await stubAgent(cwd, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1241,12 +1231,10 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) @@ -1280,7 +1268,6 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) @@ -1300,7 +1287,6 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const repeated = await stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) @@ -1335,7 +1321,6 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const claudeResume = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) @@ -1351,7 +1336,6 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const restored = await stubAgent(root, claudeResume.session.snapshotEvents()) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) @@ -1387,7 +1371,6 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) @@ -1402,7 +1385,6 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const repeated = await stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) @@ -1433,7 +1415,6 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = claimInbox(resumed, 'next-step') @@ -1450,7 +1431,6 @@ describe('workspace context request injection', () => { expect(decision.messages.map(message => message.id)).toEqual([inserted?.id]) expect(resumed.inbox.nextStep).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1481,7 +1461,6 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = claimInbox(resumed, 'next-step') @@ -1535,7 +1514,6 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = claimInbox(resumed, 'next-step') @@ -1577,7 +1555,6 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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')) @@ -1612,7 +1589,6 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const workspaceEvents = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(workspaceEvents).toHaveLength(2) @@ -1622,7 +1598,6 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await composeBaselinePrefix(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(2) } finally { @@ -1846,7 +1821,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) // Resume announces its lifecycle start before the first step. @@ -1855,7 +1829,6 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -2037,7 +2010,6 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', ) @@ -2679,7 +2651,6 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })) await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', )).toHaveLength(0) @@ -2687,7 +2658,6 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })) await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -3663,7 +3633,6 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, agent.session.snapshotEvents()) const afterResume = await ctx.tools.execute({ @@ -3698,12 +3667,10 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') }], @@ -4734,7 +4701,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = await stubAgent(root, original.session.snapshotEvents()) await ctx.tools.execute({ diff --git a/packages/context/session-reference/tests/loader-composition.spec.ts b/packages/context/session-reference/tests/loader-composition.spec.ts index 624b28de33..c680e393ca 100644 --- a/packages/context/session-reference/tests/loader-composition.spec.ts +++ b/packages/context/session-reference/tests/loader-composition.spec.ts @@ -134,7 +134,6 @@ describe('session-reference real Loader composition', () => { ctx.sessions.get(SessionId('reference-source'))!.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'LATER_SOURCE_MUTATION' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(captured) expect(await readFile(notice.fullSnapshot.locator, 'utf8')).toBe(transcript) }) diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index cbb14be78d..dc2caa5067 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -322,7 +322,6 @@ describe('session reference spill outcomes', () => { const target = ctx.sessions.create(SessionId('target')) const source = ctx.sessions.create(SessionId('source')) for (const text of texts) appendText(source, text) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const captured = source.snapshotEvents().at(-1)?.seq const read = vi.spyOn(ctx.sessionQuery, 'readSurface') const store = ctx.spillStore as RecordingSpill @@ -372,7 +371,6 @@ describe('session reference spill outcomes', () => { const context = result.additionalContext! target.append('user/message', context, { surfaceOp: 'append' }) for (const detach of detachSources) detach() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('replayed'), target.snapshotEvents()).deriveMessages() expect(replayed).toEqual(target.deriveMessages()) expect(JSON.stringify(replayed)).toContain('memory:session-reference-1.txt') @@ -453,7 +451,6 @@ describe('session reference spill outcomes', () => { finish.resolve(undefined) await rejected await settled.promise - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(target.snapshotEvents().filter(event => event.type === 'user/message')).toEqual([]) } finally { finish.resolve(undefined); await ctx.fiber.dispose() } }) @@ -1201,7 +1198,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(before) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 16e9a52a89..52700e6d62 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -68,7 +68,6 @@ function openMessageTurn(session: Session, turn: number, clientTimeZone?: string function contextTexts(session: Session): string[] { const texts: string[] = [] - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -169,7 +168,6 @@ 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.', ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') @@ -293,9 +291,7 @@ describe('durable step context', () => { const original = Session.create(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -308,17 +304,14 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = Session.create(SessionId('resumed'), original.snapshotEvents()) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const beforeSkip = resumed.snapshotEvents().length await fire(ctx, resumedAgent, 2, 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(resumed)).toHaveLength(1) @@ -341,7 +334,6 @@ describe('durable step context', () => { vi.setSystemTime(BASE + 500) openMessageTurn(first, 2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const beforeSkip = first.snapshotEvents().length await fire(ctx, firstAgent, 2, 1) @@ -349,7 +341,6 @@ describe('durable step context', () => { openMessageTurn(independent, 1) await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(first.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(first)).toHaveLength(1) expect(contextTexts(independent)).toHaveLength(1) @@ -469,7 +460,6 @@ describe('real agent-loop request history', () => { expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) @@ -492,10 +482,8 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contexts = agent.session.snapshotEvents().filter( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) @@ -520,7 +508,6 @@ describe('real agent-loop request history', () => { expect(request.messages[0]?.role).toBe('system') expect(JSON.stringify(request.messages[0])).not.toContain('Time sampled while preparing') } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(event => event.type === 'system/message') expect(JSON.stringify(systemNodes)).not.toContain('Time sampled while preparing') await ctx.fiber.dispose() diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index ef1b0e930c..bc9f9f0ed5 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -123,7 +123,6 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -172,7 +171,6 @@ 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]}', ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index f25740593a..0b9d32ec06 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -335,7 +335,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' })) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 1d5ea1ac41..2ec5bcf01c 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -35,7 +35,6 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced']) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) @@ -48,7 +47,6 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const injected = agent.session.snapshotEvents().at(-1) expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source) .toEqual({ kind: 'plugin', plugin: '' }) @@ -99,7 +97,6 @@ describe('Agent', () => { expect(() => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })) }).toThrow(/non-JSON-serializable/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents()).toHaveLength(0) }) @@ -111,7 +108,6 @@ describe('Agent', () => { agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 00d9339fa6..2b1781083c 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -50,7 +50,6 @@ 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[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -72,7 +71,6 @@ describe('Agent.cancel()', () => { // The prompt ran: its user message is in the log and one turn completed. expect(userTexts(agent)).toEqual(['real prompt']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) }) @@ -88,14 +86,12 @@ 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) @@ -150,7 +146,6 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['active', 'B']) expect(adapter.requests).toHaveLength(2) expect(agent.inbox.nextTurn).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }, @@ -201,7 +196,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(1) }) @@ -261,11 +255,8 @@ describe('Agent.cancel()', () => { await new Promise(r => setTimeout(r, 30)) expect(userTexts(agent)).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) expect(agent.status).toBe('idle') @@ -295,7 +286,6 @@ describe('Agent.cancel()', () => { await driverDone(agent) expect(agent.status).toBe('idle') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) @@ -333,7 +323,6 @@ describe('Agent.cancel()', () => { replacementObservation = agent.whenIdle().then(() => ({ status: agent.status, requests: adapter.requests.length, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. turns: agent.session.snapshotEvents().filter(event => event.type === 'turn/start').length, })) agent.cancel({ kind: 'user' }) @@ -411,7 +400,6 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) expect(userTexts(agent)).toEqual(['go']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) @@ -448,9 +436,7 @@ describe('Agent.cancel()', () => { expect(executions).toBe(0) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const call = agent.session.snapshotEvents().find(event => event.type === 'tool/call') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -491,7 +477,6 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toContain('second') // The second turn completed (its reply was streamed). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reasons = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(reasons.length).toBe(2) }) @@ -508,7 +493,6 @@ describe('Agent.cancel()', () => { // The prefix the user watched stream is committed as the step's message, // carrying the truncation marker and exact embedded stream that delivered it. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -519,7 +503,6 @@ describe('Agent.cancel()', () => { )) : false).toBe(true) expect(message?.sourceEventSeqs).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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')) @@ -554,14 +537,12 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const message = agent.session.snapshotEvents().find(event => event.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.source.replayState : undefined) .toEqual(replayState) expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) expect(() => Session.fromRestore( agent.session.id, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. structuredClone([...agent.session.snapshotEvents()]), structuredClone(agent.session.header), SessionLogOffset(0), @@ -585,7 +566,6 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -613,11 +593,9 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The undispatched call is dropped whole — no dangling tool_use to pair. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) }) @@ -640,9 +618,7 @@ describe('Agent.cancel()', () => { // The failed stream's prefix stays off the surface: clients reset it on // retry, and provider failures commit nothing. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') }) @@ -663,14 +639,12 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const failed = agent.session.snapshotEvents().find(e => e.type === 'assistant/attempt') expect(failed?.type === 'assistant/attempt' ? expandAssistantStream(failed.data.stream).some(member => ( @@ -702,12 +676,10 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const attempt = agent.session.snapshotEvents().find(event => event.type === 'assistant/attempt') expect(attempt?.type === 'assistant/attempt' ? expandAssistantStream(attempt.data.stream).map(member => member.chunk) : []).toContainEqual({ type: 'text-delta', index: 0, text: 'partial before failure' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: failure.message } } }, }) @@ -728,7 +700,6 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) }) @@ -758,7 +729,6 @@ 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' } }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) }) @@ -794,9 +764,7 @@ describe('Agent.cancel()', () => { expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) }) @@ -850,7 +818,6 @@ describe('Agent.cancel()', () => { // No turn opened, no step streamed, and a later prompt still runs (the marker // was reset). expect(streamed).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(false) }) @@ -882,7 +849,6 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) }) @@ -906,7 +872,6 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(3) }) @@ -928,11 +893,9 @@ describe('Agent.cancel()', () => { // started from the dropped steering. await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('idle') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const flat = agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.data.content) @@ -964,7 +927,6 @@ describe('Agent.cancel()', () => { status: agent.status, requests: adapter.requests.length, users: userTexts(agent), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: agent.session.snapshotEvents().map(event => event.type), })}`)) }, 1000) @@ -976,7 +938,6 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(2) expect(userTexts(agent)).toEqual(['original', 'replacement']) expect(agent.inbox.nextTurn).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reasons = agent.session.snapshotEvents() .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) @@ -1004,7 +965,6 @@ describe('Agent.cancel()', () => { const runtimeReason: unknown = adapter.requests[0]?.signal?.reason expect(runtimeReason).toEqual({ kind: 'parent' }) expect(runtimeReason).toBe(supplied) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', @@ -1026,7 +986,6 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await handle.dispose() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }) }) @@ -1098,7 +1057,6 @@ describe('Agent.cancel()', () => { const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 841bbbd63f..68cb93f129 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -72,7 +72,6 @@ describe('assistant replay provider and model fields', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 +111,6 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -126,7 +124,6 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'wake') await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] @@ -177,7 +174,6 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() expect(events .filter(event => event.type === 'tool/result' @@ -203,11 +199,9 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data) .toEqual({ turn: 1, reason: { kind: 'completed' } }) expect(agent.inbox.nextTurn).toHaveLength(0) @@ -249,23 +243,19 @@ describe('abort during tool execution ends the turn', () => { await started.promise await fiber.dispose() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) .toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' ? [event.data.inserted.map(inboxText)] : []) .at(-1)) .toEqual(['accepted result context during disposal']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')) .toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) @@ -320,7 +310,6 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] @@ -373,7 +362,6 @@ describe('plugin exceptions are contained', () => { send(agent, 'first') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } }, }) @@ -410,9 +398,7 @@ describe('disposal leaves the two-state status contract balanced', () => { expect(statuses).toEqual(['running', 'idle']) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .flatMap(event => event.data.content) @@ -462,7 +448,6 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -525,7 +510,6 @@ describe('adapter registration, routing, and accepted-input ownership', () => { ['content', 'id', 'role', 'source'], ]) expect(targets).toEqual(['next-turn', 'next-step']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -559,7 +543,6 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const claims = events.flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' @@ -605,7 +588,6 @@ describe('turn numbering continues across seeded sessions', () => { const { agent: forked } = await ctx2.agents.create({ sessionId: SessionId('forked'), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -674,7 +656,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) @@ -697,7 +678,6 @@ 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' } }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) @@ -728,7 +708,6 @@ 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = subject.snapshotEvents() const last = events.at(-1) observed.push({ @@ -765,7 +744,6 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const e = agent.session.snapshotEvents() return { turnStart: e.filter(x => x.type === 'turn/start').length, @@ -796,7 +774,6 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const e = agent.session.snapshotEvents() const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) @@ -830,7 +807,6 @@ describe('turn and step boundary recovery', () => { send(agent, 'rejected') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start' || event.type === 'user/message')).toBe(false) expect(agent.inbox.nextTurn).toHaveLength(1) @@ -862,7 +838,6 @@ describe('turn and step boundary recovery', () => { stepEnd: 0, errors: 1, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } }, }) @@ -956,7 +931,6 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -990,7 +964,6 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1020,14 +993,11 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastBoundary = agent.session.snapshotEvents().findLast(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') // loop survives: a second turn runs normally. @@ -1060,7 +1030,6 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const e = agent.session.snapshotEvents() const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') @@ -1095,7 +1064,6 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1126,7 +1094,6 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1136,7 +1103,6 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1169,7 +1135,6 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resultEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { @@ -1234,7 +1199,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1283,7 +1247,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1334,7 +1297,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) // The post-listener cancellation check catches disposal before any step or LLM call. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1382,7 +1344,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -1428,7 +1389,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index f18e662e61..1fa24977b2 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -69,14 +69,12 @@ describe('tool JSON parse', () => { await waitForIdle(ctx, agent) // tool/call event should have recorded the raw arguments string - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) @@ -103,7 +101,6 @@ describe('tool JSON parse', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) }) @@ -133,11 +130,8 @@ describe('thrown-value propagation', () => { expect(errors).toHaveLength(1) expect(errors[0]).toBe('naked string error') expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const starts = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ends = agent.session.snapshotEvents().filter(event => event.type === 'turn/end') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents().filter(event => event.type === 'user/message') expect(starts).toHaveLength(0) expect(ends).toHaveLength(0) @@ -161,7 +155,6 @@ describe('thrown-value propagation', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -187,7 +180,6 @@ describe('durable error rendering', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { @@ -244,7 +236,6 @@ describe('structured tool error propagation (the runtime-validation Agent Note, send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -271,7 +262,6 @@ describe('request-error action edges', () => { // One failed request, no retry turn. expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ends = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(ends).toHaveLength(1) }) @@ -294,7 +284,6 @@ describe('request-error action edges', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted') }) @@ -324,7 +313,6 @@ describe('stream failure edges', () => { // No facts -> not offered to recovery; the turn fails through settle(). expect(recoveries).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) @@ -400,7 +388,6 @@ describe('tool result meta persistence', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' }) }) @@ -449,7 +436,6 @@ describe('recovery without a retry action', () => { expect(recoveries).toBe(1) expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) @@ -475,7 +461,6 @@ describe('unrenderable failure settlement', () => { send(agent, 'go') await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') { @@ -517,7 +502,6 @@ describe('driver bookkeeping edges', () => { expect(proposals).toBe(2) expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' }) }) @@ -540,10 +524,8 @@ describe('driver bookkeeping edges', () => { send(agent, 'go') await agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/end')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 021e2339f7..028c42ca5c 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -142,7 +142,6 @@ describe('ReactLoopInbox', () => { const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length) expect(childInbox.nextTurn).toEqual([inherited]) @@ -247,7 +246,6 @@ describe('ReactLoopInbox', () => { const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) agent.inbox.append('next-turn', nextTurn) agent.inbox.append('next-step', nextStep) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const beforeClear = session.snapshotEvents().length agent.inbox.clear() @@ -255,7 +253,6 @@ describe('ReactLoopInbox', () => { expect(agent.inbox.nextTurn).toEqual([]) expect(agent.inbox.nextStep).toEqual([]) expect(discarded).toEqual([nextStep, nextTurn]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' ? event.data : event.type)).toEqual([ @@ -264,7 +261,6 @@ describe('ReactLoopInbox', () => { ]) agent.inbox.clear() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 3decd9499e..b50fb45942 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -57,7 +57,6 @@ function send(agent: Agent, text: string) { } function events(agent: Agent): readonly SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() } diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 68b8683cb0..fb98a37e31 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -53,7 +53,6 @@ function systemOf(request: GenerateOptions | undefined): string | undefined { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -73,7 +72,6 @@ describe('agent loop', () => { if (subject !== agent) return frames.push(frame) if (frame.type === 'end' && frame.outcome.kind === 'committed') { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. committedAfterMessage = agent.session.snapshotEvents().at(-1)?.type === 'assistant/message' } }) @@ -93,9 +91,7 @@ describe('agent loop', () => { (frame): frame is Extract => frame.type === 'chunk', ) expect(chunks.map(frame => frame.index)).toEqual(chunks.map((_frame, index) => index)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => (event.type as string) === 'assistant/chunk')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const message = agent.session.snapshotEvents().findLast(event => event.type === 'assistant/message') expect(message?.type === 'assistant/message' ? expandAssistantStream(message.data.stream).map(member => member.chunk) @@ -137,7 +133,6 @@ describe('agent loop', () => { type: 'end', outcome: { kind: 'abandoned' }, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) @@ -165,11 +160,9 @@ describe('agent loop', () => { index: 2, outcome: { kind: 'abandoned' }, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => ( event.type === 'assistant/message' || event.type === 'assistant/attempt' ))).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, @@ -225,7 +218,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(frames).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, @@ -441,7 +433,6 @@ describe('agent loop', () => { expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(0) }) @@ -465,14 +456,12 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -515,7 +504,6 @@ describe('agent loop', () => { expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }]) // session log records call + result - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = agent.session.snapshotEvents().map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') @@ -578,7 +566,6 @@ describe('agent loop', () => { expect(errors.map(error => error.message)).toEqual([ 'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona-prefix")', ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' @@ -596,7 +583,6 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(systemOf(adapter.requests[0])).toBe('You are an AI agent powered by DeepSeek Harness.\n\nIn /rescued.') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -644,7 +630,6 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect('system' in adapter.requests[0]!).toBe(false) expect(adapter.requests[0]!.messages.map(message => message.role)).toEqual(['user']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toMatchObject([ { data: { message: { role: 'system', content: [] } }, surfaceOp: 'append' }, ]) @@ -659,7 +644,6 @@ describe('agent loop', () => { const firstIdle = waitForIdle(ctx, agent) send(agent, 'first') await firstIdle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const head = agent.session.snapshotEvents().find(event => event.type === 'system/message') expect(adapter.requests[0]?.messages.map(message => message.role)).toEqual(['user']) expect(head).toMatchObject({ data: { message: { content: [] } }, surfaceOp: 'append' }) @@ -672,14 +656,12 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(2) expect(systemOf(adapter.requests[1])).toBe('You are an AI agent powered by DeepSeek Harness.') expect(adapter.requests[1]?.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replacement = agent.session.snapshotEvents().findLast(event => event.type === 'system/message') expect(replacement).toMatchObject({ surfaceOp: { op: 'replace', startSeq: head?.seq, endSeq: head?.seq }, sourceEventSeqs: [head?.seq], }) expect(agent.session.surface.nodes[0]).toBe(replacement?.seq) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) } finally { @@ -699,7 +681,6 @@ describe('agent loop', () => { let mode = 'read-only' const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` }) const agent = await ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contextEvents = () => agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -741,9 +722,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(contextEvents()).toHaveLength(3) expect(adapter.requests.map(systemOf)).toEqual(Array(5).fill(systemOf(adapter.requests[0]))) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -756,7 +735,6 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -772,7 +750,6 @@ describe('agent loop', () => { send(agent, 'after compaction') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -793,7 +770,6 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -856,7 +832,6 @@ describe('agent loop', () => { send(agent, 'repair context') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -878,7 +853,6 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') const chunks = message?.type === 'assistant/message' ? expandAssistantStream(message.data.stream) : [] // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7 @@ -913,14 +887,12 @@ describe('agent loop', () => { send(agent, 'start') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const secondStepStart = agent.session.snapshotEvents().filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) @@ -939,14 +911,11 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ @@ -975,9 +944,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(1) expect(agent.inbox.nextStep).toHaveLength(1) @@ -985,7 +952,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) @@ -998,9 +964,7 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'agent/inbox/spliced', data: { @@ -1015,7 +979,6 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1031,7 +994,6 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) @@ -1058,7 +1020,6 @@ 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' } })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, @@ -1071,12 +1032,9 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result')! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1121,7 +1079,6 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) @@ -1167,7 +1124,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) @@ -1194,7 +1150,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -1224,7 +1179,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header') expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) @@ -1264,7 +1218,6 @@ describe('agent loop', () => { let boundaryOpen = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (subject === agent) boundaryOpen = subject.session.snapshotEvents().at(-1)?.type === 'step/start' return next() }) @@ -1297,16 +1250,13 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastTurnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) @@ -1353,7 +1303,6 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Assert the durable row, not only the live listener. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) @@ -1458,7 +1407,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(executions).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages().slice(1)).toEqual([{ id: expect.any(String) as unknown, @@ -1469,7 +1417,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toMatchObject({ turn: 1, @@ -1510,7 +1457,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toMatchObject({ turn: 1, @@ -1546,7 +1492,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'completed' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toMatchObject({ turn: 1, @@ -1590,7 +1535,6 @@ describe('agent loop', () => { send(agent, 'continue') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. @@ -1659,7 +1603,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) @@ -1681,9 +1624,7 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) @@ -1702,9 +1643,7 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const sources = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.source) @@ -1760,11 +1699,9 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(messages).toEqual([ [{ type: 'text', text: 'outer message' }], @@ -1795,7 +1732,6 @@ describe('agent loop', () => { }) expect(reasons[0]).toMatchObject({ kind: 'error' }) // The durable failure and live relay describe the same failed turn. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' }) }) @@ -1849,7 +1785,6 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]?.reasoningEffort).toBe(effort) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) @@ -1888,15 +1823,11 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. agent.session.snapshotEvents().map(e => e.type)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 bf3c723153..68e170f27e 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -72,21 +72,18 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () } function userMessageTexts(agent: Agent): string[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'turn/start') .map(e => e.data.turn) } function turnEndNumbers(agent: Agent): number[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(e => e.type === 'turn/end') .map(e => (e.data as { turn: number }).turn) @@ -94,7 +91,6 @@ function turnEndNumbers(agent: Agent): number[] { function userMessageCountsByTurn(agent: Agent): number[] { const counts: number[] = [] - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 5a1c912bc0..ceaa2b0b6d 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,7 +82,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const usages = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index f0ae238d80..616a817082 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -92,14 +92,12 @@ describe('agent/request-error', () => { code: 'SERVICE_UNAVAILABLE', }, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -117,9 +115,7 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, @@ -141,9 +137,7 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index 82261178fe..560be88627 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -58,7 +58,6 @@ describe('loop-owned request freezing', () => { }), stream: [], }, { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = structuredClone(seed.snapshotEvents()) const userEvent = events.find(event => event.type === 'user/message')! const assistantEvent = events.find(event => event.type === 'assistant/message')! @@ -199,7 +198,6 @@ describe('loop-owned request freezing', () => { expect(() => request.stop!.push('mutate')).toThrow(TypeError) if (!registered) expect(request.stop).toBe(stops[index]) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(agent.session.requestHeader()!.tools).not.toBe(requests[0]!.tools) expect(agent.session.requestHeader()!.config.stop).not.toBe(requests[0]!.stop) @@ -223,7 +221,6 @@ describe('loop-owned request freezing', () => { await agent.whenIdle() expect(request.signal!.aborted).toBe(true) expect(request.signal!.reason).toEqual({ kind: 'user' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index e5ec05cb60..2450a21999 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -94,7 +94,6 @@ describe('request stability across the loop', () => { expect(Object.isFrozen(request.messages)).toBe(true) } // One anchoring header snapshot; no further header events (nothing changed). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -112,7 +111,6 @@ describe('request stability across the loop', () => { expect(adapter.requests).toHaveLength(2) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -134,7 +132,6 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -160,7 +157,6 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ @@ -198,7 +194,6 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -228,7 +223,6 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ ReasoningEffortId('high'), @@ -248,7 +242,6 @@ describe('request stability across the loop', () => { const resumedCtx = await harness(resumedAdapter) const resumedHandle = await resumedCtx.agents.create({ sessionId: SessionId(`effort-${model}`), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: structuredClone(agent.session.snapshotEvents()), agentOptions: { provider: 'mock', model }, }) @@ -257,7 +250,6 @@ describe('request stability across the loop', () => { expect(resumedAdapter.requests[0]?.model).toBe(model) expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -276,7 +268,6 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests[0]?.maxTokens).toBe(256_000) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -308,7 +299,6 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(256_000) expect(other.requests[0]?.maxTokens).toBe(8_192) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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([ @@ -343,7 +333,6 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(4_096) expect(other.requests[0]?.maxTokens).toBe(4_096) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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]) @@ -396,7 +385,6 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ]) expect(second.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) }) @@ -435,7 +423,6 @@ describe('request stability across the loop', () => { expect(signal.aborted).toBe(true) expect(handle.agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(handle.agent.session.snapshotEvents().some(event => event.type === 'request/header')).toBe(false) }) @@ -459,7 +446,6 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: failure instanceof LlmError @@ -535,7 +521,6 @@ describe('request stability across the loop', () => { // The rewritten history: summary replaces turn 1's user+assistant pair behind the system prompt. expect(second.messages[0]!.role).toBe('system') expect(second.messages[1]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ @@ -574,7 +559,6 @@ describe('request stability across the loop', () => { expect(adapter.requests[1]?.messages[1]?.content).toContainEqual({ type: 'text', text: '[summary for retry]', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -588,7 +572,6 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) @@ -596,10 +579,8 @@ describe('request stability across the loop', () => { send(agent, 'third') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const snapshots = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots.map(event => event.data.reason)).toEqual(['initial', 'series']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) @@ -629,10 +610,8 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) // No new series: the header stays, node 0 stays, and the prompt update follows the cached prefix. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toBe('append') @@ -646,7 +625,6 @@ describe('request stability across the loop', () => { // An unchanged prompt adds nothing on the next step. send(agent, 'fourth') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(e => e.type === 'system/message')).toHaveLength(2) expectPrefixExtension(adapter.requests[2]!, adapter.requests[3]!) }) @@ -670,11 +648,9 @@ describe('request stability across the loop', () => { startSeries = true send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. let systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) @@ -684,7 +660,6 @@ describe('request stability across the loop', () => { disposeSection = ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'newer guidance' }) send(agent, 'third') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(3) expect(systemNodes[2]?.surfaceOp).toBe('append') @@ -695,7 +670,6 @@ describe('request stability across the loop', () => { ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'newest guidance' }) send(agent, 'fourth') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(5) expect(systemNodes[3]?.data.message.content).toEqual([]) @@ -729,12 +703,10 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) expect(adapter.requests[1]!.messages.map(message => message.role)).toEqual(['system', 'user', 'user']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -753,10 +725,8 @@ describe('request stability across the loop', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const headers = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(headers.map(event => [event.data.reason, event.data.startsSeries])).toEqual([['initial', undefined], ['change', true]]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = agent.session.snapshotEvents().filter(e => e.type === 'system/message') expect(systemNodes).toHaveLength(2) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: systemNodes[0]?.seq, endSeq: systemNodes[0]?.seq }) @@ -782,7 +752,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') @@ -810,7 +779,6 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -830,7 +798,6 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -838,13 +805,11 @@ describe('request stability across the loop', () => { send(agent2, 'second') await waitForIdle(ctx2, agent2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') // Identical header and an unchanged system node across the restart: byte-identical continuation. expect(adapter2.requests[0]!.messages[0]).toEqual(adapter.requests[0]!.messages[0]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent2.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!) }) @@ -873,7 +838,6 @@ describe('request stability across the loop', () => { // The second turn reuses the same series and header; the session's own // fold remains immutable state. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) @@ -900,7 +864,6 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(3) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const stepStarts = events.filter(e => e.type === 'step/start') expect(stepStarts).toHaveLength(3) @@ -957,7 +920,6 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) @@ -983,7 +945,6 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data.contextWindow)).toEqual([64_000, 256_000]) @@ -996,7 +957,6 @@ describe('request/context capacity records', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }]) @@ -1017,7 +977,6 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([ diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 838c3e7004..271849eba3 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -484,7 +484,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // Lifecycle 2: resume repairs the tail and stores the repair durably. const ctx2 = await mountPersistentHarness(root, new MockAdapter([])) const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(handle.agent.session.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'turn/end', 'session/end-seed']) await handle.dispose() @@ -553,7 +552,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // closers immediately after the committed prefix — no gap, no fragment. const ctx2 = await mountPersistentHarness(root, new MockAdapter([]), 'none') const handle = await ctx2.agents.resume({ resumeSessionId: sessionId }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(handle.agent.session.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'turn/end', 'session/end-seed']) await handle.dispose() @@ -641,7 +639,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setup: async (agentCtx, agent) => { expect(agent.id).toBe(sessionId) // The two persisted events plus the end-seed marker. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(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')) @@ -694,7 +691,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(stored.map(event => event.type)).toEqual(['turn/start', 'turn/end', 'session/end-seed']) const second = await ctx.agents.resume({ resumeSessionId: sessionId }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(second.agent.session.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'turn/end', 'session/end-seed']) await second.dispose() @@ -987,7 +983,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = h1.agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events1 = a1.session.snapshotEvents() const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous @@ -1001,10 +996,8 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(a2.session.snapshotEvents().length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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()) @@ -1012,10 +1005,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // …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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const allSeqs = a2.session.snapshotEvents().map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 851701a646..81bfc64abd 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -809,7 +809,6 @@ describe('agent scope lifecycle', () => { expect(statuses).toEqual([]) expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -1115,10 +1114,8 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const eventsAfter = agent.session.snapshotEvents().length await new Promise(resolve => setTimeout(resolve, 30)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().length).toBe(eventsAfter) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index e57df3e855..d3702147f6 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -39,7 +39,6 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () => ctx.on('llm/stream', (request, next) => { // Rebuild from copied source events, not the live projection's cached state. const subject = ctx.agents.get(request.sessionId!)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replay = Session.create(subject.id, subject.session.snapshotEvents()) expect(request.messages).toEqual(replay.deriveMessages()) expect(request.system).toBeUndefined() @@ -84,26 +83,21 @@ describe('prepared-route prompt admission', () => { expect(systemTexts(cleared)).toEqual([]) expect(toPiContext(cleared).systemPrompt).toBeUndefined() expect(JSON.stringify(toPiContext(cleared))).not.toContain('prompt ') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const clearEvents = h.agent.session.snapshotEvents().filter(event => event.type === 'system/message').filter(event => event.data.turn === 4) expect(clearEvents).toHaveLength(3) for (const event of clearEvents) { expect(event.data.message.content).toEqual([]) expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: event.sourceEventSeqs?.[0], endSeq: event.sourceEventSeqs?.[0] }) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const count = h.agent.session.snapshotEvents().filter(event => event.type === 'system/message').length await send(h.agent, 'still clear') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(h.agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) expect(systemTexts(adapter.requests.at(-1)!)).toEqual([]) const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('cleared-resume'), agentOptions: { provider, model: 'model' }, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: [...h.agent.session.snapshotEvents()], }) await send(resumed, 'resume clear') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) expect(systemTexts(adapter.requests.at(-1)!)).toEqual([]) h.setPrompt('restored instruction') @@ -130,7 +124,6 @@ describe('prepared-route prompt admission', () => { } await send(h.agent, 'third') expectPlain(h.capable.requests[2]!, 'prompt two') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const header = h.agent.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1) expect(header?.data.reason).toBe(reason === 'explicit' ? 'series' : 'change') if (reason === 'tools') expect(header?.data.startsSeries).toBe(true) @@ -154,7 +147,6 @@ describe('prepared-route prompt admission', () => { expect(failure.code).toBe('CONTEXT_LENGTH') if (attempts++ === 0) { const nodes = h.agent.session.surface.nodes - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const latest = nodes.findLast(seq => h.agent.session.eventAt(seq)?.type === 'system/message')! const start = retainOlder ? latest : nodes[1]! const replaced = nodes.slice(nodes.indexOf(start), nodes.indexOf(latest) + 1) @@ -174,7 +166,6 @@ describe('prepared-route prompt admission', () => { expectPlain(adapter.requests[3]!, 'prompt three') expect(adapter.requests[4]!.messages).toEqual(adapter.requests[3]!.messages) expect(adapter.requests[3]!.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'third' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = h.agent.session.snapshotEvents() expect(events.filter(event => event.type === 'step/start')).toHaveLength(3) expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(3) @@ -192,7 +183,6 @@ describe('prepared-route prompt admission', () => { if (changed) h.setPrompt('prompt three') await send(h.agent, 'third') expectPlain(h.plain.requests[0]!, changed ? 'prompt three' : 'prompt two') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = h.agent.session.snapshotEvents() const replacements = events.filter(event => event.type === 'system/message').filter(event => event.surfaceOp !== 'append') expect(replacements).toHaveLength(2) @@ -201,7 +191,6 @@ describe('prepared-route prompt admission', () => { expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: event.sourceEventSeqs?.[0], endSeq: event.sourceEventSeqs?.[0] }) } await send(h.agent, 'fourth') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(h.agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(4) expectPlain(h.plain.requests[1]!, changed ? 'prompt three' : 'prompt two') }) @@ -225,7 +214,6 @@ describe('prepared-route prompt admission', () => { expect(messages.slice(-2).map(message => message.content)).toEqual([ [{ type: 'text', text: 'second' }], notice, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const notices = h.agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'model-selection') expect(notices).toHaveLength(1) @@ -242,12 +230,10 @@ describe('prepared-route prompt admission', () => { await send(h.agent, 'second') const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('resumed-capable'), agentOptions: { provider: 'capable', model: 'model' }, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: [...h.agent.session.snapshotEvents()], }) h.ctx.on('agent/pre-step', ({ agent }, next) => { if (agent === resumed && replace) { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seq = agent.session.surface.nodes.find(seq => agent.session.eventAt(seq)?.type === 'user/message')! agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'test-compaction' }, @@ -259,7 +245,6 @@ describe('prepared-route prompt admission', () => { expect(systemTexts(h.capable.requests[2]!)).toEqual(replace ? [[{ type: 'text', text: 'prompt two' }]] : [[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }]]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').map(event => event.data.reason)) .toEqual(['initial', 'resume']) }) @@ -269,12 +254,10 @@ describe('prepared-route prompt admission', () => { await send(h.agent, 'first') h.setPrompt('prompt two') await send(h.agent, 'second') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = h.agent.session.snapshotEvents() const { agent: resumed } = await h.ctx.agents.create({ sessionId: SessionId('resumed'), agentOptions: { provider: 'plain', model: 'model' }, seed: [...seed] }) await send(resumed, 'resume') expectPlain(h.plain.requests[0]!, 'prompt two') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1)?.data.reason).toBe('resume') }) @@ -283,7 +266,6 @@ describe('prepared-route prompt admission', () => { const entered = Promise.withResolvers() const release = Promise.withResolvers() const observe = async () => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = h.agent.session.snapshotEvents().map(event => event.type) expect(types).toContain('step/start') expect(types).not.toContain('system/message') @@ -303,7 +285,6 @@ describe('prepared-route prompt admission', () => { release.resolve(undefined) await h.agent.whenIdle() expect(h.capable.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = h.agent.session.snapshotEvents().map(event => event.type) expect(types.filter(type => type === 'step/start' || type === 'step/end')).toEqual(['step/start', 'step/end']) expect(types.filter(type => ['system/message', 'user/message', 'request/header'].includes(type))).toEqual([]) @@ -322,7 +303,6 @@ describe('prepared-route prompt admission', () => { expect(h.plain.requests).toHaveLength(0) expect(h.capable.requests[1]).toMatchObject({ provider: 'capable', temperature: 0.5, maxTokens: 100 }) expect(systemTexts(h.capable.requests[1]!)).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(h.agent.session.snapshotEvents().filter(event => event.type === 'user/message')).toHaveLength(2) }) }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index dc6f783139..74871d4a0d 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -42,7 +42,6 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): readonly SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() } diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index e4c05be289..287f5fe761 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -69,7 +69,6 @@ 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']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 @@ -83,7 +82,6 @@ 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']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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)) @@ -91,7 +89,6 @@ describe('loop-level canonical tool order', () => { 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]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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']) @@ -106,15 +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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldRequestHeader(agent.session.snapshotEvents())).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'step/start')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'step/end')).toBe(false) }) }) diff --git a/packages/core/agent/tests/consumed-work.spec.ts b/packages/core/agent/tests/consumed-work.spec.ts index 221a5f7d0f..26808c5c81 100644 --- a/packages/core/agent/tests/consumed-work.spec.ts +++ b/packages/core/agent/tests/consumed-work.spec.ts @@ -40,7 +40,6 @@ describe('foldConsumedWork', () => { const session = Session.create(SessionId('empty')) accept(session, 'queued') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents())).toEqual({ droppedUnrun: false }) }) @@ -49,7 +48,6 @@ describe('foldConsumedWork', () => { steppedTurn(session, 1, { kind: 'completed' }) steppedTurn(session, 2, { kind: 'max-tokens' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data) .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) }) @@ -63,7 +61,6 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) @@ -74,7 +71,6 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) @@ -89,7 +85,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) @@ -102,7 +97,6 @@ describe('foldConsumedWork', () => { // Rejection does not retain the claimed messages, so the `blocked` end is // the only account of input that will never run. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) @@ -115,7 +109,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) @@ -127,7 +120,6 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) @@ -138,9 +130,7 @@ describe('foldConsumedWork', () => { cancelPending(session) // No turn opened over it, so only the cancellation says the work was cut short. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents())).toEqual({ - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. end: session.snapshotEvents().find(event => event.type === 'turn/end'), droppedUnrun: true, }) @@ -153,7 +143,6 @@ describe('foldConsumedWork', () => { target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents()).droppedUnrun).toBe(false) }) @@ -163,9 +152,7 @@ describe('foldConsumedWork', () => { cancelPending(session) steppedTurn(session, 2, { kind: 'completed' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldConsumedWork(session.snapshotEvents())).toEqual({ - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. end: session.snapshotEvents().findLast(event => event.type === 'turn/end'), droppedUnrun: false, }) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 773d428da8..2e2efcb90f 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: ebe4c1c29992b29b07ad38d7368ca5cef2253ee0 -README.zh.md: f5d0ac59f1276e4fe59c1cc449dcf6ce5a082aba +README.md: e61481d1f5b62e24499b0db676a9ae62cce8f0c0 +README.zh.md: 9350a98c57261a4fc78487578717707a9393b502 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ebe4c1c299..e61481d1f5 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -55,7 +55,7 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and ### 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. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new calls are prohibited ([rationale](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Callers that only need a length use `seq`. +`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. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new production calls are prohibited. Repository test files may use these three readers under their scoped lint allowance ([policy](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Callers that only need a length use `seq`. Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index f5d0ac59f1..9350a98c57 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -55,7 +55,7 @@ session.deriveMessages() // the derived model history ### 读取日志 -`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增调用([理由](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md))。只需要长度的调用方使用 `seq`。 +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增生产调用。仓库测试文件可以在限定范围的 lint 豁免下使用这三个读取方法([策略](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md))。只需要长度的调用方使用 `seq`。 会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,品牌在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。 diff --git a/packages/core/session/tests/canonical-envelopes.spec.ts b/packages/core/session/tests/canonical-envelopes.spec.ts index 3177f333d6..123852762d 100644 --- a/packages/core/session/tests/canonical-envelopes.spec.ts +++ b/packages/core/session/tests/canonical-envelopes.spec.ts @@ -121,7 +121,6 @@ describe('canonical event payload acceptance', () => { expect(copy).toEqual(event) if (path === 'adopt' || path === 'snapshot') expect(accept(copy)).toEqual(event) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(id, [event]).eventAt(SessionSeq(0))).toEqual(event) } }) @@ -179,7 +178,6 @@ describe('canonical event payload acceptance', () => { const prepared = ctx.sessions.prepare(id, { seed: [event], meta: { ...header }, inheritedEventCount: SessionLogOffset(0), eventState, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(prepared.eventAt(SessionSeq(0))).toBe(event) expect(prepared.requestHeader()).toEqual({ config }) expect(ctx.sessions.get(id)).toBeUndefined() @@ -205,7 +203,6 @@ describe('canonical event payload acceptance', () => { const published = vi.fn() ctx.on('session/created', created) ctx.on('session/event', published) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() const messages = session.deriveMessages() for (const { event } of invalidData) { @@ -213,7 +210,6 @@ describe('canonical event payload acceptance', () => { expect(() => ctx.sessions.create(SessionId('invalid-seed'), { seed: [event] })).toThrow() expect(ctx.sessions.get(SessionId('invalid-seed'))).toBeUndefined() expect(session.seq).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toBe(events) expect(session.surface.nodes).toEqual([]) expect(session.surface.replaceGeneration).toBe(0) @@ -283,7 +279,6 @@ describe('canonical event-local surface metadata', () => { expect(() => accept(structuredClone(event))).not.toThrow() } const session = Session.create(id, [event]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(0))).toEqual(event) expect(session.surface.nodes).toEqual([]) expect(session.deriveMessages()).toEqual([]) diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 35bf0fa15d..21d2b7153b 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,7 +16,6 @@ function userText(session: Session, text: string): void { /** From-scratch oracle: replay the log into a fresh session and derive. */ function scratch(session: Session): unknown { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), session.snapshotEvents()).deriveMessages() } diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index a62019e23c..d67538ded8 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -47,7 +47,6 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m } function lastSeq(session: Session): SessionSeq { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().at(-1) if (event === undefined) throw new Error('missing last event') return event.seq @@ -55,7 +54,6 @@ function lastSeq(session: Session): SessionSeq { /** A seeded child's fork-inherited prefix. */ function inherited(session: Session): readonly SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents(SessionLogOffset(0), session.inheritedEventCount) } @@ -83,19 +81,13 @@ describe('SessionStore.fork', () => { const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(inherited(child)).toEqual(source.snapshotEvents()) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.snapshotEvents()).not.toBe(source.snapshotEvents()) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.snapshotEvents()[1]).not.toBe(source.snapshotEvents()[1]) expect(() => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. firstUserMessage(child.snapshotEvents()).data.content[0] = { type: 'text', text: 'child mutation' } }).toThrow(TypeError) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), @@ -114,7 +106,6 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('log-only-child')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(inherited(child)).toEqual(source.snapshotEvents()) expect(inherited(child).at(-1)).toMatchObject({ type: 'test/log-only', @@ -132,7 +123,6 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(inherited(child)).toEqual(source.snapshotEvents().slice(0, firstBoundary + 1)) expect(child.inheritedEventCount).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ @@ -176,12 +166,9 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.snapshotEvents().at(-1)).toBe(open) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundary = child.snapshotEvents().at(-1) expect(boundary).toMatchObject({ type: 'session/end-seed' }) expect(boundary!.seq).toBeGreaterThan(open.seq) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 778e4b560f..9b8b281cf5 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -83,7 +83,6 @@ describe('session-log invariants', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('later dispatch veto') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) expect(() => { session.append('turn/start', { turn: 1 }) @@ -429,7 +428,6 @@ describe('session-log invariants', () => { const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, ] }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 })) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 200a1107f0..07bf37ac02 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -107,7 +107,6 @@ describe('Session properties', () => { it('seq is strictly monotonic and zero-based contiguous', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents().forEach((event, i) => { expect(event.seq).toBe(i) }) expect(session.seq).toBe(events.length) })) @@ -116,11 +115,9 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) })) @@ -129,12 +126,9 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const once = Session.create(SessionId(`idem-a-${counter++}`), original.snapshotEvents()) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const twice = Session.create(SessionId(`idem-b-${counter++}`), once.snapshotEvents()) // Lazy resume makes browsing a pickup, so this must not grow per open. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) }) @@ -168,7 +162,6 @@ describe('Session properties', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) const messages = session.deriveMessages() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = structuredClone(session.snapshotEvents()) for (const m of messages) { expect(['user', 'assistant', 'system']).toContain(m.role) @@ -177,7 +170,6 @@ describe('Session properties', () => { expect(Object.isFrozen(m)).toBe(true) expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 5aa4a5695b..c7b2719b6e 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -80,7 +80,6 @@ describe('foldRequestHeader', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('request/header', { header: { config: { provider: 'mock', model: 'other' } }, reason: 'change' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldRequestHeader(session.snapshotEvents())).toEqual({ config: { provider: 'mock', model: 'other' } }) }) }) diff --git a/packages/core/session/tests/sequence-types.spec.ts b/packages/core/session/tests/sequence-types.spec.ts index b6aaee8a16..d20d2c721f 100644 --- a/packages/core/session/tests/sequence-types.spec.ts +++ b/packages/core/session/tests/sequence-types.spec.ts @@ -69,13 +69,11 @@ describe('Session log positions', () => { isSeeded: true, } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = Session.create(id, source.snapshotEvents(), header, source.seq) expect(child.header.isSeeded).toBe(true) expect('seedLength' in child.header).toBe(false) expect(child.inheritedEventCount).toBe(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.ownEvents().map(event => event.type)).toEqual(['session/end-seed']) expect(child.isOwnSeq(SessionSeq(1))).toBe(false) expect(child.isOwnSeq(SessionSeq(2))).toBe(true) @@ -92,7 +90,6 @@ describe('Session log positions', () => { parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const id = SessionId('suffix-child') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = Session.create(id, parent.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id, @@ -101,7 +98,6 @@ describe('Session log positions', () => { }, parent.seq) child.append('request/context', { provider: 'provider', model: 'model' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.ownEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'request/context', diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index ed5dfe46d4..d6762ba147 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -71,7 +71,6 @@ describe('Session', () => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -82,11 +81,8 @@ 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' } } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.snapshotEvents())) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, -1)).toEqual(session.snapshotEvents()) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEnd = replayed.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) @@ -119,7 +115,6 @@ describe('Session', () => { session.append('user/message', message, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual([message]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents()[0] expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) }) @@ -144,11 +139,9 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) expect(replayed.firstLiveSeq).toBe(original.seq) @@ -156,20 +149,16 @@ describe('Session', () => { it('marks an explicitly empty seed without marking a fresh session', () => { const fresh = Session.create(SessionId('fresh-empty')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) expect(resumed.firstLiveSeq).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const reopened = Session.create(SessionId('reopened-empty'), resumed.snapshotEvents()) expect(reopened.firstLiveSeq).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(reopened.snapshotEvents()).toEqual(resumed.snapshotEvents()) }) @@ -199,7 +188,6 @@ describe('Session', () => { const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).snapshotEvents().slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -262,7 +250,6 @@ describe('Session', () => { SessionLogOffset(0), 'detached', ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(restored.eventAt(SessionSeq(0))).toBe(mismatchedMessage) expect(Object.isFrozen(mismatchedMessage)).toBe(false) }) @@ -532,7 +519,6 @@ describe('Session', () => { reason: 'initial', }, } as const - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('reasoning-effort'), [valid]).snapshotEvents()[0]) .toEqual(valid) @@ -563,7 +549,6 @@ describe('Session', () => { reason: 'initial', }, } as const - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(Session.create(SessionId('adapter-defaults'), [valid]).snapshotEvents()[0]).toEqual(valid) for (const adapterDefaults of [ @@ -594,7 +579,6 @@ describe('Session', () => { isError: false, }), }, { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = structuredClone(session.snapshotEvents()) // A misbehaving consumer tries to mutate the messages it was handed. @@ -611,7 +595,6 @@ describe('Session', () => { messages.reverse() // The log is unchanged: deep-equal to the snapshot taken before mutation. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -641,7 +624,6 @@ describe('Session', () => { cyclic['self'] = cyclic expect(bad(cyclic)).toThrow(/non-JSON-serializable/) // The rejected appends never entered the log. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(0) }) @@ -656,14 +638,12 @@ describe('Session', () => { }))) .toThrow(/surface-eligible and requires a surfaceOp marker/) // The rejected append never entered the log (only turn/start is present). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(1) }) @@ -708,7 +688,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(0, 3)).toEqual(goodSeed) expect(session.firstLiveSeq).toBe(3) }) @@ -734,7 +713,6 @@ describe('Session', () => { const session = Session.create(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(0, 1)).toEqual([accepted]) }) @@ -752,7 +730,6 @@ describe('Session', () => { const session = Session.create(SessionId('seed-nested-drift'), seed) expect(reads).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()[0]!.data).toEqual({ value: 'accepted' }) }) @@ -814,7 +791,6 @@ describe('Session', () => { const session = Session.create(SessionId('seed-null-prototype'), [event]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().slice(0, 1)).toEqual([{ ...event }]) }) @@ -847,7 +823,6 @@ describe('Session', () => { }] as unknown as SessionEvent[] const session = Session.create(SessionId('seed-unstable-metadata'), seed) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents()[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') @@ -908,7 +883,6 @@ describe('Session', () => { 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -927,7 +901,6 @@ describe('Session', () => { // make session.snapshotEvents() diverge from the value that passed validation. data.content[0]!.text = 'HACKED' ;(data as Record)['injected'] = 1n - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -950,7 +923,6 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.data).toEqual({ value: 'accepted' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([event]) }) @@ -964,7 +936,6 @@ describe('Session', () => { }), { surfaceOp: { op: 'replace', startSeq: 1n, endSeq: 2 } } as never, )).toThrow(/non-JSON-serializable surface metadata/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -983,7 +954,6 @@ describe('Session', () => { }), { surfaceOp: new ReplaceOp() }, )).toThrow(/non-JSON-serializable surface metadata/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -1015,7 +985,6 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([source, event]) }) @@ -1037,7 +1006,6 @@ describe('Session', () => { surfaceOp: 'append', sourceEventSeqs: [0, -1], })).toThrow(/non-negative safe integers/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -1061,7 +1029,6 @@ describe('Session', () => { data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -1072,7 +1039,6 @@ describe('Session', () => { time: 1, data: { turn: 1 }, }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1093,24 +1059,20 @@ 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents() const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const after = session.snapshotEvents() expect(before).toHaveLength(1) expect(after).toHaveLength(2) expect(after).not.toBe(before) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toBe(after) }) @@ -1119,14 +1081,10 @@ describe('Session', () => { const start = session.append('turn/start', { turn: 1 }) const end = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(0))).toBe(start) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(1))).toBe(end) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(2))).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const range = session.snapshotEvents(SessionLogOffset(1), SessionLogOffset(2)) expect(range).toEqual([end]) expect(Object.isFrozen(range)).toBe(true) @@ -1272,7 +1230,6 @@ describe('Session', () => { const marked = Session.create(SessionId('ignorable-envelope'), [ { ...base, ignorable: true } as SessionEvent, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(marked.snapshotEvents()[0]?.ignorable).toBe(true) }) }) @@ -1317,7 +1274,6 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const forked = ctx.sessions.create(SessionId('fork'), { seed: a.snapshotEvents() }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -1581,7 +1537,6 @@ describe('SessionStore', () => { const heard: SessionEvent[] = [] let committedBeforeNotify = false ctx.on('session/event', (observedSession, event) => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. committedBeforeNotify = observedSession.snapshotEvents().at(-1) === event throw new Error('sync event observer') }) @@ -1595,7 +1550,6 @@ describe('SessionStore', () => { }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) await Promise.resolve() @@ -1619,7 +1573,6 @@ describe('SessionStore', () => { const [observedSession, event] = args as [Session, SessionEvent] validations.push({ event, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. logLength: observedSession.snapshotEvents().length, frozen: Object.isFrozen(event) && Object.isFrozen(event.data), }) @@ -1633,7 +1586,6 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('reject first candidate') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) @@ -1646,7 +1598,6 @@ describe('SessionStore', () => { ]) expect(validations.map(({ event }) => event.seq)).toEqual([0, 0]) expect(validations[1]!.event).toBe(appended) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([appended]) expect(observed).toEqual([appended]) }) @@ -1678,7 +1629,6 @@ describe('SessionStore', () => { sourceEventSeqs: [SessionSeq(2)], })).toThrow('reject surface candidate') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(3) expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) @@ -1704,7 +1654,6 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('dispatch instrumentation rejected the carrier') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) }) @@ -1724,7 +1673,6 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) expect(warnings).toEqual([ @@ -1756,7 +1704,6 @@ describe('SessionStore', () => { turn: 1, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 5436777e22..6d10e22335 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -328,7 +328,6 @@ describe('SurfaceManager', () => { sourceEventSeqs: sourceSeqs(2, 1), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const folded = foldSurface(s.snapshotEvents()) expect(folded.nodes).toEqual(s.surface.nodes) expect(folded.replacements).toEqual([ @@ -338,9 +337,7 @@ describe('SurfaceManager', () => { folded.nodes[0] = SessionSeq(99) folded.replacements[0]!.shadowedSeqs.push(SessionSeq(99)) expect(s.surface.nodes).toEqual([3]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0]) }) @@ -357,7 +354,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(s.snapshotEvents()).replacements).toEqual([ { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, ]) @@ -392,7 +388,6 @@ describe('SurfaceManager', () => { const surface = s.surface const nodes = surface.nodes - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) expect(surface.replaceGeneration).toBe(0) @@ -413,12 +408,10 @@ describe('SurfaceManager', () => { { surfaceOp: surfaceOp({ op: 'replace', startSeq: 0, endSeq: 0 }) }, )).toThrow(/missing 0/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(s.snapshotEvents()).toHaveLength(1) expect(s.surface).toBe(surface) expect(surface.nodes).toEqual([0]) expect(surface.replaceGeneration).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) s.append('user/message', createUserMessage({ @@ -427,7 +420,6 @@ describe('SurfaceManager', () => { expect(surface.nodes).toBe(nodes) expect(surface.nodes).toEqual([0, 1]) expect(surface.replaceGeneration).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) }) @@ -501,7 +493,6 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('replay'), original.snapshotEvents()) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) @@ -603,7 +594,6 @@ describe('SurfaceManager', () => { // Mutate caller's array after append. sources.push(SessionSeq(1)) sources[0] = SessionSeq(99) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([0]) }) @@ -639,7 +629,6 @@ describe('SurfaceManager', () => { }) // Mutate caller's object after append. op.startSeq = SessionSeq(99) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', startSeq: 0, endSeq: 0 }) }) @@ -730,9 +719,7 @@ describe('Session.append surface opts', () => { expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append') }) @@ -766,9 +753,7 @@ 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[0] as SessionEvent).sourceEventSeqs).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((s.snapshotEvents()[0] as SessionEvent).surfaceOp).toBeUndefined() }) @@ -821,14 +806,12 @@ describe('surface type guards', () => { it('isSurfaceEvent narrows a fully-formed surface event', () => { const s = surfaceSession() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! expect(isSurfaceEvent(turnStart)).toBe(false) }) @@ -852,9 +835,7 @@ describe('surface type guards', () => { s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' }, }), { surfaceOp: surfaceOp({ op: 'replace', startSeq: 1, endSeq: 2 }), sourceEventSeqs: sourceSeqs(1, 2) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const appended = s.snapshotEvents().find(e => e.type === 'user/message')! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replacement = s.snapshotEvents().at(-1)! expect(isAppendSurfaceEvent(appended)).toBe(true) @@ -865,7 +846,6 @@ describe('surface type guards', () => { it('rejects log-only and markerless events from both marker guards', () => { const s = surfaceSession() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 795bef2ad8..202963379e 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -152,7 +152,6 @@ describe('tool-pipeline invariants', () => { name: 'echo', arguments: {}, })).toThrow(/parentCallId child does not belong to rootCallId another-root/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'tool/ptc-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 2f2dfebd1a..11bf14e01a 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -1484,7 +1484,6 @@ 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' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const dispatch = session.snapshotEvents().find(event => event.type === 'tool/ptc-dispatch') if (dispatch === undefined) throw new Error('expected a durable tool/ptc-dispatch event') const logged = dispatch.data.arguments as { nested: JsonValue } diff --git a/packages/experimental/agent-team/tests/invariant.spec.ts b/packages/experimental/agent-team/tests/invariant.spec.ts index df9ec3b3a7..5dd8dba256 100644 --- a/packages/experimental/agent-team/tests/invariant.spec.ts +++ b/packages/experimental/agent-team/tests/invariant.spec.ts @@ -44,7 +44,6 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(invalid.snapshotEvents()).toEqual([]) }) @@ -70,7 +69,6 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 c7d4414792..5237fc0301 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -31,7 +31,6 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected @@ -159,7 +158,6 @@ async function persistedChild( // Live sessions persist only through an attached agent-loop writer; this // bare fixture session seeds its durable log directly for the cold restart. const handle = await ctx.sessionPersistence.create(child.header) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await handle.append(child.snapshotEvents()) await handle.close() return child @@ -395,7 +393,6 @@ for (const backend of backends) { const checkpointEntered = Promise.withResolvers() const releaseCheckpoint = Promise.withResolvers() const delayedCheckpoint = vi.spyOn(second.ctx.sessions, 'flush').mockImplementation(async (session) => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (session.id === rootId && session.snapshotEvents().some(event => event.type === 'team/message/delivered' && event.data.messageId === messageId)) { checkpointEntered.resolve(undefined) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index d19f1522b7..79354a6427 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -35,7 +35,6 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected @@ -271,7 +270,6 @@ describe('Team identity and provisioning', () => { // Live sessions persist only through an attached agent-loop writer; this // bare fixture session seeds its durable log directly for the cold reread. const persisted = await ctx.sessionPersistence.create(liveSession.header) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await persisted.append(liveSession.snapshotEvents()) await persisted.close() await liveFiber.dispose() @@ -471,7 +469,6 @@ 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'), - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: lead.session.snapshotEvents(), meta: { parentSession: lead.id, isSeeded: true }, inheritedEventCount: SessionLogOffset(lead.session.seq), @@ -954,7 +951,6 @@ describe('Team mailbox and waiting', () => { }) await expect(teamInternals(ctx).mailbox.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(lead.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(input => input.source.kind === 'team-message' && input.source.messageId === message.id))).toBe(true) @@ -999,14 +995,12 @@ describe('Team mailbox and waiting', () => { 'team/message/delivered', ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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([]) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -1208,7 +1202,6 @@ describe('Team mailbox and waiting', () => { expect(later.status).toBe('accepted') const target = await waitRunning(ctx, started.member.id) await vi.waitFor(() => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const accepted = target.session.snapshotEvents().flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.flatMap(message => message.source.kind === 'team-message' ? [message.source.messageId] diff --git a/packages/experimental/agent-team/tests/test-session-query.ts b/packages/experimental/agent-team/tests/test-session-query.ts index 3b7554873a..233c493ad2 100644 --- a/packages/experimental/agent-team/tests/test-session-query.ts +++ b/packages/experimental/agent-team/tests/test-session-query.ts @@ -33,7 +33,6 @@ export class TestSessionQuery extends SessionQueryEngine { options: SessionObservationOptions = {}, ): Promise { const live = this.ctx.sessions.get(sessionId) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (live !== undefined) return cut('live', live.header, live.snapshotEvents()) const handle = await this.ctx.sessionPersistence.open( sessionId, diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 74a8175fcd..6ae2fddca3 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -148,7 +148,6 @@ describe('dsh-tool-team', () => { expect(childAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) .toEqual(TOOL_NAMES) expect(renderPrompt(childAssembly)).toContain('Your Team role is teammate; your Team name is tool-worker') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const initialPrompt = child.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') expect(initialPrompt?.type === 'user/message' diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 40ef1755b4..e59020bfc6 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -73,7 +73,6 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s /** Authoritative feedback payloads in log order. */ function feedbackTexts(session: Session): string[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents() .filter(event => event.type === 'feedback/record') .map(event => event.data.text) @@ -108,17 +107,14 @@ describe('/feedback human command', () => { text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run') expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 ') }) @@ -129,7 +125,6 @@ describe('/feedback human command', () => { it('keeps command bookkeeping around the authoritative feedback event', async () => { const test = await harness() await run(test, ' nothing else happens') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'feedback/record', 'command/done', ]) @@ -166,12 +161,10 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of test.session.snapshotEvents()) { expect('surfaceOp' in event).toBe(false) expect(test.session.deriveEventMessage(event)).toBeNull() } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([]) expect(test.session.surface.nodes).toEqual([]) expect(test.session.deriveMessages()).toEqual([]) @@ -187,10 +180,8 @@ describe('/feedback human command', () => { await expect(run(test, ' \n\t ')).resolves.toEqual(expected) expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const done = test.session.snapshotEvents().filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of test.session.snapshotEvents()) { if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) } @@ -202,7 +193,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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 6867625302..6ab826e516 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -102,16 +102,12 @@ describe('/feedback real Loader composition through cordis.yml', () => { }) // The domain event owns the payload; generic command bookkeeping omits it. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().map(event => event.type)) .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const run = owner.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(JSON.stringify(owner.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index 8f3f6c3bb8..b2ac2f1943 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -186,7 +186,6 @@ class TestPersistence extends SessionPersistence { } persist(session: Session): void { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. this.durable.set(session.id, { meta: session.header, events: [...session.snapshotEvents()] }) } diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 039c0b1388..7a94fefca2 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,7 +62,6 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) const corruption = new Error('stored log checksum mismatch') persistence.readFailure = corruption @@ -415,7 +414,6 @@ describe('MessageFeedbackService item concurrency', () => { const replacement = Session.create( old.session.id, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. old.session.snapshotEvents(), { ...old.session.header, createdAt: 20, cwd: '/new' }, ) @@ -495,7 +493,6 @@ describe('canonical message feedback history', () => { const sessionId = fixture.session.id const messageId = fixture.assistantMessageIds[0] persistence.persist(fixture.session) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const prefix = fixture.session.snapshotEvents() const lifecycle: string[] = [] ctx.on('session/created', () => { lifecycle.push('created') }) @@ -541,7 +538,6 @@ describe('canonical message feedback history', () => { const { ctx, persistence } = await harness() const session = ctx.sessions.create(SessionId('live-log')) const fixture = appendMessageFixture(session) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents().length const failure = new Error('disk unavailable') let fail = true @@ -556,9 +552,7 @@ describe('canonical message feedback history', () => { const item = listed.value.items[0]! fail = false expectItem(await ctx.messageFeedback.put({ ...request, ifVersion: item.version })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(before + 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(persistence.durable.get(session.id)?.events).toEqual(session.snapshotEvents()) expect(persistence.openCalls).toEqual(['read']) }) @@ -569,7 +563,6 @@ describe('canonical message feedback history', () => { const session = ctx.sessions.create(SessionId('mismatched-checkpoint')) const fixture = appendMessageFixture(session) ctx.on('session/flush', () => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = [...session.snapshotEvents()] if (kind === 'missing-tail') events.pop() if (kind === 'different-tail') events[events.length - 1] = { ...events.at(-1)!, time: 0 } @@ -594,7 +587,6 @@ describe('canonical message feedback history', () => { expectItem(await ctx.messageFeedback.put({ sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null, })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(persistence.durable.get(session.id)!.events.length).toBe(session.snapshotEvents().length - 1) }) @@ -632,7 +624,6 @@ describe('canonical message feedback history', () => { ])('rejects malformed durable feedback payload %#', async (record) => { const { ctx, persistence } = await harness() const fixture = messageFixture('invalid-feedback') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = fixture.session.snapshotEvents() persistence.setDurable({ meta: fixture.session.header, events: [...events, { ...record, seq: SessionSeq(events.length), time: 1, diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 79bddaa090..9e15589025 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,7 +43,6 @@ 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). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const calls = agent.session.snapshotEvents().filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') diff --git a/packages/fs/tool-present/tests/present.spec.ts b/packages/fs/tool-present/tests/present.spec.ts index 4c805c2395..95606e09dc 100644 --- a/packages/fs/tool-present/tests/present.spec.ts +++ b/packages/fs/tool-present/tests/present.spec.ts @@ -84,7 +84,6 @@ describe('present', () => { if (result.isError) throw new Error('present failed') const files = (result.value as unknown as { files: PresentedFile[] }).files expect(files).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files) expect(files).toEqual([{ path: '报告.docx', description: 'Report' }]) expect(read).not.toHaveBeenCalled() @@ -110,7 +109,6 @@ describe('present', () => { execute: async () => ({ turn: 1, files: [] }), })) expect((await execute([])).isError).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')).toEqual([]) }) @@ -119,7 +117,6 @@ describe('present', () => { await owner.ctx.plugin(Present, { maxFiles: 2 }) await writeFile(join(root, 'a'), 'a') expect((await execute([{ path: 'a' }])).isError).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented') expect(deliveries).toHaveLength(1) expect(deliveries[0]?.data.files[0]?.path).toBe('a') @@ -133,7 +130,6 @@ describe('present', () => { return { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] } }) expect((await execute([{ path: 'a' }])).isError).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) @@ -145,7 +141,6 @@ describe('present', () => { const result = await execute(files) expect(result.isError, JSON.stringify(files)).toBe(true) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 94f42cdfe8..1c1761cf74 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -59,19 +59,15 @@ async function harness(): Promise { /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */ function domainEvents(session: Session): readonly SessionEvent[] { const lifecycle = new Set() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents()[event.seq - 1] - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) } - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter(event => !lifecycle.has(event.seq)) } 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 4732334ddc..2ec805d71b 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 @@ -197,7 +197,6 @@ describe('same-session goal driving', () => { }) expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. @@ -208,7 +207,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -264,7 +262,6 @@ describe('same-session goal driving', () => { message: 'Goal round was rejected before entering its step.', }) expect(test.adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) @@ -302,7 +299,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) @@ -381,7 +377,6 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ phase: 'paused', roundsStarted: 1 }) expect(test.adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turnEndKinds = test.agent.session.snapshotEvents().flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []) expect(turnEndKinds).toContain('completed') @@ -399,7 +394,6 @@ 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('') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -437,7 +431,6 @@ 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' @@ -824,7 +817,6 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) @@ -999,7 +991,6 @@ describe('same-session goal driving', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } })) await handle.agent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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() @@ -1105,7 +1096,6 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) expect(test.adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7281859055..ac32cd3765 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -27,7 +27,6 @@ const sessionStubs = new WeakMap() /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } @@ -132,9 +131,7 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const context = session.snapshotEvents()[0] expect(context?.type).toBe('goal/change') if (context?.type !== 'goal/change') throw new Error('expected durable goal change') @@ -143,7 +140,6 @@ describe('GoalService creation and replay', () => { expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) expect(agent.inbox.nextStep).toEqual([]) expect(session.deriveMessages()).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() }) @@ -196,7 +192,6 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) ctx.agents.register(resumed.agent) expect(ctx.goals.get(resumed.agent)).toMatchObject({ @@ -245,14 +240,12 @@ describe('GoalService creation and replay', () => { expect(activations.map(entry => entry.activation)).toEqual(['armed', 'disarmed', 'armed']) expect(activations.map(entry => entry.id)).toEqual([goal.id, goal.id, goal.id]) expect(activations.map(entry => entry.revision)).toEqual([1, 1, 2]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents().length expect(ctx.goals.disarm(agent)).toMatchObject({ id: goal.id, @@ -260,7 +253,6 @@ describe('GoalService creation and replay', () => { phase: 'active', activation: 'disarmed', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(before) expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) }) @@ -424,7 +416,6 @@ describe('GoalService mutations', () => { const tombstone = ctx.goals.clear(agent, goal) expect(tombstone).toEqual({ id: goal.id, revision: 2 }) expect(ctx.goals.get(agent)).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) @@ -441,13 +432,11 @@ describe('GoalService mutations', () => { expect(goal.updatedAt).toBe(100) vi.setSystemTime(80) ctx.goals.clear(agent, goal) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).not.toThrow() vi.useRealTimers() }) @@ -469,12 +458,10 @@ 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' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'goal/change', 'goal/change', 'goal/change', ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) @@ -495,7 +482,6 @@ describe('GoalService mutations', () => { expect(observed).toEqual(created) expect(ctx.goals.get(stub.agent)).toEqual(created) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(stub.session.snapshotEvents())).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) @@ -513,7 +499,6 @@ describe('GoalService mutations', () => { revision: 1, }) expect(stub.agent.inbox.nextStep).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(stub.session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) }) @@ -599,7 +584,6 @@ describe('goal replay validation', () => { function oneChange(change: GoalChangeMeta) { const session = Session.create(SessionId(`validation-${Math.random()}`)) appendChange(session, change) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents() } @@ -631,7 +615,6 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('inbox-independent-change')) appendChange(session, change) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) const message = createUserMessage({ content: [{ type: 'text', text: 'unrelated pending context' }], @@ -640,7 +623,6 @@ describe('goal replay validation', () => { const inbox = stubAgentForSession(session).agent.inbox inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) }) @@ -648,7 +630,6 @@ describe('goal replay validation', () => { const session = Session.create(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) appendChange(session, second) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldGoal(session.snapshotEvents()) } @@ -660,7 +641,6 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'other' }], source: { kind: 'plugin', plugin: 'test' }, })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) @@ -669,7 +649,6 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) }) @@ -677,7 +656,6 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('other-goal-round'), oneChange(change)) appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).toThrow('not the next admitted round') }) @@ -754,7 +732,6 @@ describe('goal replay validation', () => { appendRound(session, base.goal, 2) appendChange(session, { ...paused, roundsStarted: 2 }) appendChange(session, exhausted) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).toThrow('exhausted round budget') }) @@ -779,7 +756,6 @@ describe('goal replay validation', () => { appendChange(completedSession, base) appendChange(completedSession, complete) appendChange(completedSession, sameCurrentId) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(completedSession.snapshotEvents())).toThrow('fresh active revision-one') const second = snapshotChange({ @@ -794,7 +770,6 @@ describe('goal replay validation', () => { appendChange(nonAdjacentReuse, second) appendChange(nonAdjacentReuse, secondComplete) appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(nonAdjacentReuse.snapshotEvents())).toThrow('fresh active revision-one') const clear: GoalChangeMeta = { @@ -804,7 +779,6 @@ describe('goal replay validation', () => { appendChange(clearedSession, base) appendChange(clearedSession, clear) appendChange(clearedSession, sameCurrentId) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(clearedSession.snapshotEvents())).toThrow('fresh active revision-one') }) @@ -817,7 +791,6 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(() => foldGoal(session.snapshotEvents())).toThrow('goal message source is invalid') }) @@ -865,7 +838,6 @@ describe('goal replay validation', () => { clearedAt: 20, } appendChange(session, clear) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 77c2e23818..45cd1ddb37 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -137,7 +137,6 @@ describe('goal projection unit', () => { }) expect(bench.tailValues().goal).toBeNull() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined() }) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 00b2feabe2..b4b4d54808 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -68,7 +68,6 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St /** Open one message-triggered turn with its accepted model-visible input. */ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const turn = stub.session.snapshotEvents() .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 @@ -283,7 +282,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const forkSession = Session.create(forkId, root.session.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id: forkId, 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 e83b99d1ec..4c505d6cf0 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 @@ -37,7 +37,6 @@ 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 }[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ @@ -338,7 +337,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }]) @@ -363,7 +361,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index 5da0f91911..45aeca1050 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,7 +12,6 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { @@ -26,7 +25,6 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) @@ -39,7 +37,6 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 }) @@ -51,7 +48,6 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const sparse = session2.snapshotEvents().find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) @@ -67,7 +63,6 @@ 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' }) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const decisions = session.snapshotEvents() .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) @@ -80,7 +75,6 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') @@ -93,7 +87,6 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) @@ -105,9 +98,7 @@ 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' }) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const invoked = session.snapshotEvents().find(e => e.type === 'hook/invoked') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index ec6a9c2453..68f6c8319d 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -71,7 +71,6 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { } function events(agent: Agent): readonly SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 1edfcb2500..8627a970cd 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -52,7 +52,6 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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). */ diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 2ffb062a0c..1ef87de7c1 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -54,7 +54,6 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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). */ diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 91956f33a5..12a3c370ab 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -42,7 +42,6 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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). */ diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index aef91060c8..92a12d921d 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -33,7 +33,6 @@ 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 }> { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => ({ type: event.type, data: event.data })) @@ -317,7 +316,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'command/done', ]) @@ -356,7 +354,6 @@ describe('CommandRuntime', () => { await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -431,7 +428,6 @@ describe('CommandRuntime', () => { const signal = new AbortController().signal await ctx.commands.execute(agent, 'not a command', [], signal) await ctx.commands.execute(agent, '/missing', [], signal) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents()).toEqual([]) }) @@ -441,7 +437,6 @@ describe('CommandRuntime', () => { ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 552373474f..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,6 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], @@ -163,7 +162,6 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-noop') ctx.permissionPresets.set(session, 'workspace-write') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(0) }) @@ -175,7 +173,6 @@ describe('PermissionPresetService', () => { // the changed knob. session.append('sandbox/mode', { mode: 'read-only' }) ctx.permissionPresets.set(session, 'danger-full-access') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const tail = session.snapshotEvents().slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], @@ -211,7 +208,6 @@ describe('PermissionPresetService', () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(0) expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) @@ -221,7 +217,6 @@ 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')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(first.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'workspace-write' }], ['sandbox/mode', { mode: 'workspace-write' }], @@ -235,7 +230,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(second.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -249,10 +243,8 @@ describe('new-session default', () => { const legacy = freshSession('legacy-source') legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.snapshotEvents() }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents().slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -265,7 +257,6 @@ describe('new-session default', () => { }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -283,11 +274,9 @@ describe('new-session default', () => { }) ctx.provide('approval', { config: { policy: 'ask' } }) const existing = ctx.sessions.create(SessionId('existing-before-permission')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents()).toEqual([]) await ctx.plugin(PermissionPresetService, {}) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -313,7 +302,6 @@ 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). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'sandbox/mode', 'approval/policy', ]) @@ -325,9 +313,7 @@ describe('new-session default', () => { const partial = freshSession('partial-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) partial.append('approval/policy', { policy: 'ask' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.snapshotEvents() }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'permission/preset', data: { preset: 'workspace-write' }, @@ -336,10 +322,8 @@ describe('new-session default', () => { const custom = freshSession('custom-source') custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.snapshotEvents() }) expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(unmatched.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) @@ -347,9 +331,7 @@ describe('new-session default', () => { const ctx = await mountedStore({ approvalDefault: undefined }) const partial = freshSession('approval-fallback-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.snapshotEvents() }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 9a7ef5d0d3..53022e9089 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -99,7 +99,6 @@ describe('/permission command', () => { text: 'The approval policy changed from "ask" to "never" (changed by the user).', }], }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const run = session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) }) @@ -112,14 +111,12 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -130,7 +127,6 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index c0fce76425..70e1abcf4e 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -132,11 +132,8 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -158,11 +155,8 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }) @@ -382,7 +376,6 @@ describe('approval policy (the approval/policy fold)', () => { setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') expect(service.overrideOf(session)).toBe('ask') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) @@ -422,9 +415,7 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(e => e.type === 'approval/asked')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(e => e.type === 'approval/decided')).toHaveLength(1) }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 7b95f6982d..5c5a177040 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -112,7 +112,6 @@ describe('real Loader composition', () => { await agent.whenIdle() expect(adapter.requests).toBe(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index cf61fcd4fa..e7f0ededb2 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -215,7 +215,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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({ @@ -251,7 +250,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message').map(event => ({ turn: event.data.turn, step: event.data.step, @@ -288,9 +286,7 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(500) await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const failedAttempts = agent.session.snapshotEvents().filter((event): event is SessionEvent<'assistant/attempt'> => event.type === 'assistant/attempt' && retryEvent !== undefined @@ -298,14 +294,12 @@ describe('provider-routed retry policy', () => { ) expect(failedAttempts).toHaveLength(1) expect(expandAssistantStream(failedAttempts[0]!.data.stream)).toHaveLength(7) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const assistantMessages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') expect(assistantMessages.map(event => ({ turn: event.data.turn, step: event.data.step, }))).toEqual([{ turn: 1, step: 1 }]) expect(assistantMessages[0]?.sourceEventSeqs).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'tool/call')).toBe(false) expect(toolExecutions).toBe(0) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ @@ -343,9 +337,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(3) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } }, @@ -401,7 +393,6 @@ describe('provider-routed retry policy', () => { rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await rejectedIdle expect(rejected.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(rejectedAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) @@ -440,7 +431,6 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await idle expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -460,9 +450,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', @@ -491,7 +479,6 @@ describe('provider-routed retry policy', () => { const normalIdle = waitForIdle(context, normalAgent) normalAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } })) await normalIdle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(normalAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) const alwaysAgent = await context.agentLoop.create(SessionId('retry-provider-always'), { @@ -578,7 +565,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => ({ provider: event.data.provider, retry: event.data.retry, @@ -690,7 +676,6 @@ describe('provider-routed retry policy', () => { await vi.runAllTimersAsync() await idle - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(adapter.requests).toHaveLength(5) expect(events.map(event => ({ @@ -735,7 +720,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry' && event.data.failure.message === diagnostic, )).toBe(true) @@ -758,7 +742,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) @@ -810,7 +793,6 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(60_000) expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) @@ -852,7 +834,6 @@ describe('provider-routed retry policy', () => { expect(order[0]).toBe('downstream') expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) @@ -891,7 +872,6 @@ describe('provider-routed retry policy', () => { expect(order).toEqual(['downstream', 'idle']) expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -929,7 +909,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -987,7 +966,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -1017,9 +995,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, @@ -1043,7 +1019,6 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 b14db6e47e..ba218842de 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -102,11 +102,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server).toBeDefined() expect(server?.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -133,9 +131,7 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const failedAttempts = agent.session.snapshotEvents().filter((event): event is SessionEvent<'assistant/attempt'> => event.type === 'assistant/attempt' && retryEvent !== undefined @@ -143,11 +139,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { ) expect(failedAttempts).toHaveLength(1) expect(expandAssistantStream(failedAttempts[0]!.data.stream)).toHaveLength(failedChunkCount) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('recovered response') @@ -168,14 +162,11 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['EMPTY_RESPONSE']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -198,14 +189,10 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const attempt = agent.session.snapshotEvents().find(event => event.type === 'assistant/attempt' && event.data.turn === 1) expect(attempt?.type === 'assistant/attempt' ? expandAssistantStream(attempt.data.stream) : []).toHaveLength(3) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, @@ -228,7 +215,6 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -247,11 +233,8 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(3) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 35f64858f9..769f00e0ec 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -292,7 +292,6 @@ describe('contextBreakdown session projection', () => { const first = appendUser(session, 'first message') const last = appendUser(session, 'last message') const definition = contextBreakdownProjectionDefinition - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const state = session.snapshotEvents().reduce(definition.apply, definition.init()) const before = JSON.stringify(state) const replacement = session.append('user/message', createUserMessage({ @@ -383,10 +382,8 @@ describe('contextBreakdown session projection', () => { const { session } = await harness() const head = appendSystem(session, 'head') const definition = contextBreakdownProjectionDefinition - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const state = session.snapshotEvents().reduce(definition.apply, definition.init()) replaceSystem(session, head, 'same') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const next = definition.apply(state, session.snapshotEvents().at(-1)!) expect(next).not.toBe(state) expect(definition.wire.view(next)).toBe(definition.wire.view(state)) @@ -424,7 +421,6 @@ describe('contextBreakdown session projection', () => { stale, [replacement], SessionLogOffset(replacement.seq), session.header, session.inheritedEventCount, )).toThrow('re-read from seq 0') const replayed = ctx.sessionProjections.restore( - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. stale, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, ) expect(replayed.snapshot.values.contextBreakdown).toEqual(projected(ctx, session)) @@ -437,7 +433,6 @@ describe('contextBreakdown session projection', () => { }, } expect(() => ctx.sessionProjections.restore( - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. invalid, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, )).toThrow() }) @@ -457,7 +452,6 @@ describe('contextBreakdown session projection', () => { expect.soft(ctx.sessionProjections.viewCheckpoint(checkpoint)).not.toHaveProperty('contextBreakdown') expect.soft(ctx.sessionProjections.restoreFloor(checkpoint)).toBe(0) const restored = ctx.sessionProjections.restore( - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. checkpoint, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, ) expect(restored.snapshot.values.contextBreakdown).toEqual({ diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index bd8218beb0..e795ea5507 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -230,7 +230,6 @@ describe('TokenMeter pricing', () => { const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(result.logRevision).toBe(session.snapshotEvents().length) expectSurfaceTotal(result) }) @@ -424,7 +423,6 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'new tail' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seeded = Session.create(SessionId('surface-seeded'), original.snapshotEvents()) const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) @@ -438,9 +436,7 @@ describe('replay anchors and surface folds', () => { }), { surfaceOp: { op: 'replace', startSeq: first, endSeq: first }, sourceEventSeqs: [first] }) const after = service.measure(seeded) expect(after.nodes).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(after.nodes[0]!.seq).toBe(seeded.snapshotEvents().length - 1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(after.logRevision).toBe(seeded.snapshotEvents().length) expect(Object.isFrozen(after.nodes)).toBe(true) expect(Object.isFrozen(after.nodes[0])).toBe(true) @@ -448,7 +444,6 @@ describe('replay anchors and surface folds', () => { expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) // The earlier snapshot still reports the log it measured: seed + boundary. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(before.logRevision).toBe(original.snapshotEvents().length + 1) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -460,7 +455,6 @@ describe('replay anchors and surface folds', () => { durableText: '', }) const measurement = meter().measure(session) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 4b0eb97f01..ac82e45822 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -105,7 +105,6 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const header = findEvent(log, 'request/header') @@ -139,10 +138,8 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const first = findEvent(agent.session.snapshotEvents(), 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstSystem = findEvent(agent.session.snapshotEvents(), 'system/message') expect(systemText(agent)).not.toContain(PLAN_CONFIG.section) @@ -150,7 +147,6 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -200,7 +196,6 @@ describe('plan mode through the agent loop', () => { expect(requestSystem(adapter.requests[1])).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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const nextIdle = waitForIdle(ctx, agent) @@ -210,7 +205,6 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(3) expect(requestSystem(adapter.requests[2])).toContain(PLAN_CONFIG.section) expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 9bd0e7c87c..2e3bd89949 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -142,7 +142,6 @@ function header(session: Session): void { } function noticeTexts(session: Session): string[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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('')) @@ -206,12 +205,10 @@ describe('resolveConfig', () => { describe('foldPlanMode', () => { it('folds an empty log to inactive and takes the last plan/mode otherwise', () => { const session = Session.create(SessionId('fold')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents())).toBe(false) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) session.append('plan/mode', { active: true }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents())).toBe(true) }) @@ -219,9 +216,7 @@ describe('foldPlanMode', () => { const session = Session.create(SessionId('fold-prefix')) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents(), 1)).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(session.snapshotEvents(), 0)).toBe(false) }) }) @@ -286,16 +281,13 @@ describe('ctx.planMode: get/set', () => { const ctx = await setup() const agent = await agentWithSession(ctx, 'agent-idle') expect(ctx.planMode.set(agent, true)).toBe('committed') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) // A later boundary finds nothing pending — no double append. await boundary(ctx, agent, 'step-start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().filter(event => event.type === 'plan/mode')).toHaveLength(2) }) @@ -307,7 +299,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(ctx.planMode.get(agent)).toEqual({ active: false }) }) @@ -328,7 +319,6 @@ describe('the boundary flush', () => { const service = ctx.planMode as unknown as { onBoundary(session: Session): void } expect(() => { service.onBoundary(agent.session) }).not.toThrow() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) @@ -338,7 +328,6 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'pre-step') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) @@ -354,7 +343,6 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) await fiber.dispose() await boundary(ctx, agent, 'pre-step') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) @@ -363,7 +351,6 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -375,7 +362,6 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) await boundary(ctx, agent, 'pre-step') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -417,7 +403,6 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -445,7 +430,6 @@ describe('the boundary flush', () => { expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) agent.session.append = original await boundary(ctx, agent, 'step-start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) @@ -690,7 +674,6 @@ describe('/plan', () => { expect(enteringSteer).not.toHaveBeenCalled() await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(entering.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const active = await agentWithSession(ctx, 'active-plan-command', { active: true }) @@ -715,11 +698,9 @@ 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.' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) @@ -883,7 +864,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -893,7 +873,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -902,7 +881,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -923,7 +901,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(child.session.snapshotEvents())).toBe(true) }) @@ -936,11 +913,9 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) await boundary(ctx, agent, 'step-start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) @@ -991,7 +966,6 @@ describe('exit_plan_mode', () => { question: 'Approve this plan and leave plan mode?', detail: plan, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().find(event => event.type === 'tool/ptc-dispatch')?.data).toMatchObject({ name: EXIT_PLAN_MODE, arguments: { plan }, @@ -1007,13 +981,11 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) @@ -1025,7 +997,6 @@ describe('exit_plan_mode', () => { header(agent.session) await callExit(ctx, agent) await boundary(ctx, agent, 'step-start') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -1035,7 +1006,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1051,7 +1021,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1060,7 +1029,6 @@ 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.' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1069,7 +1037,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1084,7 +1051,6 @@ 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.' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1117,7 +1083,6 @@ 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.' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1130,7 +1095,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1171,7 +1135,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1181,7 +1144,6 @@ 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' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -1233,7 +1195,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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 1e15df94b4..f4d46d5b62 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -155,7 +155,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index f02651d2f5..b1a685d019 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -98,7 +98,6 @@ async function agentOn(ctx: Context, id: string, presetId?: string): Promise - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. agent.session.snapshotEvents().findLast(event => event.type === 'agent-preset/selected')?.data describe('the roster a client reads', () => { diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 543fbff319..608abeeade 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -207,7 +207,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumed = Session.create(active.id, active.snapshotEvents(), active.header) const ctx = await promptMounted({ mode: 'read-only' }) @@ -233,7 +232,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/schedule/schedule/tests/invariant.spec.ts b/packages/schedule/schedule/tests/invariant.spec.ts index 5600ea2635..f595bfa081 100644 --- a/packages/schedule/schedule/tests/invariant.spec.ts +++ b/packages/schedule/schedule/tests/invariant.spec.ts @@ -53,7 +53,6 @@ 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')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(2) expect(() => session.append('schedule/change', { @@ -61,11 +60,9 @@ describe('Schedule package invariant', () => { operation: 'delete', id: ScheduleId('missing'), })).toThrow(InvariantError) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(2) session.append('schedule/change', { version: 1, operation: 'dispatch', id: ScheduleId('schedule-1') }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(3) await ctx.fiber.dispose() }) @@ -85,7 +82,6 @@ describe('Schedule package invariant', () => { id: ScheduleId('schedule-every'), acceptedAt: '2026-08-05T12:17:34.000Z', }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toHaveLength(2) await ctx.fiber.dispose() }) @@ -122,7 +118,6 @@ describe('Schedule package invariant', () => { }) const fiber = await ctx.plugin(scheduleInvariant) child.append('schedule/change', create('child')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 3a86c940d6..baf02e4702 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -107,7 +107,6 @@ describe('Schedule production JSONL restart', () => { ) pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord }) const seed = await first.sessionPersistence.create(pending.header) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await seed.append(pending.snapshotEvents()) await seed.close() await disposeContext(first) @@ -144,7 +143,6 @@ describe('Schedule production JSONL restart', () => { await replayed.sessions.flush(replayHandle.agent.session) expect(replayAdapter.requests).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayHandle.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) const replayedStored = await readStored(replayed, sessionId) diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index a1daefbef8..f216babd7d 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -173,7 +173,6 @@ describe('Schedule timer and admission runtime', () => { await settle() expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().find(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toBeDefined() await runtime.dispose() @@ -224,7 +223,6 @@ describe('Schedule timer and admission runtime', () => { expect(test.followed).toEqual([]) expect(test.controls.whenIdleCount).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) runtime.requestDrive() @@ -297,14 +295,12 @@ describe('Schedule timer and admission runtime', () => { ].join('\n'), }]) expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'schedule' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }), @@ -374,7 +370,6 @@ describe('Schedule timer and admission runtime', () => { expect(test.controls.releaseCount).toBe(1) expect(test.followed).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'delete' }) runtime.requestDrive() await settle() @@ -434,7 +429,6 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.releaseCount).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) await runtime.dispose() @@ -466,7 +460,6 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) runtime.requestDrive() @@ -494,7 +487,6 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' )).map(event => event.data)).toEqual([{ @@ -503,7 +495,6 @@ describe('Schedule runtime failure and teardown boundaries', () => { id: 'schedule-first', acceptedAt: '2026-08-05T12:00:00.000Z', }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' }), @@ -554,7 +545,6 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.flushCount).toBe(1) expect(test.followed).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await runtime.dispose() @@ -617,7 +607,6 @@ describe('Schedule runtime failure and teardown boundaries', () => { } await settle() expect(test.followed).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) }) diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index 467bc4f288..35e4dc1724 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -160,7 +160,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -234,7 +233,6 @@ describe('Schedule tool protocol', () => { expect.objectContaining({ id: 'schedule-1', kind: 'at' }), expect.objectContaining({ id: 'schedule-2', kind: 'at' }), ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const changes = test.agent.session.snapshotEvents() .filter(event => event.type === 'schedule/change' && event.data.operation === 'create') expect(changes.map((change) => { @@ -303,7 +301,6 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be strictly in the future.', }) expect(test.flushes.count).toBe(3) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -316,7 +313,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const internal = await harness() @@ -443,7 +439,6 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -468,7 +463,6 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) @@ -492,7 +486,6 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(3) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')) .toHaveLength(1) expect(value(await execute(test, 'schedule_list', {}))) @@ -504,7 +497,6 @@ 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' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(createTest.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const deleteTest = await harness() @@ -512,7 +504,6 @@ describe('Schedule persistence failure boundaries', () => { deleteTest.flushes.outcomes.push('reject') expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(deleteTest.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) }) 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 afb9125448..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,6 @@ 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.' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done']) expect(session.deriveMessages()).toEqual([]) }) 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 b1cfa90c28..ea89275ef3 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1034,7 +1034,6 @@ describe('session-query exact reads', () => { }).toThrow() Object.assign(snapshot.session, { cwd: '/mutated' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.eventAt(SessionSeq(4)) expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) expect(session.header.cwd).toBe('/work') @@ -1072,7 +1071,6 @@ describe('session-query exact reads', () => { (result.events[0]!.data as { content: unknown[] }).content = [] }).toThrow() expect(session.header.createdAt).not.toBe(-1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.eventAt(SessionSeq(1)) expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) 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 0ca1de7052..c127c2a127 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 @@ -2006,7 +2006,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(text(result)).toContain(new Date(session.snapshotEvents()[0]?.time ?? 0).toISOString()) }) diff --git a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts index 31eb4d215f..76702f4c38 100644 --- a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts +++ b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts @@ -84,7 +84,6 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged recordFeedback(session, ' The session needs a clearer explanation. ') const created = await ctx.messageFeedback.put({ sessionId: session.id, messageId: assistant.id, rating: 'negative', note: 'Explain the result.', ifVersion: null }) if (!created.ok) throw new Error(created.error.code) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const initialPrefix = session.snapshotEvents() const request = async () => { const chunks = [] @@ -133,7 +132,6 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged ] }) } await ctx.sessions.flush(session) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((await handle.read()).events).toEqual(session.snapshotEvents()) } finally { await handle.close() diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index dc4eb3f1c3..e92df32921 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -117,7 +117,6 @@ describe('incremental DeepSeek session-log upload', () => { turn: 1, step: 3, message: createSystemMessage('new head', 'fixture'), }, { surfaceOp: { op: 'replace', startSeq: head.seq, endSeq: head.seq }, sourceEventSeqs: [head.seq] }) const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: session.id }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) @@ -177,7 +176,6 @@ describe('incremental DeepSeek session-log upload', () => { expect(firstPayload?.events).toHaveLength(2) await first.accept() expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().at(-1)?.data).toEqual({ sessionId: session.id, throughSeq: 1, @@ -199,7 +197,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = first.session.snapshotEvents() const resumed = await harness('parent', seed) @@ -364,7 +361,6 @@ 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 }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) diff --git a/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts b/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts index 814da947a6..9be35ec1bf 100644 --- a/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/multi-edge-publication.spec.ts @@ -176,7 +176,6 @@ function assertMigrated(result: Awaited>) { expect(session.firstLiveSeq).toBe(23) expect(session.isOwnSeq(SessionSeq(8))).toBe(false) expect(session.isOwnSeq(SessionSeq(9))).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.ownEvents()).toEqual([ ...events.slice(9), expect.objectContaining({ type: 'session/end-seed', seq: 23, data: {} }), diff --git a/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts index 3c40b96b20..b8a7fad132 100644 --- a/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/v2-system-migration.spec.ts @@ -131,7 +131,6 @@ describe('V2 system prompts through current Session and JSONL persistence', () = const session = await restore(reader) expect(messages(session)).toEqual([human]) expect(session.surface.nodes).toEqual([8, 3]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.eventAt(SessionSeq(8))?.type).toBe('system/message') } finally { await reader.close() @@ -208,7 +207,6 @@ describe('V2 system prompts through current Session and JSONL persistence', () = expect(session.firstLiveSeq).toBe(15) expect(session.surface.nodes).toEqual([4, 8]) expect(messages(session)).toEqual([prompt('seed prompt'), { role: 'user', content: [{ type: 'text', text: 'summary' }] }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.ownEvents()).toEqual([prepared[14]]) } finally { await reader.close() @@ -229,9 +227,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () = }, { surfaceOp: { op: 'replace', startSeq: SessionSeq(4), endSeq: SessionSeq(4) }, sourceEventSeqs: [SessionSeq(4)] }) session.append('step/end', { turn: 2, step: 1 }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expected = session.snapshotEvents() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await writer.append(session.snapshotEvents(session.firstLiveSeq)) await writer.flush() } finally { @@ -290,7 +286,6 @@ describe('V2 system prompts through current Session and JSONL persistence', () = }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await writer.append(session.snapshotEvents()) await writer.flush() } finally { @@ -299,7 +294,6 @@ describe('V2 system prompts through current Session and JSONL persistence', () = const reopened = await mount() const reader = await reopened.sessionPersistence.open(id, 'read') try { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect((await reader.read()).events).toEqual(session.snapshotEvents()) const restored = await restore(reader) expect(restored.surface.nodes).toEqual([2, 3, 4]) diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 35c2b89e11..b8919024bd 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -175,7 +175,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents().find(event => event.type === 'turn/start')!.time) * 1_000_000n) expect(start?.record.attributes).toContainEqual({ key: 'session.format_version', @@ -241,7 +240,6 @@ describe('OpenTelemetrySessionBackend wire', () => { }, }) expect(eventTypes(captures)).not.toContain('manual') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) expect(ops).toHaveLength(0) }) @@ -495,7 +493,6 @@ describe('OpenTelemetrySessionBackend wire', () => { session.append('turn/start', { turn: 1 }) expect(captures).toEqual([]) recordFeedback(session, 'explicit report') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const submitted = session.snapshotEvents().map(event => event.type) await expect.poll(() => eventTypes(captures)).toEqual(submitted) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -521,7 +518,6 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { session.append('turn/start', { turn: 1 }) expect(captures).toEqual([]) recordFeedback(session, 'explicit report') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const expected = session.snapshotEvents().map(event => event.type) await expect.poll(() => eventTypes(captures)).toEqual(expected) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -539,7 +535,6 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { await ctx.plugin(SessionStore) const donor = Session.create(SessionId('stored-feedback')) recordFeedback(donor, 'old feedback is not a submission') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const restored = ctx.sessions.create(donor.id, { seed: donor.snapshotEvents(), meta: donor.header }) try { const first = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } }) @@ -550,10 +545,8 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'change' }) } const child = ctx.sessions.fork(restored, undefined, SessionId('child')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const inherited = child.snapshotEvents().find(event => event.type === 'feedback/record')! ctx.emit('session/event', child, inherited) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const opened = ctx.sessions.create(SessionId('opened'), { seed: donor.snapshotEvents() }) ctx.emit('session/created', opened) await first.dispose() @@ -588,7 +581,6 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { expect(captures).toEqual([]) const put = await ctx.messageFeedback.put(request) if (!put.ok) throw new Error('live put failed') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) const throughPut = session.seq await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version }) @@ -596,10 +588,8 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { expect(session.seq).toBe(throughPut) const edit = await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version, note: 'edited note' }) if (!edit.ok) throw new Error('live note failed') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) await ctx.messageFeedback.delete({ sessionId: session.id, messageId: message.id, ifVersion: edit.value.version }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type)) const submitted = eventTypes(captures) const throughDelete = session.seq @@ -622,7 +612,6 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { const { ctx, fiber } = await boot(url) const session = Session.create(SessionId('cold-not-submitted')) const notify = async () => ctx.parallel('feedback/committed', { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. meta: session.header, events: session.snapshotEvents(), inheritedEventCount: session.inheritedEventCount, }) await notify() @@ -634,7 +623,6 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { child.append('feedback/message-delete', { sessionId: session.id, messageId: message.id }) await ctx.parallel('feedback/committed', { meta: { ...session.header, id: SessionId('inherited-cold'), parentSession: session.id, isSeeded: true }, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(1)), inheritedEventCount: SessionLogOffset(1), }) await fiber.dispose() @@ -654,14 +642,12 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { parent.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) const message = createAssistantMessage({ content: [{ type: 'text', text: 'inherited answer' }], source: { provider: 'mock', model: 'm' } }) parent.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = Session.create(SessionId('cold-child'), parent.snapshotEvents(), { ...parent.header, id: SessionId('cold-child'), parentSession: parent.id, isSeeded: true, }, parent.seq) recordFeedback(child, 'child-owned stored feedback') const handle = await ctx.sessionPersistence.create(child.header, { inheritedEventCount: child.inheritedEventCount }) try { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await handle.append(child.snapshotEvents()) } finally { await handle.close() @@ -709,7 +695,6 @@ describe('OpenTelemetrySessionBackend route and feedback', () => { session.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' }) const handle = await ctx.sessionPersistence.create(session.header) try { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. await handle.append(session.snapshotEvents()) } finally { await handle.close() diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index 7ecdc624a0..b563d9c469 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -72,7 +72,6 @@ describe('session-telemetry/record waterfall', () => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.snapshotEvents()[0]!.data as { content: { text: string }[] } expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) }) @@ -125,7 +124,6 @@ describe('session-telemetry/record waterfall', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) expect(backend.records).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 8a5b50bbe7..ac9ffdcfc0 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -128,13 +128,11 @@ describe('SessionTelemetryCoordinator capture', () => { 'event.type': 'turn/start', 'event.seq': 0, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const logged = session.snapshotEvents()[1] as SessionEvent<'user/message'> expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) }) @@ -246,7 +244,6 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) appendAssistantMessage(session, 1, 1, ['first'], 100) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstBoundary = session.snapshotEvents()[2]!.seq appendAssistantMessage(session, 1, 2, ['second'], 200) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -302,7 +299,6 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { }) const parent = liveSession(ctx, 'history-parent') appendTurn(parent) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = ctx.sessions.create(SessionId('history-child'), { seed: [...parent.snapshotEvents()] }) const boundary = child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) child.append('turn/start', { turn: 2 }) @@ -402,7 +398,6 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.snapshotEvents()], meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) @@ -432,7 +427,6 @@ describe('SessionTelemetryCoordinator adoption', () => { // telemetry therefore receives a current-format object with the complete // migrated canonical seed. const resumed = ctx.sessions.prepare(SessionId('resumed'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: structuredClone(donor.snapshotEvents()) as SessionEvent[], meta: { version: SESSION_FORMAT_VERSION, @@ -466,9 +460,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: [...parent.snapshotEvents()], - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. inheritedEventCount: SessionLogOffset(parent.snapshotEvents().length), meta: { parentSession: SessionId('stitch-parent'), isSeeded: true }, }) 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 ef37a9d131..429f1f5bd3 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,6 @@ 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'), { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. seed: seeded.snapshotEvents(), inheritedEventCount: seeded.seq, meta: { parentSession: seeded.id, isSeeded: true }, diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index bd9ce65dc4..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,6 @@ describe('generateSessionTitleWithLlm', () => { const providerRequest = request(ctx) let requestWasLoggedAtDispatch = false const adapter = new RecordingAdapter(SCRIPT, () => { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. requestWasLoggedAtDispatch = providerRequest.session.snapshotEvents() .some(event => event.type === 'session/title-llm-request') }) @@ -163,7 +162,6 @@ 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('第二个问题') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(providerRequest.session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data) .toEqual({ titleProvider: TITLE_PROVIDER, @@ -195,7 +193,6 @@ describe('generateSessionTitleWithLlm', () => { await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER)) .rejects.toThrow(/input.*bytes.*maxInputBytes/i) expect(adapter.requests).toEqual([]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(oversized.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false) const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 }) @@ -264,7 +261,6 @@ describe('generateSessionTitleWithLlm', () => { providerRequest.messages, TITLE_PROVIDER, )).rejects.toMatchObject({ message, code }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(true) }) diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 90f6a207bb..df2b5f1f91 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -17,7 +17,6 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi } function appendTitle(session: Session, title: string): SessionSeqType { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messageSeq = session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user')?.seq ?? session.append('user/message', createUserMessage({ diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 2708d51e2c..d9632ea3b6 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -66,9 +66,7 @@ describe('SessionTitleService Provider lifecycle', () => { const child = ctx.sessions.fork(parent, undefined, SessionId('title-child')) expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent)) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.snapshotEvents().find(event => event.type === 'session/title')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. .toEqual(parent.snapshotEvents().find(event => event.type === 'session/title')) const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ @@ -379,7 +377,6 @@ describe('SessionTitleService Provider lifecycle', () => { }))) await settle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(requests).toHaveLength(2) expect(requests[1]).toMatchObject({ diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 706511d2e3..9654c18edc 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -46,7 +46,6 @@ describe('SessionTitleService.rename', () => { messageSeqs: [], source: { kind: 'user' }, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.data).toEqual({ title: 'Hand picked name', @@ -54,7 +53,6 @@ describe('SessionTitleService.rename', () => { source: { kind: 'user' }, }) // foldSessionTitle round-trips the third source kind. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSessionTitle(session.snapshotEvents())?.source).toEqual({ kind: 'user' }) }) @@ -166,7 +164,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 c0b13910fe..c6e0dc93c3 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -171,7 +171,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.snapshotEvents() }) const results = await Promise.all([ @@ -180,9 +179,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { ]) expect(results[0]).toEqual(results[1]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', @@ -207,7 +204,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) @@ -270,7 +266,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await fiber.dispose() await settle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) const inactiveError = await lifecycle.inactiveRefresh expect(inactiveError).toBeInstanceOf(Error) @@ -290,7 +285,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await Promise.resolve() await fiber.dispose() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 b5cbf31c3c..69edbb84ff 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -53,7 +53,6 @@ describe('SessionTitleService', () => { await settleTitles() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const titleEvent = session.snapshotEvents().findLast(event => event.type === 'session/title') expect(titleEvent).toMatchObject({ type: 'session/title', @@ -141,7 +140,6 @@ describe('SessionTitleService', () => { expect(first?.messageSeqs).toEqual([eligible.seq]) expect(ctx.sessionTitle.get(session)).toEqual(first) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) @@ -162,7 +160,6 @@ describe('SessionTitleService', () => { }, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSessionTitle(seed.snapshotEvents())).toEqual({ title: 'Later', messageSeqs: [1, 4], @@ -172,7 +169,6 @@ describe('SessionTitleService', () => { model: { provider: 'mock', model: 'title-model' }, }, eventSeq: 1, - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. updatedAt: seed.snapshotEvents()[1]?.time, }) }) @@ -180,7 +176,6 @@ describe('SessionTitleService', () => { it('folds an empty or title-less log to undefined', () => { expect(foldSessionTitle([])).toBeUndefined() const empty = Session.create(SessionId('no-title')) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(foldSessionTitle(empty.snapshotEvents())).toBeUndefined() }) }) diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index d561bed780..cf2a4e1d2e 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -58,7 +58,6 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): readonly SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() } diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index b70b02691c..1dfbd27b81 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -631,7 +631,6 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7fe649b1a8..603166de04 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -124,7 +124,6 @@ async function proposeStep( } function catalogMessages(session: Session): Extract[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().filter((event): event is Extract => event.type === 'user/message' && event.data.source.kind === 'skill-catalog') } 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 04238d479b..35c8af4df3 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 @@ -67,7 +67,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentPrefixLen = parent.session.snapshotEvents().length // Delegate to a fresh spawn child. @@ -89,7 +88,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(forkChild.session.snapshotEvents().slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) await spawnRun.dispose() @@ -98,14 +96,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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 2e557bebb0..6f7080cd31 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 @@ -84,7 +84,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) expect(child.session.header.isSeeded).toBe(false) expect(child.session.inheritedEventCount).toBe(0) @@ -97,7 +96,6 @@ describe('dsh-subagent-fork-in-process', () => { await parent.whenIdle() parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })) await parent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) @@ -105,9 +103,7 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! expect(child.session.header.isSeeded).toBe(true) expect(child.session.inheritedEventCount).toBe(parentPrefixLen) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) @@ -116,7 +112,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) @@ -126,10 +121,8 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().length).toBeGreaterThan(parentPrefixLen) // The seeded prefix carried the parent's user message. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seededUser = child.session.snapshotEvents().slice(0, parentPrefixLen).find(e => e.type === 'user/message') expect(seededUser).toBeDefined() // Lineage stamped. @@ -160,7 +153,6 @@ 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). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index 72d3426a8a..d3fc067a5a 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -69,7 +69,6 @@ function spawnRequest(parent: Agent) { } function toolResultTexts(agent: Agent): string[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents() .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') .map(event => event.data.message.content @@ -87,7 +86,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentLogLength = parent.session.snapshotEvents().length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -102,7 +100,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }, @@ -112,15 +109,12 @@ describe('in-process policy inheritance', () => { expect(child.session.inheritedEventCount).toBe(0) expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') expect(ctx.approval.overrideOf(child.session)).toBe('never') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const request = child.session.snapshotEvents().find( (event): event is SessionEvent<'request/header'> => event.type === 'request/header', ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNode = child.session.snapshotEvents().find( (event): event is SessionEvent<'system/message'> => event.type === 'system/message', ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const runtimeContext = child.session.snapshotEvents().find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' @@ -148,7 +142,6 @@ describe('in-process policy inheritance', () => { .join('\n') expect(systemText).not.toContain('Approval prompts are disabled') expect(systemText).not.toContain('You are a delegated subagent') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength) } finally { await run.dispose() @@ -160,7 +153,6 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = parent.session.snapshotEvents() setSandboxMode(parent.session, 'read-only') script.push( @@ -177,7 +169,6 @@ describe('in-process policy inheritance', () => { expect(child.session.inheritedEventCount).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) // seq 1 is the constructor's end-seed marker. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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' } }, @@ -224,9 +215,7 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([ { seq: 0, data: { policy: 'never', source: 'delegation' } }, ]) @@ -266,11 +255,9 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const asked = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const decided = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', ) 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 e4ec2605b0..106b9e3eaa 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 @@ -85,7 +85,6 @@ describe('a child agent composed in-process', () => { const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(run.localAgent?.session.snapshotEvents().some(event => event.type === 'system/message' && JSON.stringify(event.data.message.content).includes('section for preset_only'))).toBe(true) 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 70398a2f9f..fa4bfdb22b 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -196,7 +196,6 @@ describe('in-process structured output', () => { expect(result.structured).toEqual({ answer: 5 }) expect(sideEffectRan).toBe(false) const child = ctx.agents.get(run.id) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -241,7 +240,6 @@ 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)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -260,7 +258,6 @@ 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)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) @@ -326,7 +323,6 @@ 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)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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') @@ -372,7 +368,6 @@ describe('in-process structured output', () => { expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') const child = ctx.agents.get(run.id) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -445,7 +440,6 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') expect(adapter.requests).toHaveLength(2) const child = ctx.agents.get(run.id)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) 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 e274ac7cb4..d11fa73b05 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 @@ -150,7 +150,6 @@ describe('startInProcessRun', () => { let injected = false ctx.on('session/flush', (session) => { if (injected || session.header.parentSession === undefined) return - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastEnd = session.snapshotEvents().findLast(event => event.type === 'turn/end') if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return injected = true @@ -165,7 +164,6 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(injected).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().findLast(event => event.type === 'turn/end')) .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) expect(result.stopReason).toBe('max-tokens') @@ -201,7 +199,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = parent.session.snapshotEvents() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result @@ -209,13 +206,11 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(child.session.header.isSeeded).toBe(true) expect(child.session.inheritedEventCount).toBe(seed.length) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) // The seeded `system/message` stays surface node 0: the child renders the // same prompt, so its loop appends no second system node. const seededSystem = seed.find(event => event.type === 'system/message') expect(seededSystem).toBeDefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(1) expect(child.session.surface.nodes[0]).toBe(seededSystem?.seq) expect(child.session.deriveMessages()[0]).toEqual(parent.session.deriveMessages()[0]) @@ -226,7 +221,6 @@ describe('startInProcessRun', () => { const { ctx, parent, adapter } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const seed = parent.session.snapshotEvents() const seededSystem = seed.find(event => event.type === 'system/message') if (seededSystem === undefined) throw new Error('the parent log lacks a system node') @@ -235,7 +229,6 @@ describe('startInProcessRun', () => { const run = await startInProcessRun(request(parent), { seed }) await run.result const child = ctx.agents.get(run.id)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const systemNodes = child.session.snapshotEvents().filter(event => event.type === 'system/message') expect(systemNodes.map(event => event.seq)).toEqual([seededSystem.seq, systemNodes[1]?.seq]) expect(systemNodes[1]?.surfaceOp).toEqual({ op: 'replace', startSeq: seededSystem.seq, endSeq: seededSystem.seq }) @@ -367,7 +360,6 @@ describe('startInProcessRun', () => { }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index c031772384..3e764070f7 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,6 @@ 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). - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = parent.session.snapshotEvents() const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) 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 957496e492..b678f5c263 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 @@ -118,7 +118,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const parentEventCount = parent.session.snapshotEvents().length expect(parentEventCount).toBeGreaterThan(0) @@ -126,7 +125,6 @@ describe('dsh-subagent-spawn-in-process', () => { 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const firstUser = child.session.snapshotEvents().find(e => e.type === 'user/message') expect(firstUser).toBeDefined() await run.dispose() @@ -441,7 +439,6 @@ 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)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index ecaa72a347..f2bbd16ef5 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -258,7 +258,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.snapshotEvents(), 'child task') }) }) @@ -451,7 +450,6 @@ describe('SubagentRuntime.startContinuable', () => { })).rejects.toBe(catalogFailure) await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().filter(event => event.type === 'subagent/catalog')).toEqual([]) }) @@ -531,7 +529,6 @@ describe('SubagentRuntime.startContinuable', () => { expect(found).toBeDefined() return found! }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const descriptor = child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor') expect(descriptor?.data).toEqual({ @@ -567,7 +564,6 @@ describe('SubagentRuntime.startContinuable', () => { return found! }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor')?.data) .toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, @@ -1159,7 +1155,6 @@ describe('continuable durability and teardown', () => { child.followup(createUserMessage({ content: message('accepted during flush'), source: { kind: 'user' } })) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(hasAssistantText(child.session.snapshotEvents(), 'late answer')).toBe(true) }) await child.whenIdle() @@ -1887,7 +1882,6 @@ 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)! - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = child.session.snapshotEvents().length const controller = new AbortController() @@ -2349,7 +2343,6 @@ 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 }[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 [] @@ -2412,7 +2405,6 @@ describe('continuable adjacent-Agent delivery', () => { await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const delivered = parent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'agent-message' ? [event.data] : [])[0] expect(delivered?.id).toBe(messageId) @@ -2707,7 +2699,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( @@ -2876,9 +2867,7 @@ describe('continuable settlement delivery', () => { `Background subagent ${started.childId} was stopped before it finished.` + '\nIt left no closing message.', ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(parent.status).toBe('idle') }) @@ -2895,7 +2884,6 @@ describe('continuable settlement delivery', () => { await drained expect(settlementNotices(parent)).toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) 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 2af3149716..f5921a3fab 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -347,7 +347,6 @@ describe('SubagentModelSelectionConfig', () => { const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.snapshotEvents() }) expect(selectable(ctx, resumedEnabled)).toBe(true) @@ -360,7 +359,6 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, resumedEmpty)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 78216805a3..af707d8269 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -618,7 +618,6 @@ 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', ) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(1) const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -629,7 +628,6 @@ 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() - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(2) }) diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index f65454023a..f4235aad18 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -96,7 +96,6 @@ describe('dsh-agent-loop-testkit', () => { { id: step.id, turn: 3 }, { id: turn.id, turn: 3 }, ]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'agent/inbox/spliced', 'agent/inbox/spliced', 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 31292f374c..f80929880d 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,13 +19,11 @@ 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 - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. .map(seq => agent.session.snapshotEvents()[seq]) .find(event => event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const openTurn = agent.session.snapshotEvents().findLast(event => event.type === 'turn/start') if (openTurn?.type !== 'turn/start') throw new Error('workspace snapshot compaction has no open turn') const compactionId = CompactionId('workspace-context-fixture') diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index cde02c8482..3384372a65 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -63,7 +63,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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) @@ -92,10 +91,8 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const todoEvents = agent.session.snapshotEvents().filter(e => e.type === 'todo/write') expect(todoEvents).toHaveLength(2) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. 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 0501982bdb..4b41b6a301 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,11 +66,9 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const before = session.snapshotEvents() expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual(before) }) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 039ba4dc1f..b99909fa2a 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -112,7 +112,6 @@ 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') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }, 30_000) @@ -130,7 +129,6 @@ describe('tool-todo real Loader composition through cordis.yml', () => { agent: owner, }) expect(result.isError).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(owner.session.snapshotEvents().findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index b045128eac..a3d60f5b63 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -82,7 +82,6 @@ describe('dsh-tool-todo', () => { }) expect(text(result)).toContain('1 pending, 1 in progress, 0 completed') - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual(todos) }) @@ -93,7 +92,6 @@ describe('dsh-tool-todo', () => { const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent }) expect(result.isError).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual([{ content: 'plan the work', status: 'pending' }]) }) @@ -107,7 +105,6 @@ describe('dsh-tool-todo', () => { { content: 'b', status: 'in_progress' }, ] }, { agent }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const current = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'a', status: 'completed' }, @@ -142,7 +139,6 @@ describe('dsh-tool-todo', () => { todos, counts: { pending: 1, inProgress: 2, completed: 0 }, }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) @@ -159,7 +155,6 @@ 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. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(agent.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }) @@ -252,7 +247,6 @@ describe('todo/write event', () => { ] session.append('todo/write', { todos }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.type).toBe('todo/write') expect(event.data.todos).toEqual(todos) @@ -274,7 +268,6 @@ describe('todo/write event', () => { { content: 'second', status: 'in_progress' }, ] }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const current = session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'first', status: 'completed' }, @@ -300,13 +293,10 @@ 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' } }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const replayed = Session.create(SessionId('t4-replay'), original.snapshotEvents()) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(replayed.snapshotEvents(SessionLogOffset(0), original.seq)).toEqual(original.snapshotEvents()) expect(replayed.firstLiveSeq).toBe(original.seq) }) diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index e752ae307c..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,6 @@ describe('dsh-tool-workflow', () => { engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 }) expect((await pending).isError).toBe(false) expect(engine.disposed).toBe(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], ['tool-workflow/agent-start', { @@ -167,11 +166,9 @@ describe('dsh-tool-workflow', () => { value: null, stopReason: 'completed', agentsStarted: 0, }) await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual(['tool-workflow/run-start']) barrier.resolve(undefined) expect((await pending).isError).toBe(false) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'tool-workflow/run-start', 'tool-workflow/run-end', ]) @@ -193,10 +190,8 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/agent-start')) .toHaveLength(1) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) .toEqual([ { runId: 'run-1', stopReason: 'completed' }, @@ -213,7 +208,6 @@ 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) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(session.snapshotEvents()).toEqual([]) }) @@ -246,7 +240,6 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain(failedType) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const types = session.snapshotEvents().map(event => event.type) const expectedPrefixes = { 'tool-workflow/run-start': [], diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index f358f71d9c..aab098c2aa 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -254,13 +254,30 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + } }, 90_000) - it('allows waived Session history reads while rejecting new reads and unrelated deprecations', async () => { + it('allows Session history reads only in tests or with existing-call waivers', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) + const testPaths = [ + `packages/core/session/tests/oxlint-contract-${suffix}.ts`, + `apps/cli/tests/oxlint-contract-${suffix}.ts`, + `scripts/oxlint-contract-${suffix}.spec.ts`, + ] + const productionPaths = [ + `packages/core/session/src/oxlint-contract-${suffix}.ts`, + `scripts/oxlint-contract-${suffix}.ts`, + ] + const paths = [...testPaths, ...productionPaths] + const reads = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session' + +export function reads(session: Session): void { + session.snapshotEvents() + session.eventAt(SessionSeq(0)) + session.ownEvents() +} +` const existing = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session' -export function existingReads(session: Session): void { +export function reads(session: Session): void { // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents() // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. @@ -269,39 +286,50 @@ export function existingReads(session: Session): void { session.ownEvents() } ` - const added = ` + const unrelated = ` /** @deprecated Use the replacement API. */ function oldApi(): void {} -export function newReads(session: Session): void { - session.snapshotEvents() - session.eventAt(SessionSeq(0)) - session.ownEvents() +export function unrelatedRead(): void { oldApi() } ` try { - await writeFile(path, existing) - const args = ['--config', relative(repositoryRoot, configPath), '--format', 'unix', relative(repositoryRoot, path)] + await Promise.all([ + ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads)), + ...productionPaths.map(path => writeFile(join(repositoryRoot, path), existing)), + ]) + const args = ['--config', relative(repositoryRoot, configPath), '--format', 'unix', ...paths] const allowed = runRepositoryOxlint(args) expect(allowed.error).toBeUndefined() expect(allowed.signal).toBeNull() expect(allowed.status, normalizedOutput(allowed)).toBe(0) - await writeFile(path, existing + added) + await Promise.all([ + ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads + unrelated)), + ...productionPaths.map(path => writeFile(join(repositoryRoot, path), reads)), + ]) const rejected = runRepositoryOxlint(args) const output = normalizedOutput(rejected) expect(rejected.error).toBeUndefined() expect(rejected.signal).toBeNull() expect(rejected.status, output).toBe(1) - expect(output.match(/typescript\(no-deprecated\)/g), output).toHaveLength(4) + const diagnostics = output.split('\n').filter(line => line.includes('typescript(no-deprecated)')) + for (const path of testPaths) { + const reported = diagnostics.filter(line => line.startsWith(`${path}:`)) + expect(reported, output).toHaveLength(1) + expect(reported[0]).toContain('`oldApi` is deprecated') + } + for (const path of productionPaths) { + expect(diagnostics.filter(line => line.startsWith(`${path}:`)), output).toHaveLength(3) + } for (const method of ['snapshotEvents', 'eventAt', 'ownEvents', 'oldApi']) { expect(output).toContain(`\`${method}\` is deprecated`) } } finally { await Promise.all([ - rm(path, { force: true }), + ...paths.map(path => rm(join(repositoryRoot, path), { force: true })), rm(configPath, { force: true }), ]) } diff --git a/scripts/python-snapshot-workflow-order.spec.ts b/scripts/python-snapshot-workflow-order.spec.ts index 84f401c51b..964244f5da 100644 --- a/scripts/python-snapshot-workflow-order.spec.ts +++ b/scripts/python-snapshot-workflow-order.spec.ts @@ -82,7 +82,6 @@ describe('advanced Python snapshot workflow ordering', () => { const child = await entered.promise expect(child.id).toBe(run.id) expect(adapter.requests).toHaveLength(0) - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false) parent.session.append('tool-workflow/agent-start', { runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id, From a61b0c5a567f03ce5c36e05387218cf9426e223b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 13:16:09 +0800 Subject: [PATCH 05/60] test(lint): update the scoped deprecation fingerprint --- scripts/lint-rule-fingerprint.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index 4aecd9e985..5ad4653d4e 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -30,7 +30,7 @@ const profiles = { test: { count: 84, indexes: [0, 3, 4, 5], - sha256: '7800bf57f0d689a5623e4ad60315b10173158fb9cf988d3f005cb115f2245691', + sha256: '7e4e1ed0cfca06ac94eeb26081195b5f753e7527775d5f5b95ccfefac9d2b137', }, } as const satisfies Record From c433ac777681e576895f8fecee1069819bc0ae79 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 13:17:20 +0800 Subject: [PATCH 06/60] test(credentials): await reload commit notifications --- .../credentials-local/tests/watcher.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 314d425d1f..7a77e7952a 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -145,9 +145,15 @@ describe('watcher pipeline', () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) + const firstUpdate = Promise.withResolvers() + const secondUpdate = Promise.withResolvers() let arm = true ctx.on('credentials/reference-updated', () => { - if (!arm) return + if (!arm) { + secondUpdate.resolve(undefined) + return + } + firstUpdate.resolve(undefined) throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) }) const [instance] = await fakeInstances() @@ -156,16 +162,14 @@ describe('watcher pipeline', () => { instance!.watcher.emit('all', 'change', path) // The snapshot commits before the fan-out, so the value lands even though // the listener threw out of the refresh. - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) - }) + await firstUpdate.promise + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) arm = false await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_PIPE: second\n') instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) - }) + await secondUpdate.promise + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) }) it('quiesces the refresh pipeline before dispose completes', async () => { From 3744740b6256347606aa973eb24f304513f8ccf2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 13:17:21 +0800 Subject: [PATCH 07/60] test(agent-team): await recovered mail acknowledgement --- .../experimental/agent-team/tests/persistence.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 5237fc0301..8ca2f1b1e9 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -311,12 +311,20 @@ for (const backend of backends) { await first.dispose() const second = await stack(backend, storageRoot, [textResponse('resumed teammate answer')]) + const delivered = Promise.withResolvers() + second.ctx.on('session/event', (session, event) => { + if (session.id === rootId && event.type === 'team/message/delivered' + && event.data.messageId === queued.messageId) delivered.resolve(undefined) + }) const rootHandle = await second.ctx.agents.resume({ resumeSessionId: rootId, agentOptions: { provider: 'mock', model: 'mock' }, }) + // The child is also absent before cold resume starts; delivery proves recovery reached the mailbox. + await delivered.promise + await settleMailbox(second.ctx) await vi.waitFor(() => { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) - await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) }) + expect(durable(rootHandle.agent).pendingMessages).toEqual([]) const child = await storedEvents(second.ctx, started.member.id) const peerIds = child.flatMap(event => event.type === 'user/message' From 536754191668eed7764be95dc26fa344bb9c3ea0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 13:40:51 +0800 Subject: [PATCH 08/60] Revert "test(agent-team): await recovered mail acknowledgement" This reverts commit 6e9b6abf38694f9e2ba4214e87356d90293f8dc8. --- .../experimental/agent-team/tests/persistence.spec.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 8ca2f1b1e9..5237fc0301 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -311,20 +311,12 @@ for (const backend of backends) { await first.dispose() const second = await stack(backend, storageRoot, [textResponse('resumed teammate answer')]) - const delivered = Promise.withResolvers() - second.ctx.on('session/event', (session, event) => { - if (session.id === rootId && event.type === 'team/message/delivered' - && event.data.messageId === queued.messageId) delivered.resolve(undefined) - }) const rootHandle = await second.ctx.agents.resume({ resumeSessionId: rootId, agentOptions: { provider: 'mock', model: 'mock' }, }) - // The child is also absent before cold resume starts; delivery proves recovery reached the mailbox. - await delivered.promise - await settleMailbox(second.ctx) await vi.waitFor(() => { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) - expect(durable(rootHandle.agent).pendingMessages).toEqual([]) + await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) }) const child = await storedEvents(second.ctx, started.member.id) const peerIds = child.flatMap(event => event.type === 'user/message' From ba1e73ddf56313b01bf22060b5293a6ee858d3ed Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 13:40:51 +0800 Subject: [PATCH 09/60] Revert "test(credentials): await reload commit notifications" This reverts commit 436ec72d4b744cbf3c1473680388d802940b6b9e. --- .../credentials-local/tests/watcher.spec.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 7a77e7952a..314d425d1f 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -145,15 +145,9 @@ describe('watcher pipeline', () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) - const firstUpdate = Promise.withResolvers() - const secondUpdate = Promise.withResolvers() let arm = true ctx.on('credentials/reference-updated', () => { - if (!arm) { - secondUpdate.resolve(undefined) - return - } - firstUpdate.resolve(undefined) + if (!arm) return throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) }) const [instance] = await fakeInstances() @@ -162,14 +156,16 @@ describe('watcher pipeline', () => { instance!.watcher.emit('all', 'change', path) // The snapshot commits before the fan-out, so the value lands even though // the listener threw out of the refresh. - await firstUpdate.promise - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) + }) arm = false await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_PIPE: second\n') instance!.watcher.emit('all', 'change', path) - await secondUpdate.promise - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) + }) }) it('quiesces the refresh pipeline before dispose completes', async () => { From 14da43d9ff2dc147fd15372a28f28c6df537905e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 14:51:50 +0800 Subject: [PATCH 10/60] docs(session): link deprecated readers to the history policy --- ...09-09-deprecate-synchronous-session-event-reads.i18n.yaml | 4 ++-- .../2026-09-09-deprecate-synchronous-session-event-reads.md | 4 ++-- ...026-09-09-deprecate-synchronous-session-event-reads.zh.md | 4 ++-- docs/subsystems/session.i18n.yaml | 4 ++-- docs/subsystems/session.md | 3 +++ docs/subsystems/session.zh.md | 3 +++ packages/core/session/src/index.ts | 5 ++++- scripts/oxlint-contract.spec.ts | 5 ++++- 8 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml index 7a61bc540e..0cae49b087 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md -2026-09-09-deprecate-synchronous-session-event-reads.md: 1420227daf9a410e7db6a7a685bcf4433d7d7b0b -2026-09-09-deprecate-synchronous-session-event-reads.zh.md: e546337bcb15cf6682935729de37aab0c579d2a2 +2026-09-09-deprecate-synchronous-session-event-reads.md: a2a86b0d6500269531ca1088738aaa612076bfb4 +2026-09-09-deprecate-synchronous-session-event-reads.zh.md: 38468708a5323b6ebf94377f3cc8f2c86dc95dde diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md index 1420227daf..a2a86b0d65 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md @@ -16,7 +16,7 @@ All operations that synchronously read arbitrary positions or ranges of Session The three methods carry this rule in `@deprecated` JSDoc. This is an API-use decision; the current Session implementation still retains the complete event sequence in memory. -Repository test files may call these three readers to inspect emitted events and exercise Session history behavior. Their existing lint override allows `snapshotEvents`, `eventAt`, and `ownEvents`; all other deprecated names remain errors. This allowance also covers unrelated declarations with the same three names under the current linter. It does not apply to production source or ordinary repository scripts. +Repository test files, including `scripts/**/*.spec.{ts,tsx}`, may call these three readers to inspect emitted events and exercise Session history behavior. The test-file lint override allows `snapshotEvents`, `eventAt`, and `ownEvents`; all other deprecated names remain errors. This allowance also covers unrelated declarations with the same three names under the current linter. It does not apply to production source or non-test repository scripts. ### State needed after resume @@ -44,7 +44,7 @@ Fork and a small number of operations may genuinely need a complete historical s New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log. -Existing calls outside test files carry line-scoped `typescript/no-deprecated` waivers with a migration-deferral reason. Remove a waiver when its call migrates; copying a waiver to a new production call violates this policy. The executable lint check accepts test reads and existing waived reads, rejects unwaived production reads, and rejects unrelated deprecated APIs in tests. Documentation checks verify the source-equivalent API declarations and bilingual records. +Calls outside test files carry line-scoped `typescript/no-deprecated` waivers that identify deferred migration or delegation between deprecated readers. Remove a waiver when its deprecated call is removed; copying a waiver to a new production call violates this policy. The executable lint check accepts test reads and existing waived reads, rejects unwaived production reads, and rejects unrelated deprecated APIs in tests. Documentation checks verify the source-equivalent API declarations and bilingual records. ## Related decisions diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md index e546337bcb..38468708a5 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md @@ -16,7 +16,7 @@ Status: implemented 这三个方法通过 `@deprecated` JSDoc 声明该规则。这是 API 使用决策;当前 Session 实现仍在内存中保留完整事件序列。 -仓库测试文件可以调用这三个读取方法,以检查已发出的事件并验证 Session 历史行为。现有测试 lint override 允许 `snapshotEvents`、`eventAt` 和 `ownEvents`,其他弃用名称仍报错。在当前 linter 下,此豁免也覆盖使用这三个名称的其他声明。它不适用于生产源码或普通仓库脚本。 +仓库测试文件(包括 `scripts/**/*.spec.{ts,tsx}`)可以调用这三个读取方法,以检查已发出的事件并验证 Session 历史行为。测试文件 lint override 允许 `snapshotEvents`、`eventAt` 和 `ownEvents`,其他弃用名称仍报错。在当前 linter 下,此豁免也覆盖使用这三个名称的其他声明。它不适用于生产源码或非测试仓库脚本。 ### 恢复后需要的状态 @@ -44,7 +44,7 @@ fork 等少数操作可能确实需要完整历史序列或继承前缀。它们 新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。 -测试文件之外的现有调用带有逐行的 `typescript/no-deprecated` 豁免,并说明暂缓迁移的理由。调用迁移后应删除豁免;将豁免复制到新增生产调用违反本策略。实际执行 lint 的检查允许测试读取和已有豁免调用,拒绝未豁免的生产读取,并拒绝测试中其他已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。 +测试文件之外的调用带有逐行的 `typescript/no-deprecated` 豁免,说明暂缓迁移或已弃用读取方法之间的委托。删除已弃用调用时应一并删除其豁免;将豁免复制到新增生产调用违反本策略。实际执行 lint 的检查允许测试读取和已有豁免调用,拒绝未豁免的生产读取,并拒绝测试中其他已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。 ## 相关决策 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 4d4f5bc022..c5dbb4944f 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: 077e90c66abe3b5f3f24d14c3fb25f356d887fa8 -session.zh.md: 6b9ceb85a276b0221c1e6ff474fbcdea77dff1fd +session.md: 187578ac720dd3b7facba768491bd5fb867aa397 +session.zh.md: 3dae495b695c4ddec220c83d872c4f2c5f9b13c3 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 077e90c66a..187578ac72 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -474,6 +474,7 @@ declare class Session { /** * Return the immutable event stored at one exact sequence number. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -483,6 +484,7 @@ declare class Session { * A full current snapshot is reused until the next append; every previously * returned snapshot remains stable after later appends. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @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. @@ -494,6 +496,7 @@ declare class Session { /** * Return this Session's events after its fork-inherited prefix. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[]; diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 6b9ceb85a2..3dae495b69 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -476,6 +476,7 @@ declare class Session { /** * Return the immutable event stored at one exact sequence number. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -485,6 +486,7 @@ declare class Session { * A full current snapshot is reused until the next append; every previously * returned snapshot remains stable after later appends. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @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. @@ -496,6 +498,7 @@ declare class Session { /** * Return this Session's events after its fork-inherited prefix. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[]; diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index dbf124893e..12e9ed1918 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -616,6 +616,7 @@ export class Session { /** * Return the immutable event stored at one exact sequence number. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -628,6 +629,7 @@ export class Session { * A full current snapshot is reused until the next append; every previously * returned snapshot remains stable after later appends. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @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. @@ -646,10 +648,11 @@ export class Session { /** * Return this Session's events after its fork-inherited prefix. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[] { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-deprecated -- Deprecated reader delegates to the deprecated range read. return this.snapshotEvents(this.inheritedEventCount) } diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index aab098c2aa..1618907a1b 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -315,7 +315,7 @@ export function unrelatedRead(): void { expect(rejected.error).toBeUndefined() expect(rejected.signal).toBeNull() expect(rejected.status, output).toBe(1) - const diagnostics = output.split('\n').filter(line => line.includes('typescript(no-deprecated)')) + const diagnostics = output.split('\n').filter(line => /:\d+:\d+: `\w+` is deprecated\./.test(line)) for (const path of testPaths) { const reported = diagnostics.filter(line => line.startsWith(`${path}:`)) expect(reported, output).toHaveLength(1) @@ -327,6 +327,9 @@ export function unrelatedRead(): void { for (const method of ['snapshotEvents', 'eventAt', 'ownEvents', 'oldApi']) { expect(output).toContain(`\`${method}\` is deprecated`) } + expect(output).toContain( + 'See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).', + ) } finally { await Promise.all([ ...paths.map(path => rm(join(repositoryRoot, path), { force: true })), From 12ba944f549229b2c64555f9b8368843d5d139a3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 14:52:15 +0800 Subject: [PATCH 11/60] test(lint): cover example history reader exemptions --- scripts/oxlint-contract.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 1618907a1b..8cb4098f17 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -257,9 +257,12 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('allows Session history reads only in tests or with existing-call waivers', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) + const exampleRoot = `examples/oxlint-contract-${suffix}` + const examplePath = `${exampleRoot}/tests/reads.ts` const testPaths = [ `packages/core/session/tests/oxlint-contract-${suffix}.ts`, `apps/cli/tests/oxlint-contract-${suffix}.ts`, + examplePath, `scripts/oxlint-contract-${suffix}.spec.ts`, ] const productionPaths = [ @@ -296,6 +299,11 @@ export function unrelatedRead(): void { ` try { + await mkdir(join(repositoryRoot, exampleRoot, 'tests'), { recursive: true }) + await writeFile(join(repositoryRoot, exampleRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + include: ['tests/**/*.ts'], + })) await Promise.all([ ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads)), ...productionPaths.map(path => writeFile(join(repositoryRoot, path), existing)), @@ -332,7 +340,8 @@ export function unrelatedRead(): void { ) } finally { await Promise.all([ - ...paths.map(path => rm(join(repositoryRoot, path), { force: true })), + ...paths.filter(path => path !== examplePath).map(path => rm(join(repositoryRoot, path), { force: true })), + rm(join(repositoryRoot, exampleRoot), { recursive: true, force: true }), rm(configPath, { force: true }), ]) } From 9c14f2785f2bdf41487b9006229a7cc134786b21 Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 9 Sep 2026 15:52:46 +0800 Subject: [PATCH 12/60] perf(desktop): parallelize macOS notarization --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 2 +- ...ectron-desktop-packaging-and-updates.zh.md | 2 +- ...9-09-parallel-macos-notarization.i18n.yaml | 6 + .../2026-09-09-parallel-macos-notarization.md | 37 ++++ ...26-09-09-parallel-macos-notarization.zh.md | 37 ++++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- apps/desktop/scripts/package-macos.ts | 121 +++++++++++ apps/desktop/scripts/package-target.ts | 23 ++- .../scripts/verify-macos-signature.d.mts | 7 + .../scripts/verify-macos-signature.mjs | 12 ++ apps/desktop/tests/package-macos.spec.ts | 188 ++++++++++++++++++ 14 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md create mode 100644 .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md create mode 100644 apps/desktop/scripts/package-macos.ts create mode 100644 apps/desktop/tests/package-macos.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index fe1bf84c1b..5a68f12a7b 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: d7d41aa4d6154f251b56faaa29065db3ee71071f -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 952ed4fc5546524bc088f58e62faccd576194bc7 +2026-08-25-electron-desktop-packaging-and-updates.md: 7a12cefe2ba8f9cb721a3f7eaf329964813f5bbb +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5c766fc94cd6c88d04bfb2fe13cecc936da17c87 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index d7d41aa4d6..7a12cefe2b 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -83,7 +83,7 @@ Before opening the product window, startup validates the current bundled runtime Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation. -Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 952ed4fc55..5c766fc94c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -83,7 +83,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查。 -Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml new file mode 100644 index 0000000000..bd4f67275d --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.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/process/2026-09-09-parallel-macos-notarization.md +2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70 +2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75 diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md new file mode 100644 index 0000000000..152da46612 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md @@ -0,0 +1,37 @@ +# Agent Note: Parallel macOS notarization from isolated App copies + +Status: implemented + +English | [中文](2026-09-09-parallel-macos-notarization.zh.md) + +## Problem + +The Desktop release distributes a DMG for installation and a ZIP for updates. Waiting for App notarization before creating the DMG serializes two Apple submissions. A proxy improves upload throughput but does not overlap the independent service waits. Stapling modifies the App, so concurrent notarization and packaging cannot safely share that writable directory. + +## Decision + +The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`. + +The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App. + +Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata. + +This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements. + +## Alternatives considered + +**Share one App between both lanes.** A DMG reader can overlap with `stapler` writes, producing a nondeterministic bundle. Independent copies keep the submitted and distributed bytes stable within each lane. + +**Only notarize the DMG.** ZIP updates are distributed independently and need a stapled App. Retaining both submissions keeps that independent qualification explicit. + +**Keep serial notarization and only use a proxy.** The same 214.84 MiB DMG uploads in 203.83 seconds directly and 38.59 seconds through the tested system proxy, but neither route removes the serial dependency between Apple submissions. Proxy configuration remains a build-host concern; the packaging script does not change host network settings. + +**Run both targets in one electron-builder call before App notarization finishes.** The ZIP must read the stapled copy. Separate prepackaged invocations preserve electron-builder's own archive, blockmap, and metadata implementation without changing its target scheduling or patching the dependency. + +## Consequences + +On 2026-09-09, full arm64 packaging on the same Mac through the same system proxy took 490.78 seconds with parallel notarization versus 751.33 seconds serially, a 34.7% reduction. The artifact lanes began 8 milliseconds apart and completed in 307.36 seconds for App/ZIP and 268.54 seconds for DMG. Apple accepted both submissions; their uploads completed about one second apart. Each configuration has one full-build sample, so cache and Apple queue variation prevent attributing the entire difference to concurrency. + +Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process. + +The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work. diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md new file mode 100644 index 0000000000..d5375e39a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 基于隔离 App 副本的并行 macOS 公证 + +Status: implemented + +[English](2026-09-09-parallel-macos-notarization.md) | 中文 + +## 问题 + +Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 App 公证完成后才创建 DMG,会让两次 Apple 提交串行执行。代理可以提高上传吞吐量,但不能让两个独立的服务等待过程重叠。钉票会修改 App,因此并发公证和打包不能安全地共享同一个可写目录。 + +## 决策 + +固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装其副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。 + +ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。 + +错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。 + +本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。 + +## 考虑过的替代方案 + +**两路共享一个 App。** DMG 读取可能与 `stapler` 写入重叠,使 bundle 内容不确定。独立副本使每条路线中提交与分发的字节保持稳定。 + +**只公证 DMG。** ZIP 更新独立分发,需要已钉票的 App。保留两次提交可以明确维持这项独立验收。 + +**保留串行公证,仅使用代理。** 同一个 214.84 MiB DMG 直连上传耗时 203.83 秒,通过所测系统代理上传耗时 38.59 秒,但两种网络路径都不能消除 Apple 提交间的串行依赖。代理配置仍由构建主机负责;打包脚本不修改主机网络设置。 + +**App 公证结束前,在同一次 electron-builder 调用中运行两个目标。** ZIP 必须读取已钉票副本。独立的 prepackaged 调用可以保留 electron-builder 自己的归档、blockmap 和元数据实现,无需修改目标调度或给依赖打补丁。 + +## 影响 + +2026-09-09,在同一台 Mac、同一系统代理下,arm64 完整打包在并行公证时耗时 490.78 秒,串行时耗时 751.33 秒,缩短 34.7%。两条产物路线相隔 8 毫秒启动,App/ZIP 耗时 307.36 秒,DMG 耗时 268.54 秒。Apple 接受了两次提交,两者上传完成时间仅相差约一秒。每种配置只有一次完整构建样本,缓存与 Apple 队列变化使我们不能将全部差值归因于并发。 + +两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。 + +[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 30b7fcabe7..561017590b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 6ef18d302531beb80877f23d657d8524ff396b2b -README.zh.md: 94669b2697af894f91f33068526a0c61d6ed7a8f +README.md: 42ae18fa0520401adec7eaef9bb4c019911cbeb3 +README.zh.md: 65be3c1a0568a5d70f64bd4b783cfd59be8fce4b diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6ef18d3025..42ae18fa05 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -115,10 +115,12 @@ pnpm run upload:mac:arm64 Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename. -The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands still notarize and staple the App. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. +Company proxies can accelerate uploads to Apple's notarization service. See the company internal documentation for configuration. + ### Windows EV signing Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 94669b2697..65be3c1a05 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -115,10 +115,12 @@ pnpm run upload:mac:arm64 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证 hook 成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。 -macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令仍会公证 App 并钉票。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 +可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。 + ### Windows EV 签名 Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入: diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts new file mode 100644 index 0000000000..382e52b150 --- /dev/null +++ b/apps/desktop/scripts/package-macos.ts @@ -0,0 +1,121 @@ +/** Build the ZIP and DMG from separate signed application copies with overlapping notarization. */ + +import { execFile } from 'node:child_process' +import { mkdtemp, rename, rm, stat } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { promisify } from 'node:util' +import { Arch, getArchSuffix } from 'electron-builder' +import { notarize } from '@electron/notarize' +import { + resolveMacOSNotarizationEnvironment, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs' +import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs' + +const execute = promisify(execFile) + +/** One electron-builder artifact made from an already signed application. */ +export interface DesktopPrepackagedArtifact { + readonly format: 'dmg' | 'zip' + readonly appPath: string + readonly output: string +} + +/** A signed macOS directory build and its final release destination. */ +export interface MacOSArtifactRequest { + readonly arch: 'arm64' | 'x64' + readonly version: string + readonly artifactsRoot: string + readonly environment: NodeJS.ProcessEnv +} + +/** Apple-tool operations replaced by deterministic fixtures in orchestration tests. */ +export interface MacOSArtifactOperations { + readonly copyApp: (source: string, destination: string) => Promise + readonly notarize: (options: ReturnType & { appPath: string }) => Promise + readonly verifySignature: typeof verifyMacOSSignature + readonly verifyNotarization: typeof verifyMacOSNotarizedApplication +} + +const operations: MacOSArtifactOperations = { + async copyApp(source, destination) { + await execute('/usr/bin/ditto', [source, destination]) + }, + notarize, + verifySignature: verifyMacOSSignature, + verifyNotarization: verifyMacOSNotarizedApplication, +} + +async function timed(label: string, action: () => Promise): Promise { + const start = performance.now() + process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`) + await action() + process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`) +} + +/** + * Notarize independent App/DMG copies concurrently, then promote their completed artifacts. + * Both lanes settle before cleanup or rejection. The ZIP contains a stapled App; the DMG + * carries its own ticket and encloses the signed App without an individually stapled ticket. + * @param request - Signed directory build, release version, architecture, and credentials. + * @param build - Runs electron-builder to completion with publishing disabled. + * @param apple - Apple signing, copying, and notarization operations. + * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory. + */ +export async function packageMacOSArtifacts( + request: MacOSArtifactRequest, + build: (artifact: DesktopPrepackagedArtifact) => Promise, + apple: MacOSArtifactOperations = operations, +): Promise { + const { arch, version, artifactsRoot, environment } = request + const expected = resolveMacOSSigningEnvironment(environment) + const credentials = resolveMacOSNotarizationEnvironment(environment) + const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app') + const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-')) + const zipApp = join(root, 'zip', basename(appPath)) + const dmgApp = join(root, 'dmg', basename(appPath)) + const zipOutput = join(root, 'zip-artifacts') + const dmgOutput = join(root, 'dmg-artifacts') + try { + await apple.copyApp(appPath, zipApp) + await apple.copyApp(appPath, dmgApp) + apple.verifySignature(zipApp, expected) + apple.verifySignature(dmgApp, expected) + const results = await Promise.allSettled([ + timed('App notarization and ZIP', async () => { + await apple.notarize({ appPath: zipApp, ...credentials }) + apple.verifyNotarization(zipApp, expected) + await build({ format: 'zip', appPath: zipApp, output: zipOutput }) + }), + timed('DMG creation and notarization', async () => { + await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput }) + }), + ]) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length > 0) { + throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed') + } + const base = `deepseek-harness-${version}-mac-${arch}` + const artifacts = [ + [dmgOutput, `${base}.dmg`], + [zipOutput, `${base}.zip`], + [zipOutput, `${base}.zip.blockmap`], + [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')], + ] as const + for (const [output, filename] of artifacts) { + const file = join(output, filename) + const details = await stat(file) + if (!details.isFile() || details.size === 0) { + throw new Error(`desktop macOS packaging: missing or empty artifact ${file}`) + } + } + for (const [output, filename] of artifacts) { + await rename(join(output, filename), join(artifactsRoot, filename)) + } + await rm(appPath, { recursive: true }) + await rename(zipApp, appPath) + } finally { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c5b0bc2667..74d80fd1ae 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -9,6 +9,7 @@ import { resolveDesktopAutoUpdateConfig, } from './desktop-auto-update-environment.mjs' import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -193,11 +194,13 @@ export function parseDesktopPackageInvocation( * Build the electron-builder command arguments for one validated target. * @param target - Supported release target. * @param directory - Whether to stop at an unpacked application directory. + * @param artifact - Optional single artifact built from an existing signed application. * @returns Arguments that keep publishing under the separate validated upload command. */ export function desktopElectronBuilderArguments( target: DesktopPackageTarget, directory: boolean, + artifact?: DesktopPrepackagedArtifact, ): readonly string[] { return [ 'exec', @@ -205,10 +208,15 @@ export function desktopElectronBuilderArguments( '--config', 'electron-builder.config.mjs', target.builderPlatform, + ...(artifact === undefined ? [] : [artifact.format]), target.builderArch, '--publish', 'never', ...(directory ? ['--dir'] : []), + ...(artifact === undefined ? [] : [ + '--prepackaged', artifact.appPath, + '--config.directories.output', artifact.output, + ]), ] } @@ -278,7 +286,20 @@ async function main(): Promise { await runPnpm(['run', 'prepare:packages'], targetEnv) await runPnpm(['run', 'prepare:dsh'], targetEnv) if (invocation.prepareOnly) return - await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) + if (target.platform === 'darwin' && !invocation.directory) { + await runPnpm([ + ...desktopElectronBuilderArguments(target, true), + '--config.mac.notarize=false', + ], electronBuilderEnv) + await packageMacOSArtifacts({ + arch: target.arch, + version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), + artifactsRoot: buildPaths.artifacts, + environment: electronBuilderEnv, + }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)) + } else { + await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) + } if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) } diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts index e7ed5d5f6e..a5d0451092 100644 --- a/apps/desktop/scripts/verify-macos-signature.d.mts +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -41,6 +41,13 @@ export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvir */ export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void +/** + * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance. + * @param appPath - Path to the stapled `.app` directory. + * @param expected - Public release identity. + */ +export function verifyMacOSNotarizedApplication(appPath: string, expected: MacOSSigningEnvironment): void + /** * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. * @param diskImagePath - Path to the packaged `.dmg` file. diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index af0d6196ae..13d881f2fc 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -147,6 +147,18 @@ export function verifyMacOSSignature(appPath, expected) { assertMacOSSignatureDetails(details, expected) } +/** + * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance. + * @param {string} appPath - Path to the stapled `.app` directory. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSNotarizedApplication(appPath, expected) { + verifyMacOSSignature(appPath, expected) + runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', appPath], 'stapler validate') + runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], 'spctl') +} + /** * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. * @param {string} diskImagePath - Path to the packaged `.dmg` file. diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts new file mode 100644 index 0000000000..a8f961c3de --- /dev/null +++ b/apps/desktop/tests/package-macos.spec.ts @@ -0,0 +1,188 @@ +/** Exercise notarization overlap and artifact isolation without Apple credentials or network. */ + +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + packageMacOSArtifacts, + type DesktopPrepackagedArtifact, + type MacOSArtifactOperations, +} from '../scripts/package-macos.ts' +import { desktopElectronBuilderArguments, resolveDesktopPackageTarget } from '../scripts/package-target.ts' + +const environment = { + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_KEYCHAIN_PROFILE: 'fixture-profile', +} + +function barrier() { + let release!: () => void + const promise = new Promise((resolve) => { release = resolve }) + return { promise, release } +} + +async function fixture(arch: 'arm64' | 'x64' = 'arm64') { + const root = await mkdtemp(join(tmpdir(), 'desktop-parallel-notarization-')) + const artifactsRoot = join(root, 'artifacts') + const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') + await mkdir(appPath, { recursive: true }) + await writeFile(join(appPath, 'payload'), 'signed content') + const version = '1.2.3-alpha.1' + const base = `deepseek-harness-${version}-mac-${arch}` + const request = { arch, artifactsRoot, version, environment } + const apple: MacOSArtifactOperations = { + copyApp: async (source, destination) => { + await cp(source, destination, { recursive: true, verbatimSymlinks: true }) + }, + notarize: async ({ appPath: path }) => { await writeFile(join(path, 'ticket'), 'accepted') }, + verifySignature: vi.fn(), + verifyNotarization: vi.fn((path: string) => { + if (!existsSync(join(path, 'ticket'))) throw new Error('missing App ticket') + }), + } + const build = async (artifact: DesktopPrepackagedArtifact) => { + await mkdir(artifact.output, { recursive: true }) + const contents = JSON.stringify({ + payload: await readFile(join(artifact.appPath, 'payload'), 'utf8'), + appTicket: existsSync(join(artifact.appPath, 'ticket')), + }) + await writeFile(join(artifact.output, `${base}.${artifact.format}`), contents) + if (artifact.format === 'zip') { + await writeFile(join(artifact.output, `${base}.zip.blockmap`), 'blockmap') + await writeFile(join(artifact.output, 'alpha-mac.yml'), 'update metadata') + } + } + return { root, appPath, request, apple, build, base } +} + +describe('parallel macOS artifacts', () => { + it.each(['arm64', 'x64'] as const)('overlaps notarization on isolated %s copies and promotes only completed payloads', async (arch) => { + const f = await fixture(arch) + const appStarted = barrier() + const appAccepted = barrier() + const dmgCompleted = barrier() + const zipCompleted = barrier() + const starts: string[] = [] + const copies: string[] = [] + const operation = packageMacOSArtifacts(f.request, async (artifact) => { + starts.push(artifact.format) + if (artifact.format === 'dmg') await dmgCompleted.promise + await f.build(artifact) + if (artifact.format === 'zip') zipCompleted.release() + }, { + ...f.apple, + copyApp: async (source, destination) => { + copies.push(destination) + await f.apple.copyApp(source, destination) + }, + notarize: async (options) => { + starts.push('app') + appStarted.release() + await appAccepted.promise + await f.apple.notarize(options) + }, + }) + try { + await appStarted.promise + expect([...starts]).toEqual(['app', 'dmg']) + expect(new Set(copies).size).toBe(2) + expect(copies.every(path => path !== f.appPath)).toBe(true) + appAccepted.release() + await zipCompleted.promise + expect(existsSync(join(f.appPath, 'ticket'))).toBe(false) + expect(existsSync(join(f.request.artifactsRoot, `${f.base}.zip`))).toBe(false) + dmgCompleted.release() + await operation + expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.zip`), 'utf8'))) + .toEqual({ payload: 'signed content', appTicket: true }) + expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.dmg`), 'utf8'))) + .toEqual({ payload: 'signed content', appTicket: false }) + expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted') + expect((await readdir(f.root)).sort()).toEqual(['artifacts']) + expect(f.apple.verifySignature).toHaveBeenCalledTimes(2) + expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1) + } finally { + appAccepted.release() + dmgCompleted.release() + try { await operation } finally { await rm(f.root, { recursive: true, force: true }) } + } + }) + + it('collects both failures after both lanes release their copies and publishes neither payload', async () => { + const f = await fixture() + const appStarted = barrier() + const failApp = barrier() + const failDmg = barrier() + const appError = new Error('App rejected') + const dmgError = new Error('DMG rejected') + const released: string[] = [] + const outcome = packageMacOSArtifacts(f.request, async (artifact) => { + expect(artifact.format).toBe('dmg') + await failDmg.promise + expect(await readFile(join(artifact.appPath, 'payload'), 'utf8')).toBe('signed content') + released.push('dmg') + throw dmgError + }, { + ...f.apple, + notarize: async () => { + appStarted.release() + await failApp.promise + released.push('app') + throw appError + }, + }).catch((error: unknown) => error) + try { + await appStarted.promise + failApp.release() + failDmg.release() + const error = await outcome + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([appError, dmgError]) + expect(released.sort()).toEqual(['app', 'dmg']) + expect(await readdir(f.root)).toEqual(['artifacts']) + expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64']) + expect(existsSync(join(f.appPath, 'ticket'))).toBe(false) + } finally { + failApp.release() + failDmg.release() + await outcome + await rm(f.root, { recursive: true, force: true }) + } + }) + + it.each(['copy', 'signature', 'ticket', 'metadata'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => { + const f = await fixture() + try { + const apple: MacOSArtifactOperations = { + ...f.apple, + ...(failure === 'copy' ? { copyApp: async () => { throw new Error('copy failed') } } : {}), + ...(failure === 'signature' ? { verifySignature: () => { throw new Error('signature failed') } } : {}), + ...(failure === 'ticket' ? { verifyNotarization: () => { throw new Error('ticket failed') } } : {}), + } + await expect(packageMacOSArtifacts(f.request, async (artifact) => { + await f.build(artifact) + if (failure === 'metadata' && artifact.format === 'zip') { + await writeFile(join(artifact.output, 'alpha-mac.yml'), '') + } + }, apple)).rejects.toThrow() + expect(await readdir(f.root)).toEqual(['artifacts']) + expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64']) + } finally { await rm(f.root, { recursive: true, force: true }) } + }) + + it('passes the actual App and isolated output directory to each single-target builder', () => { + const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64') + for (const format of ['zip', 'dmg'] as const) { + const appPath = join('private build', format, 'DeepSeek Harness.app') + const output = join(dirname(appPath), 'artifacts') + expect(desktopElectronBuilderArguments(target, false, { format, appPath, output })).toEqual([ + 'exec', 'electron-builder', '--config', 'electron-builder.config.mjs', + '--mac', format, '--arm64', '--publish', 'never', + '--prepackaged', appPath, '--config.directories.output', output, + ]) + } + }) +}) From e07f41d5fd8ca172287fda0f923b4d1f69c592f3 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 9 Sep 2026 17:32:09 +0800 Subject: [PATCH 13/60] Revert #932 transactional Cordis reload changes Apply real git revert --no-commit operations in reverse order: acb0797a1421eab128a4bd03355e930c1a221fb0 af97714f2413a3e55f0f15ab1b283beb133b3f2d 2018b0b7e64b444bff82b62c9466f13adc4603bb 43170c027cfbf9155dad7a43c01ca39364d4aef2 a621e927d6b3763798c3929e28912d86c9576f2d Resolve the app-boot package move, preserve deleted files and frozen archived notes, retain the modern lockfile graph, and preserve independent later lifecycle and lazy-config changes. Do not revert 6ac7d72d: that merge also contains eight unrelated commits from the #936 dependency chain. This is the mechanical intermediate result. Its focused check exposed consumer and packaging incompatibilities. The following compatibility commit supplies those adaptations and reconciles the vendor ledger. --- package.json | 1 - packages/boot/app-boot/src/index.ts | 57 ++--- packages/boot/app-boot/tests/app-boot.spec.ts | 20 +- .../boot/app-boot/tests/config-reload.spec.ts | 242 ++---------------- .../boot/app-boot/tests/hmr-config.spec.ts | 213 --------------- .../host/directory-picker-auto/src/index.ts | 9 +- .../tests/loader-composition.spec.ts | 2 +- .../host/webserver/tests/webserver.spec.ts | 24 +- packages/typert/loader/tests/loader.spec.ts | 4 +- pnpm-workspace.yaml | 4 - scripts/verify-vendored-links.ts | 72 ------ vendor/README.md | 6 +- vendor/cordis/src/events.ts | 2 +- vendor/cordis/src/fiber.ts | 6 +- vendor/hmr/src/index.ts | 176 +------------ vendor/include/src/index.ts | 138 ++++------ vendor/loader/src/config/entry.ts | 198 +++----------- vendor/loader/src/config/group.ts | 81 ++---- vendor/loader/src/config/isolate.ts | 4 +- vendor/loader/src/config/tree.ts | 53 +--- vendor/loader/src/index.ts | 11 +- vendor/schemastery/package.json | 9 - 22 files changed, 206 insertions(+), 1126 deletions(-) delete mode 100644 packages/boot/app-boot/tests/hmr-config.spec.ts delete mode 100644 scripts/verify-vendored-links.ts diff --git a/package.json b/package.json index a09fa3f997..d5a5b1c41b 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,6 @@ "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", - "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index ef891cef0d..3698a092a8 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -780,9 +780,6 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * complete plugin set. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. - * @throws a labelled error after disposing the partial context — `host - * preparation failed` when `prepare` threw before any config-tree entry - * mounted, `plugin tree failed to load` afterwards. */ export async function boot( binName: string, @@ -792,45 +789,21 @@ export async function boot( bareModuleBaseUrl?: string, ): Promise { const ctx = new Context() - // Two failure labels: `prepare` runs before any config-tree entry mounts, - // so its failure is host setup, not the plugin tree. - let stage = 'host preparation failed' - try { - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - ctx.provide('dshHomePath', dshHomePath) - await ctx.plugin(Loader) - await prepare?.(ctx) - stage = 'plugin tree failed to load' - await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) - // A surface can finish and dispose the whole tree while startup is still - // in flight, before the last entry settles. The Loader service goes with - // it, and the activation audit describes a live tree — reading `ctx.loader` - // past this point would throw a TypeError over an app that exited exactly - // as asked. Transactional group updates settle - // lifecycle inside the mount, so the teardown can land before it returns; - // re-check after every await. - await ctx.get('loader')?.await() - if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) - return ctx - } catch (cause) { - // Root-fiber disposal contains cleanup failures per observer (Cordis - // fiber.ts hardening) and a repeated call returns the settled single-shot - // result, so this await cannot reject and replace `cause`. - await ctx.fiber.dispose() - const detail = cause instanceof Error ? cause.message : String(cause) - // The transactional Loader wraps a failing entry apply in one message per - // tree layer; every layer's message is folded into `detail` above, and the - // deepest cause is the plugin's own thrown error, whose stack names the - // real failure site — append it so the startup diagnostic preserves the - // original activation error instead of only the wrap chain. - let deepest: unknown = cause - while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause - const stack = deepest instanceof AggregateError - ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` - : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' - throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) - } + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) + await ctx.plugin(Loader) + await prepare?.(ctx) + await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) + await ctx.loader.await() + // A surface can finish and dispose the whole tree while that await is still + // pending: the TUI renders as soon as its own fiber starts, so an `/exit` + // typed before the last entry settles tears the context down under us. The + // Loader service goes with it, and the activation audit describes a live + // tree — reading `ctx.loader` here would throw a TypeError over an app that + // exited exactly as asked. + if (ctx.get('loader') === undefined) return ctx + await assertEntriesActivated(ctx, binName) + return ctx } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index fe373d7bf0..c878fbd3a2 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -729,22 +729,6 @@ describe('boot', () => { } }) - it('disposes partial host setup and labels non-Error preparation failures', async () => { - const dir = tmp() - const failure = 42 - let disposed = false - const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { - ctx.effect(() => () => { disposed = true }) - throw failure - }) - - await expect(task).rejects.toMatchObject({ - message: `${NAME}: host preparation failed: ${failure}`, - cause: failure, - }) - expect(disposed).toBe(true) - }) - it('exposes dshHomePath to Loader config expressions', async () => { const dir = tmp() const dshHome = join(dir, 'home') @@ -851,9 +835,9 @@ describe('boot', () => { const deepest = new Error('stackless deep failure') delete (deepest as { stack?: string }).stack await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { - throw new Error('wrapped setup failure', { cause: deepest }) + throw new Error('host preparation failed', { cause: deepest }) })).rejects.toThrow( - `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`, + `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, ) }) diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 386b537ce4..4486c66ba8 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -1,7 +1,12 @@ /** - * Transactional config replacement through the booted Include and Loader tree. - * HMR contains rejected refreshes; direct callers receive the error after the - * previous generation has been retained or restored. + * Config hot-reload resilience of the booted include tree. `dsh-app-boot` + * installs a fail-loud unhandled-rejection handler, so a `refresh()` that + * rethrows a config-file parse error would kill a live app on one bad + * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event + * callback nobody else catches). These tests pin the vendored + * `@cordisjs/plugin-include` contract that boot relies on: an invalid file + * keeps the last good tree, and a valid re-read re-applies overlay patches + * exactly like the initial load. */ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -27,11 +32,10 @@ interface TreeFixture { include: Include } -async function bootTree(configBody: string, files: Record = {}): Promise { +async function bootTree(configBody: string): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) - for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) const ctx = await boot(NAME, join(dir, 'cordis.yml')) const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) @@ -43,41 +47,20 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } -function entryById(ctx: Context, id: string) { - const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id) - if (!entry) throw new Error(`missing loader entry ${id}`) - return entry -} - -function plugin(name: string, body = ''): string { - return `export default function ${name}(_ctx, config = {}) { ${body} }\n` -} - -async function expectUpdateFailure(task: Promise, stage: string): Promise { - try { - await task - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain(`failed to ${stage} loader entry`) - return - } - throw new Error(`expected loader update to fail during ${stage}`) -} - describe('include refresh with an invalid file', () => { - it('rejects while keeping the last good tree, then applies the next valid edit', async () => { + it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).rejects.toThrow('failed to parse config file') + await expect(include.refresh()).resolves.toBeUndefined() expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) // An empty file parses to `undefined` without a YAML error; it must be // treated exactly like a parse failure, not crash the entry walk. writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).rejects.toThrow('failed to validate config file') + await expect(include.refresh()).resolves.toBeUndefined() expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') @@ -90,201 +73,6 @@ describe('include refresh with an invalid file', () => { }) }) -describe('loader entry replacement', () => { - it('imports a changed name before replacing the running plugin', async () => { - const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { - 'old.mjs': plugin('oldPlugin'), - 'new.mjs': plugin('newPlugin'), - }) - try { - const entry = entryById(ctx, 'target') - await entry.update({ name: './new.mjs' }) - expect(entry.options.name).toBe('./new.mjs') - expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) - expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin') - expect(entry.options.disabled).toBeUndefined() - await entry.fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('retains the running plugin when the replacement cannot be imported', async () => { - const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { - 'old.mjs': plugin('oldPlugin'), - }) - try { - const entry = entryById(ctx, 'target') - const fiber = entry.fiber - await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import') - expect(entry.options.name).toBe('./old.mjs') - expect(entry.fiber === fiber).toBe(true) - await fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('restores the previous plugin after replacement application fails', async () => { - const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { - 'old.mjs': plugin('oldPlugin'), - 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), - }) - try { - const entry = entryById(ctx, 'target') - const previous = entry.fiber - await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply') - expect(entry.options.name).toBe('./old.mjs') - expect(entry.fiber === previous).toBe(false) - expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin') - expect(entry.options.disabled).toBeUndefined() - await entry.fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('restores the previous config when an in-place restart fails', async () => { - const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { - 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), - }) - try { - const entry = entryById(ctx, 'target') - const fiber = entry.fiber - await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply') - expect(entry.options.config).toEqual({ fail: false }) - expect(entry.fiber === fiber).toBe(true) - await fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('does not persist a failed direct fiber update', async () => { - const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { - 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), - }) - try { - const entry = entryById(ctx, 'target') - const fiber = entry.fiber - if (!fiber) throw new Error('target entry has no fiber') - await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed') - expect(entry.options.config).toEqual({ fail: false }) - expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) - } finally { - await ctx.fiber.dispose() - } - }) -}) - -describe('loader tree replacement', () => { - it('rolls back earlier updates and additions when a later entry fails', async () => { - const { ctx, dir, include } = await bootTree([ - '- id: existing', - ' name: ./configurable.mjs', - ' config:', - ' value: old', - '', - ].join('\n'), { - 'configurable.mjs': plugin('configurablePlugin'), - 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), - }) - try { - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: existing', - ' name: ./configurable.mjs', - ' config:', - ' value: candidate', - '- id: added', - ' name: ./noop.mjs', - '- id: bad', - ' name: ./bad.mjs', - '', - ].join('\n')) - await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad') - expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false) - - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: existing', - ' name: ./configurable.mjs', - ' config:', - ' value: committed', - '- id: added', - ' name: ./noop.mjs', - '', - ].join('\n')) - await include.refresh() - expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' }) - expect(entryById(ctx, 'added').fiber).toBeDefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { - // No manual builtin registration: `boot()` supplies `cordis:group` beside - // `cordis:include`, which is what lets a composition give one `isolate` - // realm to a provider and its consumers together. - const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') - try { - const config = (disabled: boolean) => [ - '- id: parent', - ' name: cordis:group', - ' group: true', - ` disabled: ${disabled}`, - ' config:', - ' - id: child', - ' name: ./noop.mjs', - '', - ].join('\n') - - writeFileSync(join(dir, 'cordis.yml'), config(false)) - await include.refresh() - expect(entryById(ctx, 'child').fiber).toBeDefined() - - writeFileSync(join(dir, 'cordis.yml'), config(true)) - await include.refresh() - expect(entryById(ctx, 'child').fiber).toBeUndefined() - - writeFileSync(join(dir, 'cordis.yml'), config(false)) - await include.refresh() - expect(entryById(ctx, 'child').fiber).toBeDefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('restores a programmatic entry move when its update fails', async () => { - const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { - 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), - }) - try { - const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) - const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) - const target = entryById(ctx, targetId) - const source = target.parent - const sourceIndex = source.data.indexOf(target.options) - const destination = entryById(ctx, groupId).subgroup - if (!destination) throw new Error('created loader group has no subgroup') - - await expectUpdateFailure( - ctx.loader.update(targetId, { config: { fail: true } }, groupId), - 'apply', - ) - - expect(target.parent).toBe(source) - expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx) - expect(source.data.indexOf(target.options)).toBe(sourceIndex) - expect(destination.data).not.toContain(target.options) - expect(target.options.config).toEqual({ fail: false }) - } finally { - await ctx.fiber.dispose() - } - }) -}) - describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) @@ -335,9 +123,9 @@ describe('include refresh with overlay patches', () => { await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - // Omitting the patch list must remove the overlay rather than reuse the - // Include's previous config through a default parameter. - await entry.update({ config: { path: './base.yml' } }) + // Removing every patch must revert to the file's own values: patching + // may not bake earlier patch results into the cached parse. + await entry.update({ config: { path: './base.yml', patches: [] } }) await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) } finally { diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts deleted file mode 100644 index 6487ad0695..0000000000 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' -import { realpath } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Hmr from '@deepseek-ai/cordis-plugin-hmr' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Timer from '@deepseek-ai/cordis-plugin-timer' -import { afterEach, describe, expect, it, vi } from 'vitest' - -/** Every per-test tree root, removed once the booted watcher has been disposed. */ -const hmrRoots: string[] = [] - -async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(dir).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(Timer) - await ctx.plugin(Hmr, { - root, - ignored: [], - debounce: 0, - ...usePolling === undefined ? {} : { usePolling }, - }) - return ctx -} - -async function eventually(test: () => boolean, message: string): Promise { - const deadline = Date.now() + 10_000 - while (!test()) { - if (Date.now() >= deadline) throw new Error(message) - await new Promise(resolve => setTimeout(resolve, 10)) - } -} - -describe('HMR exact config paths', () => { - afterEach(() => { - for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) - }) - - it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { - const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) - const alias = `${target}-alias` - const aliasFilename = join(alias, 'module.ts') - symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') - writeFileSync(aliasFilename, 'export const generation = 0\n') - // This acceptance owns alias-to-cache identity. Other cases below exercise - // native events; polling keeps Windows fs.watch queue pressure out of it. - const ctx = await bootHmr(alias, ['.'], true) - const filename = join(await realpath(target), 'module.ts') - const expected = pathToFileURL(filename).href - const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) - const observed: string[] = [] - ctx.on('hmr/change', (url) => { observed.push(url) }) - try { - const deadline = Date.now() + 20_000 - for (let generation = 1; !observed.includes(expected); generation += 1) { - if (Date.now() >= deadline) { - throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`) - } - // The watch base, not the writer spelling, is the alias under test. - // Grow the file on every write: polling must not depend on timestamp - // precision when several generations land inside one filesystem tick. - writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`) - // Leave Chokidar's atomic-write window idle so one coalesced change can publish. - await new Promise(resolve => setTimeout(resolve, 250)) - } - expect(cacheHas).toHaveBeenCalledWith(expected) - } finally { - await ctx.fiber.dispose() - unlinkSync(alias) - rmSync(target, { recursive: true, force: true }) - } - }) - - it('collapses filesystem aliases before registering an exact watch', async () => { - const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) - const alias = `${target}-alias` - symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') - const ctx = await bootHmr(alias) - try { - await ctx.hmr.registerConfig('plugins.yml', () => {}) - await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {})) - .rejects.toThrow('config path already registered') - } finally { - await ctx.fiber.dispose() - unlinkSync(alias) - rmSync(target, { recursive: true, force: true }) - } - }) - - it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(dir) - const filename = join(dir, 'plugins.yml') - const ctx = await bootHmr(dir) - const observed: string[] = [] - try { - await ctx.hmr.registerConfig(filename, () => { - try { - observed.push(readFileSync(filename, 'utf8')) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - observed.push('missing') - } - }) - - writeFileSync(filename, 'one', { flag: 'wx' }) - await eventually(() => observed.includes('one'), 'HMR did not observe config creation') - writeFileSync(filename, 'two') - await eventually(() => observed.includes('two'), 'HMR did not observe config change') - unlinkSync(filename) - await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') - } finally { - await ctx.fiber.dispose() - } - }) - - it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { - const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(root) - const dir = join(root, 'later') - const filename = join(dir, 'plugins.yml') - const ctx = await bootHmr(root) - const observed: string[] = [] - try { - await ctx.hmr.registerConfig(filename, () => { - observed.push(readFileSync(filename, 'utf8')) - }) - mkdirSync(dir) - writeFileSync(filename, 'created') - await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') - } finally { - await ctx.fiber.dispose() - } - }) - - it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(dir) - const filename = join(dir, 'plugins.yml') - writeFileSync(filename, 'one') - const ctx = await bootHmr(dir) - const started = Promise.withResolvers() - const release = Promise.withResolvers() - const observed: string[] = [] - let active = 0 - let maxActive = 0 - try { - const dispose = await ctx.hmr.registerConfig(filename, async () => { - active += 1 - maxActive = Math.max(maxActive, active) - observed.push(readFileSync(filename, 'utf8')) - if (observed.length === 1) { - started.resolve(undefined) - await release.promise - } - active -= 1 - }) - await started.promise - writeFileSync(filename, 'two') - // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond - // that window so this edit is queued before registration disposal. - await new Promise(resolve => setTimeout(resolve, 250)) - - let disposed = false - const disposal = dispose().then(() => { disposed = true }) - await Promise.resolve() - expect(disposed).toBe(false) - release.resolve(undefined) - await disposal - expect(maxActive).toBe(1) - expect(observed).toEqual(['one', 'two']) - } finally { - release.resolve(undefined) - await ctx.fiber.dispose() - } - }) - - it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(dir) - const filename = join(dir, 'plugins.yml') - const ctx = await bootHmr(dir) - const failure = Promise.withResolvers<{ filename: string; error: Error }>() - let failureCount = 0 - try { - ctx.on('hmr/config-update-failed', () => { - throw new Error('observer failed') - }) - ctx.on('hmr/config-update-failed', (failedFilename, error) => { - failureCount += 1 - failure.resolve({ filename: failedFilename, error }) - }) - await ctx.hmr.registerConfig(filename, () => { throw 42 }) - writeFileSync(filename, 'invalid') - - const observed = await failure.promise - expect(observed.filename).toBe(filename) - expect(observed.error).toBeInstanceOf(Error) - expect(observed.error.message).toBe('42') - - // Let Chokidar's atomic-write window close before requiring a distinct - // second notification from the same path. - await new Promise(resolve => setTimeout(resolve, 250)) - writeFileSync(filename, 'invalid again') - await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index f384828125..a95e981ad8 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -77,10 +77,11 @@ export async function apply(ctx: Context): Promise { for (const id of [...ids].reverse()) { // Tree teardown (group.stop) can have removed the entry already; // nothing is left to unmount or await then. - if (ctx.loader.store[id] === undefined) continue - // remove() disposes the entry transactionally, so the chooser's unload - // signals completion only after that face quiesced. - await ctx.loader.remove(id) + const entry = ctx.loader.store[id] + if (entry === undefined) continue + const fiber = entry.fiber + ctx.loader.remove(id) + await fiber?.dispose() } } try { diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 49bf319b0c..77ad4b549d 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -259,7 +259,7 @@ describe('real Loader composition', () => { const { ctx, configPath } = await loadComposition('127.0.0.1') const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! - await ctx.loader.remove(backendEntry.id) + ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index b7ac506b17..04bbe9b4cf 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, FiberState } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer, { renderIndexInjections } from '../src/index.ts' @@ -361,17 +361,25 @@ describe('real Loader composition', () => { const firstRoot = root root = undefined // keep the first composition's files until the end + // loader.await() never rejects (allSettled); the bind failure surfaces as + // a FAILED fiber whose error escapes as a late rejection — the shape the + // boot's installFailLoud is contracted to catch. Capture it here the same + // way, and assert it really is the bind error. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) let second: Context | undefined try { - let failure: unknown - try { - await loadComposition(takenPort) - } catch (error) { - failure = error + second = await loadComposition(takenPort) + const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') + expect(entry?.fiber?.state).toBe(FiberState.FAILED) + // The rejection escapes a tick after loader.await() settles; bounded poll. + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) } - second = context - expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/) + expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') } finally { + process.off('unhandledRejection', onUnhandled) await second?.fiber.dispose() context = first if (root !== undefined) await rm(root, { recursive: true, force: true }) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 2f4b4cae04..979b5b535b 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -212,12 +212,12 @@ describe('typert loader', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.list()).toHaveLength(1) - await ctx.loader.remove(id) + ctx.loader.remove(id) await ctx.loader.await() // The unmount reconciliation rides a queued microtask flush. await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() - await ctx.loader.remove(plainId) + ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68eff9cd1c..cde062a553 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,10 +14,6 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime -# Vendored framework packages keep their upstream semver ranges, while local -# builds must resolve those matching names to this workspace's pinned sources. -linkWorkspacePackages: true - overrides: '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' '@deepseek-ai/schemastery': 'link:vendor/schemastery' diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts deleted file mode 100644 index 93b8390157..0000000000 --- a/scripts/verify-vendored-links.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Verify that pnpm-lock.yaml resolves every vendored package name to its - * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` - * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the - * pinned vendored sources; a registry copy of the same name coexisting with - * the vendored one silently forks the framework layer (vendor/README.md). - */ -import { readdir, readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import * as yaml from 'js-yaml' - -const root = resolve(import.meta.dirname, '..') - -async function vendoredNames(): Promise> { - const names = new Set() - for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { - if (!entry.isDirectory()) continue - let manifest: { name?: string } - try { - manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } - } catch { - continue // not a package directory (e.g. vendor/README.md siblings) - } - if (manifest.name !== undefined) names.add(manifest.name) - } - return names -} - -interface Lockfile { - importers?: Record> - packages?: Record - snapshots?: Record -} - -const names = await vendoredNames() -if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') -const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile - -const violations: string[] = [] - -// Importer resolutions: every dependency entry naming a vendored package must -// resolve to a link:, or the build silently uses a registry copy. -for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { - for (const [section, dependencies] of Object.entries(sections)) { - if (typeof dependencies !== 'object' || dependencies === null) continue - for (const [dependency, entry] of Object.entries(dependencies as Record)) { - if (!names.has(dependency)) continue - const version = entry.version ?? '' - if (!version.startsWith('link:')) { - violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) - } - } - } -} - -// Package/snapshot keys: a registry copy materializes as a `@` -// key; vendored names must never appear there at all. -for (const section of ['packages', 'snapshots'] as const) { - for (const key of Object.keys(lockfile[section] ?? {})) { - const atIndex = key.lastIndexOf('@') - if (atIndex <= 0) continue - const packageName = key.slice(0, atIndex) - if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) - } -} - -if (violations.length > 0) { - console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) - for (const violation of violations) console.error(` - ${violation}`) - process.exit(1) -} -console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/vendor/README.md b/vendor/README.md index 4cfbe892de..47b3093b76 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). @@ -35,9 +35,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. +6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially and undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index e9bd85e3e1..c7bf42632e 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -340,7 +340,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 38a3197e29..6bb1caa7a5 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -730,8 +730,8 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) { this.assertActive() @@ -745,7 +745,7 @@ export class Fiber { return } config = this._resolveConfig(config) - return this.context.waterfall(this, 'internal/update', config, noSave, () => { + this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index f79d8344dc..ccd399622a 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -3,8 +3,8 @@ import type { Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import type { Include } from '@deepseek-ai/cordis-plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' -import { dirname, relative, resolve } from 'node:path' -import { realpath, stat } from 'node:fs/promises' +import { relative, resolve } from 'node:path' +import { realpath } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@deepseek-ai/cordis-plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -20,13 +20,7 @@ declare module '@deepseek-ai/cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void - /** - * A watched config-file refresh failed. - * @param filename - Absolute path observed by HMR. - * @param error - Normalized refresh failure. - * @mode parallel - */ - 'hmr/config-update-failed'(filename: string, error: Error): Promise | void + } } @@ -52,37 +46,6 @@ interface Reload { runtime?: Plugin.Runtime } -interface ConfigRefresh { - dirty: boolean - running?: Promise -} - -interface ConfigRegistration { - watcher: FSWatcher -} - -async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { - let root = dirname(filename) - let depth = 0 - while (true) { - try { - if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) - const canonicalRoot = await realpath(root) - return { - filename: resolve(canonicalRoot, relative(root, filename)), - root: canonicalRoot, - depth, - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const parent = dirname(root) - if (parent === root) throw error - root = parent - depth += 1 - } - } -} - class Hmr extends Service { static inject = ['loader', 'timer'] @@ -90,9 +53,6 @@ class Hmr extends Service { private internal: ModuleLoader private watcher!: FSWatcher - private readonly configs = new Map() - private readonly configRefreshes = new WeakMap() - private readonly refreshTasks = new Set>() /** * Changes from externals will always trigger a full reload. @@ -124,68 +84,6 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } - /** - * Watch one exact config path outside the configured module roots. - * @param filename - Config path, resolved against the HMR base directory. - * @param refresh - Refresh callback run serially on add, change, or unlink. - * @returns an asynchronous disposer once the exact watch is ready. - * @throws when HMR is inactive, the path is already registered, or watcher startup fails. - */ - async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { - if (!this.watcher) throw new Error('HMR is not active') - filename = resolve(this.baseDir, filename) - const target = await findWatchRoot(filename) - const watchFilename = target.filename - if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`) - - const { root, depth } = target - const watcher = watch(root, { - ...this.config, - cwd: undefined, - depth, - ignored: undefined, - ignoreInitial: false, - }) - const registration = { watcher } - this.configs.set(watchFilename, registration) - const onChange = (path: string) => { - const observed = resolve(path) - if (observed !== filename && observed !== watchFilename) return - this.refreshConfig(registration, filename, refresh) - } - watcher.on('add', onChange) - watcher.on('change', onChange) - watcher.on('unlink', onChange) - - const ready = Promise.withResolvers() - let readyState: 'pending' | 'resolved' | 'rejected' = 'pending' - watcher.once('ready', () => { - readyState = 'resolved' - ready.resolve() - }) - watcher.on('error', (error) => { - if (readyState === 'pending') { - readyState = 'rejected' - ready.reject(error) - } else { - this.ctx.logger.warn(error) - } - }) - - try { - await ready.promise - return this.ctx.effect(() => async () => { - if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename) - await watcher.close() - await this.configRefreshes.get(registration)?.running - }, 'hmr.registerConfig()') - } catch (error) { - this.configs.delete(watchFilename) - await watcher.close() - throw error - } - } - /** * Resolve a module specifier to a URL, compatible with Node 22-24. */ @@ -197,12 +95,7 @@ class Hmr extends Service { } async* [Service.init]() { - yield async () => { - await this.watcher?.close() - await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close())) - this.configs.clear() - await Promise.allSettled([...this.refreshTasks]) - } + yield () => this.watcher?.close() const { loader } = this.ctx const { root, ignored } = this.config @@ -229,31 +122,15 @@ class Hmr extends Service { ...this.config, cwd: watchBaseDir, ignored: path => match(relative(watchBaseDir, path)), - // The initial scan re-announces files the boot just consumed: an `add` - // for a config file refreshes an include whose initial apply may still - // be in flight, and a failing apply then rolls this plugin back while - // the scan-triggered refresh waits on that apply — a teardown deadlock - // that strands boot without a diagnostic. Only events after the scan - // matter here; `registerConfig` keeps its own initial scan because a - // user patch layer present at registration must apply once. ignoreInitial: true, }) const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) - const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { - this.ctx.logger.debug('%s detected at %C', kind, path) + this.watcher.on('change', async (path) => { + this.ctx.logger.debug('change detected at %C', path) const filename = resolve(watchBaseDir, path) const configuredFilename = resolve(this.baseDir, path) - // Config reload: the file is a loader config file (e.g. cordis.yml). - for (const entry of loader.entries()) { - const include = entry.subtree as Include | undefined - if (include?.filename !== filename && include?.filename !== configuredFilename) continue - this.refreshConfig(include, include.filename, () => include.refresh()) - return - } - - if (kind !== 'change') return const url = pathToFileURL(filename).href // Full reload: the changed file is part of the framework @@ -267,11 +144,15 @@ class Hmr extends Service { return partialReload() } + for (const entry of loader.entries()) { + const include = entry.subtree as Include | undefined + if (include?.filename !== filename && include?.filename !== configuredFilename) continue + await include.refresh() + return + } + this.ctx.emit('hmr/change', url) - } - this.watcher.on('add', path => onChange('add', path)) - this.watcher.on('change', path => onChange('change', path)) - this.watcher.on('unlink', path => onChange('unlink', path)) + }) const ready = Promise.withResolvers() let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending' @@ -294,35 +175,6 @@ class Hmr extends Service { await ready.promise } - private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { - const state = this.configRefreshes.get(key) ?? { dirty: false } - this.configRefreshes.set(key, state) - state.dirty = true - if (state.running) return - const task = (async () => { - do { - state.dirty = false - try { - await refresh() - } catch (reason) { - const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) - this.ctx.logger.warn('config reload at %C failed', filename) - this.ctx.logger.warn(error) - try { - await this.ctx.parallel('hmr/config-update-failed', filename, error) - } catch (rejection) { - this.ctx.logger.warn(rejection) - } - } - } while (state.dirty) - })().finally(() => { - state.running = undefined - this.refreshTasks.delete(task) - }) - state.running = task - this.refreshTasks.add(task) - } - // hide stack trace from HMR getOuterStack = (): string[] => [ // ' at HMR.partialReload ()', diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 5ee20ef43e..cd7406bbab 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -44,8 +44,7 @@ function retryableWriteError(error: unknown): boolean { * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated and the result is always detached from it (even with no - * patches): patching or mounting shared entry objects would bake earlier + * is never mutated: patching shared entry objects would bake earlier patch * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -60,8 +59,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { + if (!patches?.length) return [...data] data = structuredClone(data) - if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -127,20 +126,6 @@ export function applyEntryPatches( return data } -type ConfigUpdateStage = 'read' | 'parse' | 'validate' - -interface ReadCandidate { - content: string - data: EntryOptions[] -} - -class ConfigFileError extends Error { - constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) { - super(`failed to ${stage} config file ${path}`, { cause }) - this.name = 'ConfigFileError' - } -} - /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -189,7 +174,6 @@ export class Include extends EntryTree { private writeTask?: NodeJS.Timeout | undefined private pendingWrite?: EntryOptions[] private writeQueue: Promise = Promise.resolve() - private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { super(ctx) @@ -203,31 +187,20 @@ export class Include extends EntryTree { this.readonly = !this.type this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href - ctx.on('internal/update', async (config, _, next) => { + ctx.on('internal/update', (config, _, next) => { if (config.path !== this.config.path) return next() - await this.enqueue(async () => { - const data = this.applyPatches(this.data!, config.patches) - await this.root.update(data) - this.config = config + // Veto the fiber restart (children update in place), but persist the new + // config ourselves — `Fiber.update` only assigns `this.config` behind + // `next()`, and a stale `this.config.patches` would make the next + // `refresh()` re-apply the old overlay. + this.config = config + this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { + this.ctx.logger.warn('config update at %C failed', this.filename) + this.ctx.logger.warn(error) }) }) } - /** - * Serialize one child-tree mutation behind every earlier one. The group's - * transactional `update` is not reentrant: two concurrent applies (the init - * apply racing an HMR-triggered refresh from the watcher's initial scan) - * interleave create and rollback on the same entries and strand the include - * fiber without settling, so every apply path funnels through this queue. - * A predecessor's failure is its own caller's outcome and never gates the - * next task. - */ - private enqueue(task: () => Promise): Promise { - const run = this.applyQueue.then(task, task) - this.applyQueue = run.then(() => {}, () => {}) - return run - } - private async checkAccess() { if (!this.type) return try { @@ -237,55 +210,56 @@ export class Include extends EntryTree { } } - private async read(forced = false): Promise { - let content: string - try { - content = await readFile(this.filename, 'utf8') - } catch (error) { - throw new ConfigFileError('read', this.filename, error) - } - if (!forced && this.content === content) return + private async read(forced = false) { + const content = await readFile(this.filename, 'utf8') + if (!forced && this.content === content) return false let data: any - try { - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module - } - } catch (error) { - throw new ConfigFileError('parse', this.filename, error) + if (this.type === 'application/yaml') { + data = yaml.load(content, { schema: entryListSchema }) + } else if (this.type === 'application/json') { + data = JSON.parse(content) + } else { + const module = await import(/* @vite-ignore */ this.filename) + data = module.default || module } + // An empty or truncated file (common mid-edit: editors and `sed -i` write + // through temp states) parses to `undefined`, not an error; reject every + // non-array shape here so callers see one "invalid file" signal. Content + // and data commit only on success, so an edit that is later reverted to + // the exact last good content correctly reads as "unchanged". if (!Array.isArray(data)) { - throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) + throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) } - return { content, data } + this.content = content + this.data = data + await this.checkAccess() + return true } - private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] { + private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { return applyEntryPatches(data, patches, (message, ...args) => { this.ctx.root.logger?.('loader').warn(message, ...args) }) } async* [Service.init]() { - let candidate: ReadCandidate try { - candidate = (await this.read(true))! + await this.read() } catch (error) { - if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error + // Only a missing file falls back to `initial` (or the not-found error): + // an existing-but-invalid file must fail loud with its real parse error, + // never be mislabelled as absent or silently overwritten. + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error if (this.config.initial) { - await this._writeFile(this.config.initial as any) - candidate = (await this.read(true))! + this.writeFile(this.config.initial as any) + await this.read() } else { throw new Error(`config file not found: ${this.filename}`) } } yield () => this.stop() - await this.apply(candidate) + await this.root.update(this.applyPatches(this.data!)) } async stop() { @@ -294,30 +268,18 @@ export class Include extends EntryTree { } /** - * Re-read the file and transactionally refresh child entries when content changed. - * @returns a promise resolving after the new tree commits, or immediately when unchanged. - * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. + * Re-read the file and refresh child entries when content changed. An + * unreadable or unparsable file logs a warning and keeps the last good + * tree: a hot-reload of a live app must never take the process down. */ async refresh() { - // Read inside the queue so the changed-content check compares against the - // predecessor's committed state, not a mid-apply snapshot. - await this.enqueue(async () => { - const candidate = await this.read() - if (!candidate) return - await this._apply(candidate) - }) - } - - private apply(candidate: ReadCandidate) { - return this.enqueue(() => this._apply(candidate)) - } - - private async _apply(candidate: ReadCandidate) { - const data = this.applyPatches(candidate.data, this.config.patches) - await this.root.update(data) - this.content = candidate.content - this.data = candidate.data - await this.checkAccess() + try { + if (!await this.read()) return + await this.root.update(this.applyPatches(this.data!)) + } catch (error) { + this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) + this.ctx.logger.warn(error) + } } private async _writeFile(config: EntryOptions[]) { diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 19dc11a40a..7cf2520af3 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -21,11 +21,6 @@ export interface EntryOptions { inject?: Inject | null } -function updateError(stage: 'import' | 'dispose' | 'apply' | 'rollback', options: EntryOptions, cause: unknown) { - const detail = cause instanceof Error ? cause.message : String(cause) - return new Error(`failed to ${stage} loader entry ${options.id} (${options.name}): ${detail}`, { cause }) -} - function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { @@ -43,11 +38,6 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } -function replaceKeys(target: T, source: T): T { - for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) - return Object.assign(target, source) -} - /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') @@ -61,7 +51,6 @@ export class Entry { public subtree?: EntryTree _initTask?: Promise - _disposing = 0 constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -82,18 +71,13 @@ export class Entry { /** True when this entry or any owning parent entry is disabled. */ get disabled() { - return this._disabled(this.options) - } - - private _disabled(options: EntryOptions) { // group is always enabled - if (options.group) return false - if (this.disabledOf(options)) return true - let entry = this.parent.ctx.fiber.entry - while (entry) { + if (this.options.group) return false + let entry: Entry | undefined = this + do { if (this.disabledOf(entry.options)) return true entry = entry.parent.ctx.fiber.entry - } + } while (entry) return false } @@ -111,12 +95,12 @@ export class Entry { return evaluate(this.ctx, expr) } - private async _patchContext(diff: string[]) { - await this.context.waterfall('loader/patch-context', this, async () => { + private _patchContext(diff: string[]) { + this.context.waterfall('loader/patch-context', this, () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - await this.fiber.update(this.options.config, true) + this.fiber.update(this.options.config, true) } }) } @@ -127,122 +111,41 @@ export class Entry { await this.init() } - async _dispose(fiber = this.fiber) { - if (!fiber) return - if (this.fiber === fiber) this.fiber = undefined - this._disposing += 1 - try { - await fiber.dispose() - } finally { - this._disposing -= 1 - } - } - /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { - const previousOptions = this.options - const legacy = { ...previousOptions } - const candidate = create ? options as EntryOptions : { ...previousOptions } - if (!create) { + const legacy = { ...this.options } + + // step 1: update options + if (create) { + this.options = options as EntryOptions + } else { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { - delete candidate[key as keyof EntryOptions] + delete this.options[key] } else { - candidate[key as keyof EntryOptions] = value as never + this.options[key] = value } } } - sortKeys(candidate) + sortKeys(this.options) - const diff = Object - .keys({ ...candidate, ...legacy }) - .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions])) - if (!diff.length && !force) return - - const commit = () => { - if (create) return - this.options = replaceKeys(previousOptions, candidate) - } - - const previous = this.fiber - if (!previous?.uid) { - this.fiber = undefined - this.options = candidate - try { - if (!this._disabled(candidate)) await this.init() - } catch (error) { - this.options = previousOptions - throw error - } - commit() + // step 2: execute + if (this.disabled) { + this.fiber?.dispose() return } - if (this._disabled(candidate)) { - this.options = candidate - try { - await this._dispose(previous) - } catch (error) { - this.options = previousOptions - throw updateError('dispose', candidate, error) - } - commit() + // step 3: check if options are changed + if (this.fiber?.uid) { + const diff = Object + .keys({ ...this.options, ...legacy }) + .filter(key => !deepEqual(this.options[key], legacy[key])) + if (!diff.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) - return + this._patchContext(diff) + } else { + await this.init() } - - const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group') - if (!replace) { - this.options = candidate - try { - await this._patchContext(diff) - } catch (error) { - this.options = previousOptions - try { - await this._patchContext(diff) - } catch (rollbackError) { - throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) - } - this.context.emit('loader/partial-dispose', this, candidate, true) - throw updateError('apply', candidate, error) - } - commit() - this.context.emit('loader/partial-dispose', this, legacy, true) - return - } - - let plugin: any - try { - plugin = diff.includes('name') - ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack)) - : previous.runtime!.callback - } catch (error) { - throw updateError('import', candidate, error) - } - - const previousPlugin = previous.runtime!.callback - this.options = candidate - try { - await this._dispose(previous) - } catch (error) { - this.options = previousOptions - throw updateError('dispose', candidate, error) - } - - try { - await this._start(plugin) - } catch (error) { - this.options = previousOptions - try { - await this._start(previousPlugin) - } catch (rollbackError) { - throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) - } - this.context.emit('loader/partial-dispose', this, candidate, true) - throw updateError('apply', candidate, error) - } - commit() - this.context.emit('loader/partial-dispose', this, legacy, true) } getOuterStack = () => { @@ -261,43 +164,26 @@ export class Entry { await (this._initTask ??= this._init()) } finally { this._initTask = undefined - if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) - } - await this._await() - } - - async _await() { - try { - await this.fiber?.await() - } catch (error) { - throw updateError('apply', this.options, error) } + this.fiber?.await().finally(() => { + if (this.loader.getTasks().length) return + this.ctx.reflect.notify(['loader']) + }) } private async _init() { - let plugin: any + let exports: any try { - plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) + exports = await this.parent.tree.import(this.options.name, this.getOuterStack) } catch (error) { - throw updateError('import', this.options, error) - } - try { - await this._start(plugin) - } catch (error) { - throw updateError('apply', this.options, error) - } - } - - private async _start(plugin: any) { - let fiber: Fiber | undefined - try { - await this._patchContext([]) - this.loader.showLog(this, 'apply') - fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) - await fiber.await() - } catch (error) { - await this._dispose(fiber) - throw error + this.ctx.logger.error(error) + return + } finally { + this._initTask = undefined } + const plugin = this.loader.unwrapExports(exports) + this._patchContext([]) + this.loader.showLog(this, 'apply') + this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index 2e322b1e26..15d4d49e4b 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -19,23 +19,12 @@ export class EntryGroup { async create(options: Omit) { const id = this.tree.ensureId(options) - const existing = this.tree.store[id] - const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader)) - const previousParent = entry.parent + const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader) // Entry may be moved from another group, // so we need to update the parent reference. entry.parent = this // Use `create: true` to replace existing entry.options. - try { - await entry.update(options, true, true) - } catch (error) { - if (existing) { - entry.parent = previousParent - } else { - delete this.tree.store[id] - } - throw error - } + await entry.update(options, true, true) return entry.id } @@ -45,10 +34,10 @@ export class EntryGroup { if (index >= 0) config.splice(index, 1) } - async remove(id: string, isDispose = false) { + remove(id: string, isDispose = false) { const entry = this.tree.store[id] if (!entry) return - await entry._dispose() + entry.fiber?.dispose() if (!isDispose) { this.unlink(entry.options) } @@ -58,56 +47,26 @@ export class EntryGroup { async update(config: EntryOptions[]) { const oldConfig = this.data as EntryOptions[] - const seen = new Set() - for (const options of config) { - const id = this.tree.ensureId(options) - if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`) - seen.add(id) - } + this.data = config const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) - const newMap = Object.fromEntries(config.map(options => [options.id, options])) + const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options])) - try { - const outcomes = await Promise.allSettled(config.map(options => this.create(options))) - // Disposal owns termination: sibling starts can still be settling after - // the containing tree has gone away, but their failures no longer - // describe a live update to roll back. - if (this.ctx.fiber.uid === null) return - const failures = outcomes - .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') - .map(outcome => outcome.reason) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply') - for (const id of Object.keys(oldMap)) { - if (!newMap[id]) await this.remove(id, true) + // update inner plugins + const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[] + await Promise.all(ids.map(async (id) => { + if (newMap[id]) { + await this.create(newMap[id]).catch((error) => { + this.ctx.logger.error(error) + }) + } else { + this.remove(id) } - this.data = config - } catch (error) { - const rollbackErrors: unknown[] = [] - for (const id of Object.keys(newMap).reverse()) { - if (oldMap[id]) continue - try { - await this.remove(id, true) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - } - for (const options of oldConfig) { - try { - await this.create(options) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - } - this.data = oldConfig - if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed') - throw error - } + })) } - async stop() { + stop() { for (const options of this.data) { - await this.remove(options.id, true) + this.remove(options.id, true) } } } @@ -119,7 +78,9 @@ export class Group extends EntryGroup { constructor(public ctx: Context, public config: EntryOptions[]) { super(ctx, ctx.fiber.entry!.parent.tree) - ctx.on('internal/update', config => this.update(config)) + ctx.on('internal/update', (config) => { + this.update(config) + }) } async* [Service.init]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 0dd2a06c92..e2fd539157 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -93,7 +93,7 @@ export default function isolate(ctx: Context) { entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) }) - ctx.on('loader/patch-context', async (entry, next) => { + ctx.on('loader/patch-context', (entry, next) => { // step 1: generate new isolate map const newMap: Dict = Object.create(entry.parent.ctx[Context.isolate]) for (const name of Object.keys(entry.options.isolate ?? {})) { @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - await next() + next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index c1925f0b90..b75d641375 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -39,27 +39,12 @@ export abstract class EntryTree { .filter(isNonNullable) } - /** - * Wait until this tree has no active import or lifecycle tasks. - * @throws a settled fiber failure, or an aggregate when several fibers failed. - */ + /** Wait until this tree has no pending import or lifecycle tasks. */ async await() { while (true) { const tasks = this.getTasks() - if (tasks.length) { - await Promise.allSettled(tasks) - continue - } - const outcomes = await Promise.allSettled( - [...this.entries()].map(entry => entry._await()), - ) - const failures = outcomes - .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') - .map(outcome => outcome.reason) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed') - this.ctx.reflect.notify(['loader']) - if (!this.getTasks().length) return + if (!tasks.length) return + await Promise.allSettled(tasks) } } @@ -96,17 +81,15 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) - const id = await group.create(options) - const entry = this.resolve(id) - group.data.splice(position, 0, entry.options) + group.data.splice(position, 0, options as EntryOptions) group.tree.write() - return id + return group.create(options) } /** Stop and remove an entry from its parent group. */ - async remove(id: string) { + remove(id: string) { const entry = this.resolve(id) - await entry.parent.remove(id) + entry.parent.remove(id) entry.parent.tree.write() } @@ -114,31 +97,15 @@ export abstract class EntryTree { async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent - const sourceIndex = source.data.indexOf(entry.options) - let target = source if (parent !== undefined) { - target = this.resolveGroup(parent) + const target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) + target.tree.write() entry.parent = target } - try { - await entry.update(options, false, true) - } catch (error) { - if (parent !== undefined) { - target.unlink(entry.options) - source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options) - entry.parent = source - try { - await entry.update({}, false, true) - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`) - } - } - throw error - } source.tree.write() - if (target !== source) target.tree.write() + return entry.update(options, false, true) } /** Import a plugin module from a specifier or `cordis:` builtin. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 781353b983..ac736ae356 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -26,7 +26,7 @@ declare module '@deepseek-ai/cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise + 'loader/patch-context'(entry: Entry, next: () => void): void } interface Context { @@ -100,12 +100,12 @@ export class Loader extends EntryTree { return interpolate(this.ctx, config) }, { global: true }) - ctx.on('internal/update', async function (config, noSave, next) { + ctx.on('internal/update', function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() - await next() const unparse = this.runtime?.Config?.['simplify'] this.entry.options.config = unparse ? unparse(config) : config this.entry.parent.tree.write() + return next() }, { global: true, prepend: true }) ctx.on('internal/update', function (config, _, next) { @@ -143,12 +143,9 @@ export class Loader extends EntryTree { const treeOwner = fiber.entry.parent.tree.ctx.fiber if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return - // case 6: Loader is replacing or removing this exact fiber - if (fiber.entry._disposing) return - this.showLog(fiber.entry, 'unload') - // case 7: fiber is disposed by loader behavior + // case 6: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable if (fiber.entry.disabled) return diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 92f9b09b82..828339371e 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -14,15 +14,6 @@ "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "import": "./lib/index.mjs", - "require": "./lib/index.cjs" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, "files": [ "lib/index.mjs", "lib/index.cjs", From 2abb542a222032b1ad3c5abd7bb65ffffe15b8de Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 9 Sep 2026 17:33:32 +0800 Subject: [PATCH 14/60] Adapt current consumers to nontransactional Cordis Loader Move exact user-patch watching into app boot and audit activation explicitly. Wait for CLI fallback HMR and preset subtrees. Join the first fiber-disposal result in the directory chooser and browser package runner. Keep async update errors observable without restoring generic Loader rollback. Restore the current workspace-link and Schemastery manifests and their gate. Preserve unconditional patch-input cloning, awaited Include initialization, and durable teardown writes. Update diagnostics, snapshots, bilingual docs, and the exhaustive vendor divergence ledger. Validation: focused Loader, watcher, preset, inventory, and cleanup tests; 100% watcher coverage; 32 Web preset cases; real CLI webhook model session; SDK smokes; 13 headless expected cases; 3 recorded-session replays; build, typecheck, lint contracts, and all 34 doc-sync gates. A full Node 24/Python 3.12 Vitest run had 21806 passes and two failures: a baseline-reproduced experimental CSS import failure and a Python probe timeout that passed alone. The default Node 26/Python 3.9 run additionally exposed unsupported Python and FileHandle-GC host failures. --- ...-fail-loud-releases-the-terminal.i18n.yaml | 4 +- ...6-07-31-fail-loud-releases-the-terminal.md | 2 +- ...7-31-fail-loud-releases-the-terminal.zh.md | 2 +- ...26-09-09-nontransactional-loader.i18n.yaml | 6 + .../2026-09-09-nontransactional-loader.md | 33 ++ .../2026-09-09-nontransactional-loader.zh.md | 33 ++ ...-09-user-patch-hmr-test-delivery.i18n.yaml | 4 +- ...2026-09-09-user-patch-hmr-test-delivery.md | 14 +- ...6-09-09-user-patch-hmr-test-delivery.zh.md | 14 +- apps/cli/src/profile-boot.ts | 1 + apps/cli/tests/built-bin.e2e.ts | 4 - .../stderr.expected.txt | 4 +- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 2 +- package.json | 1 + packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 4 +- packages/boot/app-boot/README.zh.md | 4 +- packages/boot/app-boot/package.json | 4 +- packages/boot/app-boot/src/index.ts | 68 +++-- packages/boot/app-boot/src/watch-config.ts | 89 ++++++ packages/boot/app-boot/tests/app-boot.spec.ts | 12 +- .../boot/app-boot/tests/config-reload.spec.ts | 36 ++- .../boot/app-boot/tests/user-patches.spec.ts | 59 ++-- .../boot/app-boot/tests/watch-config.spec.ts | 286 ++++++++++++++++++ .../cordis-client-runner/README.i18n.yaml | 4 +- .../extensions/cordis-client-runner/README.md | 2 +- .../cordis-client-runner/README.zh.md | 2 +- .../src/client/runtime.ts | 4 +- .../tests/runner.client.spec.ts | 30 +- .../host/directory-picker-auto/src/index.ts | 10 +- .../tests/loader-composition.spec.ts | 22 +- .../plugin-inventory/tests/inventory.spec.ts | 2 +- .../host/webserver/tests/webserver.spec.ts | 14 +- .../tests/inventory.spec.ts | 1 + .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/src/mount.ts | 20 +- .../preset/agent-presets/tests/mount.spec.ts | 9 +- .../tests/loader-composition.spec.ts | 1 + pnpm-lock.yaml | 6 +- pnpm-workspace.yaml | 4 + scripts/verify-vendored-links.ts | 72 +++++ vendor/README.md | 18 +- vendor/cordis/src/events.ts | 2 +- vendor/cordis/src/fiber.ts | 6 +- vendor/hmr/src/index.ts | 1 - vendor/include/src/index.ts | 17 +- vendor/loader/src/config/entry.ts | 11 +- vendor/loader/src/config/isolate.ts | 3 +- vendor/loader/src/config/utils.ts | 2 +- vendor/loader/src/index.ts | 2 +- vendor/schemastery/package.json | 9 + 53 files changed, 786 insertions(+), 186 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md create mode 100644 .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md create mode 100644 packages/boot/app-boot/src/watch-config.ts create mode 100644 packages/boot/app-boot/tests/watch-config.spec.ts create mode 100644 scripts/verify-vendored-links.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml index 1f631789a8..89a74eaa00 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.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-31-fail-loud-releases-the-terminal.md -2026-07-31-fail-loud-releases-the-terminal.md: e5196121a850997b5eff045a26dd7c638776196c -2026-07-31-fail-loud-releases-the-terminal.zh.md: 5cec9fe7d4ac75df1b5c88f2ea4cb0a665730719 +2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed +2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md index e5196121a8..a6b8ccc5a0 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -15,7 +15,7 @@ $ 1;2;4cecho hello zsh: command not found: 4cecho ``` -The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.) +The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The [Loader activation audit](../simplification/2026-09-09-nontransactional-loader.md) reports config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.) Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md index 5cec9fe7d4..728fca424d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -17,7 +17,7 @@ zsh: command not found: 4cecho Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。 -在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 +在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。([Loader 激活检查](../simplification/2026-09-09-nontransactional-loader.zh.md) 让配置树失败经 `boot()` 报告,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 `/exit` 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml new file mode 100644 index 0000000000..0cb0ade9b5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.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/simplification/2026-09-09-nontransactional-loader.md +2026-09-09-nontransactional-loader.md: e3d43e6d56235e35d1a273900a935a7df19318be +2026-09-09-nontransactional-loader.zh.md: 55abb03a1f72c9e88304033d76d3db80bb28f916 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md new file mode 100644 index 0000000000..e3d43e6d56 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md @@ -0,0 +1,33 @@ +# Agent Note: Keep Loader mutations non-transactional + +Status: implemented + +English | [中文](2026-09-09-nontransactional-loader.zh.md) + +## Problem + +Transactional config reload preserves an old plugin generation after a failed edit, but requires Loader to own candidate imports, lifecycle settlement, rollback, option identity, and Include serialization. These changes make the vendored implementation substantially different from its pinned sources. Application startup and profile patch watching also depend on that settlement implicitly. + +## Decision + +Revert the five commits in [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932), resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins. + +Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree. + +Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. These adaptations are separate from the reverse patch. + +The small update-result extension forwards asynchronous restart failures through Fiber and Entry updates. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. Missing-file initialization waits for its write and rereads the file before mounting initial entries. + +## Alternatives considered + +**Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback. + +**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. + +**Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover. + +## Consequences + +A plugin activation failure can leave the new options and a failed fiber in place. Callers that require active plugins must audit after settlement; awaiting `Loader.create()` alone does not establish activation. Automatic plugin rollback requires a separate future decision with evidence that its recovery benefit warrants the additional lifecycle implementation. + +[Live-patch tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md) retain controlled event delivery and native watcher coverage, while asserting failure reporting without rollback. The [terminal-release policy](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) remains applicable to fatal errors and partial boot teardown. Web preset composition and a real CLI webhook-created model Session provide the application-level verification beyond hand-mounted plugins. diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md new file mode 100644 index 0000000000..55abb03a1f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 保持 Loader 更改非事务化 + +Status: implemented + +[English](2026-09-09-nontransactional-loader.md) | 中文 + +## 问题 + +事务化配置重载会在编辑失败后保留旧插件代次,但要求 Loader 负责候选导入、生命周期结算、回滚、选项对象身份和 Include 串行化。这些更改使 vendor 实现与固定来源产生显著差异。应用启动和 profile patch 监视也隐式依赖该结算行为。 + +## 决策 + +撤销 [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932) 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。 + +Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。 + +应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。这些适配与反向补丁分开。 + +小范围的更新结果扩展通过 Fiber 和 Entry 更新传递异步重启失败。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。缺失文件的初始化等待写入完成,并重新读取文件后才挂载初始条目。 + +## 考虑过的替代方案 + +**保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。 + +**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 + +**将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。 + +## 后果 + +插件激活失败可能保留新选项和失败的 fiber。要求插件处于激活状态的调用者必须在结算后检查;仅等待 `Loader.create()` 不能证明激活。自动插件回滚需要后续独立决策,并证明其恢复收益值得额外的生命周期实现。 + +[实时 patch 测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md) 保留受控事件投递和原生监视覆盖,同时断言不回滚时的失败报告。[终端释放策略](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) 仍适用于致命错误和部分启动拆卸。Web preset 组合与真实 CLI webhook 创建的模型 Session 提供手动挂载插件之外的应用级验证。 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml index 81de55da66..699ced6cd7 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.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/testing/2026-09-09-user-patch-hmr-test-delivery.md -2026-09-09-user-patch-hmr-test-delivery.md: 427cf938d38eaf5c351fc0334bfd7df766d5663c -2026-09-09-user-patch-hmr-test-delivery.zh.md: c2a3a14c9f322e748dbfd131a98138b13c5d47a6 +2026-09-09-user-patch-hmr-test-delivery.md: fadd191650411f07c3b8b1f35c94e73869fbc8e2 +2026-09-09-user-patch-hmr-test-delivery.zh.md: b108cf25766ff108d87c429e9f208c2d4cf7afd2 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md index 427cf938d3..fadd191650 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md @@ -1,4 +1,4 @@ -# Agent Note: User-patch transactions control filesystem event delivery +# Agent Note: User-patch tests control filesystem event delivery Status: implemented @@ -6,22 +6,22 @@ English | [中文](2026-09-09-user-patch-hmr-test-delivery.zh.md) ## Problem -The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the transaction assertions from exercising the parser, activation, and rollback behavior they own. +The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the refresh assertions from exercising the parser, activation, and recovery behavior they own. ## Decision -The [user-patch transaction test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. HMR registration, refresh serialization, Include recomposition, plugin activation, failure broadcasting, rollback, and recovery remain real. The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block. +The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block. -The separate [HMR config tests](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The transaction test does not establish operating-system delivery guarantees. +The separate [watcher tests](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees. ## Alternatives considered -**Native notifications for every transaction assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken. +**Native notifications for every refresh assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken. **Polling and fixed settling delays.** Rejected because neither acknowledges delivery of the next edit. Chokidar readiness does not expose completion of Node's asynchronous initial polling baseline; a local polling reproduction still misses changes. Increasing the test deadline cannot recover an event that was never emitted. -**Mock HMR registration or Include.** Rejected because the test must retain transactional recomposition and last-good-state assertions after activation and parse failures. +**Mock HMR registration or Include.** Rejected because the test must retain real recomposition, report activation failures, and preserve the running configuration after parse failures. ## Consequences -The transaction sequence retains every semantic assertion and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis. +The refresh sequence retains its semantic assertions and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis. diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md index c2a3a14c9f..b108cf2576 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 用户 patch 事务控制文件系统事件投递 +# Agent Note: 用户 patch 测试控制文件系统事件投递 Status: implemented @@ -6,22 +6,22 @@ Status: implemented ## 问题 -[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止事务断言执行其负责验证的解析、激活与回滚行为。 +[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止刷新断言执行其负责验证的解析、激活与回滚行为。 ## 决策 -[用户 patch 事务测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。HMR 注册、刷新串行化、Include 重组、插件激活、失败广播、回滚与恢复仍使用真实实现。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 +[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 -独立的 [HMR 配置测试](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。事务测试不验证操作系统的投递保证。 +独立的 [监视器测试](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。 ## 考虑过的替代方案 -**每个事务断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。 +**每个刷新断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。 **轮询与固定等待。** 不采用,因为两者都不能确认下一次修改已经投递。Chokidar 就绪状态不暴露 Node 异步初始轮询基线的完成时刻;本地轮询复现仍会遗漏修改。延长测试期限无法恢复从未发出的事件。 -**Mock HMR 注册或 Include。** 不采用,因为测试必须保留事务重组,以及激活和解析失败后的最后有效状态断言。 +**Mock HMR 注册或 Include。** 不采用,因为测试必须保留真实重组、报告激活失败,并在解析失败后保留运行中的配置。 ## 影响 -事务序列保留所有语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。 +刷新序列保留其语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 0715efe1bb..1c50e1b581 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -368,6 +368,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' }) } await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } }) + await ctx.loader.await() } await watchUserPatches(ctx, { binName: NAME, diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ef606d49af..3697d3fb75 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -757,10 +757,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, SPAWN_TIMEOUT_MS + 30_000) it('reports a patch-overlay boot failure without hanging', async () => { - // The HMR main watcher's initial scan once refreshed the include - // mid-initial-apply, deadlocking the failing apply's rollback against the - // refresh drain: dsh exited 13 with no diagnostic instead of settling - // ([vendor/README.md](../../../vendor/README.md)). const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], { diff --git a/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt b/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt index f3252180b9..74e084a926 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt +++ b/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt @@ -1,3 +1,3 @@ -headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure -Error: startup activation snapshot failure +headless-test-driver: plugin tree failed to load: headless-test-driver: 1 entry did not activate +./activation-error.mjs: Error: startup activation snapshot failure at activation-error-fixture diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index c879e5b8b9..51c85178db 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -355,7 +355,7 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin tree failed to load') - expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') + expect(stderr).toContain('@deepseek-ai/dsh-sdk-jsonrpc-server: SyntaxError:') expect(stderr).toContain('sometimes') } finally { await rm(root, { recursive: true, force: true }) diff --git a/package.json b/package.json index d5a5b1c41b..a09fa3f997 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", + "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 3d4666ea0d..ce5f0f1cee 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: df386b64089f962960b09538381e9d4b4905feb0 -README.zh.md: 5a7246c52ea5bcc8d5322d9fac4a87ec2e2ef4cc +README.md: bbc3e3147a455149d3e263f0bf4f1091d93f46cc +README.zh.md: 53d8b58df6da74581539585bce81d885f7708cf8 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index df386b6408..bbc3e3147a 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home: - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead. -Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. +Profiles with `patchReload: live` watch both user patch files. Parse failures preserve the running configuration; plugin activation failures are reported and can leave a partially applied tree. A later valid edit can recover it. Loader changes are not rolled back. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal. @@ -118,7 +118,7 @@ Read these pages when the package-level contract is not enough. They move from t - [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`). - [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior. - [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design. -- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of transaction behavior and native filesystem delivery. +- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of live-patch behavior and native filesystem delivery. ----- diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 5a7246c52e..53d8b58df6 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个 config(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。 -带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。 +带 `patchReload: live` 的 profile 会监视两份用户 patch 文件。解析失败会保留运行中的配置;插件激活失败会被报告,并可能留下部分应用的配置树。后续有效编辑可以恢复它。Loader 更改不会回滚。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 @@ -118,7 +118,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。 - [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。 - [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。 -- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——事务行为与原生文件系统投递的验证归属。 +- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——实时 patch 行为与原生文件系统投递的验证归属。 ----- diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 2e49da08de..daf0d9a9df 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -29,6 +29,7 @@ "dependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-package-manifest": "workspace:^", + "chokidar": "4.0.3", "js-yaml": "^4.2.0", "resolve.exports": "^2.0.3" }, @@ -57,7 +58,6 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/js-yaml": "^4.0.9", - "@deepseek-ai/cordis": "workspace:^", - "chokidar": "4.0.3" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 3698a092a8..8cfc8f5678 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -18,6 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' +import { watchConfig } from './watch-config.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -241,7 +242,7 @@ export interface UserPatchWatchOptions { } /** - * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include. + * Watch the user patch layer and reapply it to the boot Include without rollback. * @param ctx - settled app context containing the root Include and an active HMR service. * @param options - diagnostic, file, and patch-composition inputs. * @returns an asynchronous disposer after the exact-path watcher is ready. @@ -256,7 +257,7 @@ export async function watchUserPatches( if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`) const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`) - const register = hmr.registerConfig(filename, async () => { + const register = watchConfig(ctx, filename, hmr.config, async () => { // Re-read the include's non-patch options per refresh so a writer that // updates another option between refreshes is not silently reverted. const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config @@ -268,6 +269,8 @@ export async function watchUserPatches( patches, }, }) + await ctx.loader.await() + await assertEntriesActivated(ctx, binName) }) try { return await register @@ -511,7 +514,7 @@ function groupedDump( * names; relative names continue to resolve beside the configuration file. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the - * transactional create was still settling entry lifecycle. + * entry creation was in flight. */ export async function mountRootInclude( ctx: Context, @@ -762,11 +765,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * is statically imported and mounted as the `cordis:include` builtin, loading * through the ambient module pipeline (vite/tsx/plain ESM). The package build * embeds Include while leaving Loader external, so the built include tree and - * host share one Loader peer. Loader - * settlement rejects startup failures, which `boot` wraps after disposing the - * partial context; a missing fiber or never-activating entry is rejected by - * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's - * init rejection with its original stack; later unhandled rejections remain + * host share one Loader peer. The final audit, {@link assertEntriesActivated}, + * rejects missing, failed, or inactive entries. `boot` disposes its partial + * context on failure and preserves the original activation stack; later unhandled rejections remain * covered by {@link installFailLoud}. Built bins need the Loader's native * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. @@ -780,6 +781,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * complete plugin set. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. + * @throws a labelled error after disposing the partial context — `host + * preparation failed` when `prepare` threw before any config-tree entry + * mounted, `plugin tree failed to load` afterwards. */ export async function boot( binName: string, @@ -789,21 +793,39 @@ export async function boot( bareModuleBaseUrl?: string, ): Promise { const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - ctx.provide('dshHomePath', dshHomePath) - await ctx.plugin(Loader) - await prepare?.(ctx) - await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) - await ctx.loader.await() - // A surface can finish and dispose the whole tree while that await is still - // pending: the TUI renders as soon as its own fiber starts, so an `/exit` - // typed before the last entry settles tears the context down under us. The - // Loader service goes with it, and the activation audit describes a live - // tree — reading `ctx.loader` here would throw a TypeError over an app that - // exited exactly as asked. - if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) - return ctx + // Two failure labels: `prepare` runs before any config-tree entry mounts, + // so its failure is host setup, not the plugin tree. + let stage = 'host preparation failed' + try { + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) + await ctx.plugin(Loader) + await prepare?.(ctx) + stage = 'plugin tree failed to load' + await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) + // A surface can finish and dispose the whole tree while startup is still + // in flight, before the last entry settles. The Loader service goes with + // it, and the activation audit describes a live tree — reading `ctx.loader` + // past this point would throw a TypeError over an app that exited exactly + // as asked. Re-check after settlement before auditing the tree. + await ctx.get('loader')?.await() + if (ctx.get('loader') === undefined) return ctx + await assertEntriesActivated(ctx, binName) + return ctx + } catch (cause) { + // Root-fiber disposal contains cleanup failures per observer (Cordis + // fiber.ts hardening) and a repeated call returns the settled single-shot + // result, so this await cannot reject and replace `cause`. + await ctx.fiber.dispose() + const detail = cause instanceof Error ? cause.message : String(cause) + // A wrapper can carry an activation error whose original stack names the failed plugin. + let deepest: unknown = cause + while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause + const stack = deepest instanceof AggregateError + ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` + : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) + } } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ diff --git a/packages/boot/app-boot/src/watch-config.ts b/packages/boot/app-boot/src/watch-config.ts new file mode 100644 index 0000000000..ec761ee6a2 --- /dev/null +++ b/packages/boot/app-boot/src/watch-config.ts @@ -0,0 +1,89 @@ +/** Exact-path watching for live profile patch files outside Cordis module roots. */ +import { dirname, relative, resolve } from 'node:path' +import { realpath, stat } from 'node:fs/promises' +import { watch, type ChokidarOptions } from 'chokidar' +import type { Context } from '@deepseek-ai/cordis' + +const registrations = new WeakMap>() + +async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { + let root = dirname(filename) + let depth = 0 + while (true) { + try { + if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) + const canonicalRoot = await realpath(root) + return { filename: resolve(canonicalRoot, relative(root, filename)), root: canonicalRoot, depth } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(root) + if (parent === root) throw error + root = parent + depth += 1 + } + } +} + +/** + * Watch one patch path, including missing parents, and serialize refresh callbacks. + * @param ctx Context that owns watcher disposal and receives refresh failures. + * @param filename Absolute patch-file path. + * @param options Deployment watcher options inherited from the HMR configuration. + * @param refresh Callback for additions, changes, and removals. + * @returns A disposer that closes the watcher and drains its current refresh. + * @throws When path resolution, watcher startup, or effect registration fails. + */ +export async function watchConfig( + ctx: Context, filename: string, options: ChokidarOptions, refresh: () => Promise | void, +): Promise<() => Promise> { + const target = await findWatchRoot(filename) + const paths = registrations.get(ctx) ?? new Set() + registrations.set(ctx, paths) + if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`) + const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options + const watcher = watch(target.root, { + ...watchOptions, depth: target.depth, ignoreInitial: false, + }) + paths.add(target.filename) + const state = { dirty: false } + let running: Promise | undefined + const onChange = (path: string) => { + const observed = resolve(path) + if (observed !== filename && observed !== target.filename) return + state.dirty = true + if (running) return + running = (async () => { + while (state.dirty) { + state.dirty = false + try { + await refresh() + } catch (reason) { + const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) + ctx.logger.warn('config reload at %C failed', filename) + ctx.logger.warn(error) + } + } + })().finally(() => { running = undefined }) + } + watcher.on('add', onChange) + watcher.on('change', onChange) + watcher.on('unlink', onChange) + const ready = Promise.withResolvers() + let pending = true + watcher.once('ready', () => { pending = false; ready.resolve() }) + watcher.on('error', (error) => { + if (pending) { pending = false; ready.reject(error) } else { ctx.logger.warn(error) } + }) + const dispose = async () => { + await watcher.close() + paths.delete(target.filename) + await running + } + try { + await ready.promise + return ctx.effect(() => dispose, 'app-boot.watchConfig()') + } catch (error) { + await dispose() + throw error + } +} diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index c878fbd3a2..61fda39717 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -790,7 +790,7 @@ describe('boot', () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow( - `${NAME}: plugin tree failed to load: failed to apply loader entry`, + `${NAME}: plugin tree failed to load: ${NAME}: plugin(s) failed to load: ./missing.mjs`, ) }) @@ -808,7 +808,7 @@ describe('boot', () => { writeFileSync(configPath, config) await expect(boot(NAME, configPath)).rejects.toThrow( - 'failed to apply loader entry invalid-config (./noop.mjs)', + './noop.mjs: SyntaxError:', ) expect(readFileSync(configPath, 'utf8')).toBe(config) }) @@ -825,8 +825,8 @@ describe('boot', () => { ].join('\n')) writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n') await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([ - String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`, - String.raw`Error: pinned activation failure\n {4}at failing-fixture$`, + String.raw`\./failing\.mjs: Error: pinned activation failure\n`, + String.raw` {4}at failing-fixture$`, ].join(''))) }) @@ -835,9 +835,9 @@ describe('boot', () => { const deepest = new Error('stackless deep failure') delete (deepest as { stack?: string }).stack await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => { - throw new Error('host preparation failed', { cause: deepest }) + throw new Error('wrapped setup failure', { cause: deepest }) })).rejects.toThrow( - `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`, + `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`, ) }) diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 4486c66ba8..e53879cf34 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -1,15 +1,6 @@ -/** - * Config hot-reload resilience of the booted include tree. `dsh-app-boot` - * installs a fail-loud unhandled-rejection handler, so a `refresh()` that - * rethrows a config-file parse error would kill a live app on one bad - * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event - * callback nobody else catches). These tests pin the vendored - * `@cordisjs/plugin-include` contract that boot relies on: an invalid file - * keeps the last good tree, and a valid re-read re-applies overlay patches - * exactly like the initial load. - */ +/** File reload and overlay behavior through the booted Include tree. */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -32,10 +23,11 @@ interface TreeFixture { include: Include } -async function bootTree(configBody: string): Promise { +async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) const ctx = await boot(NAME, join(dir, 'cordis.yml')) const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined) @@ -48,6 +40,26 @@ function entryConfig(ctx: Context, id: string): unknown { } describe('include refresh with an invalid file', () => { + it('writes and activates initial entries when an included file is missing', async () => { + const { ctx, dir } = await bootTree([ + '- id: initialized', + " name: 'cordis:include'", + ' config:', + ' path: ./created.yml', + ' initial:', + ' - id: noop', + ' name: ./noop.mjs', + ' config: { value: initial }', + '', + ].join('\n')) + try { + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'initial' }) + expect(readFileSync(join(dir, 'created.yml'), 'utf8')).toContain('id: noop') + } finally { + await ctx.fiber.dispose() + } + }) + it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index f894cda489..17c2f6bb84 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -1,7 +1,7 @@ /** * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over - * a real Loader tree, kept live through transactional HMR. + * a real Loader tree with live file watching. */ import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' @@ -270,7 +270,9 @@ describe('Loader config interpolation', () => { await provider?.update({ disabled: true }) await provider?.update({ config: { fail: true } }) await provider?.update({ disabled: false }) - await expect(ctx.loader.await()).rejects.toThrow('rejected provider') + await ctx.loader.await() + const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') + await expect(reader?.fiber?.await()).rejects.toThrow('rejected provider') expect(ctx.get('readerResult')).toBeUndefined() await provider?.update({ disabled: true }) @@ -342,7 +344,8 @@ describe('Loader entry disabled interpolation', () => { const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean await entry?.update({ disabled: disabledTrue }) expect(entry?.disabled).toBe(true) - expect(entry?.fiber).toBeUndefined() + await ctx.loader.await() + expect(entry?.fiber?.uid).toBeNull() await entry?.update({ disabled: disabledFalse }) expect(entry?.disabled).toBe(false) expect(entry?.fiber).toBeDefined() @@ -403,7 +406,7 @@ describe('boot with user patches', () => { } }) - it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { + it('applies live patches, reports failures without rollback, and recovers after a later edit', { timeout: 20_000 }, async () => { const dir = tmp() const userDir = tmp() const filename = join(userDir, PROFILE_PATCH_FILENAME) @@ -412,8 +415,8 @@ describe('boot with user patches', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - // Native notifications belong to hmr-config.spec.ts; this case owns the - // real HMR/Include transaction after each delivered filesystem event. + // Native notifications belong to watch-config.spec.ts; this case owns + // Include recomposition after each explicitly delivered filesystem event. const watchers: FSWatcher[] = [] const previousFactory = configWatch.create onTestFinished(() => { configWatch.create = previousFactory }) @@ -423,10 +426,11 @@ describe('boot with user patches', () => { queueMicrotask(() => { watcher.emit('ready') }) return watcher } - const failures: Array<{ filename: string; error: Error }> = [] - ctx.on('hmr/config-update-failed', (failedFilename, error) => { - failures.push({ filename: failedFilename, error }) + const failures: Error[] = [] + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation((value: unknown) => { + if (value instanceof Error) failures.push(value) }) + onTestFinished(() => { warn.mockRestore() }) const dispose = await watchUserPatches(ctx, { binName: NAME, filename, @@ -441,16 +445,15 @@ describe('boot with user patches', () => { writeFileSync(filename, '- id: noop\n config:\n fail: true\n') watcher.emit('change', filename) - await eventually(() => failures.length === 1, 'failed candidate was not broadcast') - expect(failures[0]).toMatchObject({ filename }) - expect(failures[0]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await eventually(() => failures.length === 1, 'failed candidate was not reported') + expect(failures[0]).toBeInstanceOf(Error) + expect(entryConfig(ctx, 'noop')).toMatchObject({ fail: true }) writeFileSync(filename, 'invalid: [unclosed\n') watcher.emit('change', filename) - await eventually(() => failures.length === 2, 'parse failure was not broadcast') - expect(failures[1]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await eventually(() => failures.length === 2, 'parse failure was not reported') + expect(failures[1]).toBeInstanceOf(Error) + expect(entryConfig(ctx, 'noop')).toMatchObject({ fail: true }) writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') watcher.emit('change', filename) @@ -494,21 +497,19 @@ describe('boot with user patches', () => { }) it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { - // A surface can dispose the whole tree while registerConfig's effect - // registration is still in flight (the HMR effect then fails with - // INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher - // must not crash the process. The stub makes the race deterministic — the - // live-teardown ordering itself is not stageable. const dir = tmp() const ctx = await boot(NAME, writeTree(dir)) - try { - const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) - ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) - await expect(dispose()).resolves.toBeUndefined() - } finally { - await ctx.fiber.dispose() + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = (options) => { + const watcher = new FSWatcher(options) + void ctx.fiber.dispose().then(() => { watcher.emit('ready') }) + return watcher } + const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) + await expect(dispose()).resolves.toBeUndefined() }) it('propagates registration failures other than mid-teardown', async () => { @@ -519,7 +520,7 @@ describe('boot with user patches', () => { await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) const dispose = await watchUserPatches(ctx, { binName: NAME, filename }) - // Same user-layer path registered twice: HMR refuses; not a teardown race. + // Same user-layer path registered twice: the watcher refuses; not a teardown race. await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') await dispose() } finally { diff --git a/packages/boot/app-boot/tests/watch-config.spec.ts b/packages/boot/app-boot/tests/watch-config.spec.ts new file mode 100644 index 0000000000..aeed3dadbf --- /dev/null +++ b/packages/boot/app-boot/tests/watch-config.spec.ts @@ -0,0 +1,286 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import { watchConfig } from '../src/watch-config.ts' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { FSWatcher, type ChokidarOptions } from 'chokidar' + +const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined })) +vi.mock('node:fs/promises', async (importOriginal) => { + const native = await importOriginal() + return { ...native, stat: vi.fn(native.stat) } +}) +vi.mock('chokidar', async (importOriginal) => { + const native = await importOriginal() + return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) => + configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) } +}) + +/** Every per-test tree root, removed once the booted watcher has been disposed. */ +const hmrRoots: string[] = [] + +async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { + root, + ignored: [], + debounce: 0, + ...usePolling === undefined ? {} : { usePolling }, + }) + return ctx +} + +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('HMR exact config paths', () => { + afterEach(() => { + for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + }) + + it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) + const alias = `${target}-alias` + const aliasFilename = join(alias, 'module.ts') + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(aliasFilename, 'export const generation = 0\n') + // This acceptance owns alias-to-cache identity. Other cases below exercise + // native events; polling keeps Windows fs.watch queue pressure out of it. + const ctx = await bootHmr(alias, ['.'], true) + const filename = join(await realpath(target), 'module.ts') + const expected = pathToFileURL(filename).href + const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) + const observed: string[] = [] + ctx.on('hmr/change', (url) => { observed.push(url) }) + try { + const deadline = Date.now() + 20_000 + for (let generation = 1; !observed.includes(expected); generation += 1) { + if (Date.now() >= deadline) { + throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`) + } + // The watch base, not the writer spelling, is the alias under test. + // Grow the file on every write: polling must not depend on timestamp + // precision when several generations land inside one filesystem tick. + writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`) + // Leave Chokidar's atomic-write window idle so one coalesced change can publish. + await new Promise(resolve => setTimeout(resolve, 250)) + } + expect(cacheHas).toHaveBeenCalledWith(expected) + } finally { + await ctx.fiber.dispose() + unlinkSync(alias) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('collapses filesystem aliases before registering an exact watch', async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) + const alias = `${target}-alias` + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = await bootHmr(alias) + try { + await watchConfig(ctx, join(alias, 'plugins.yml'), {}, () => {}) + await expect(watchConfig(ctx, join(await realpath(target), 'plugins.yml'), {}, () => {})) + .rejects.toThrow('config path already registered') + } finally { + await ctx.fiber.dispose() + unlinkSync(alias) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const observed: string[] = [] + try { + await watchConfig(ctx, filename, {}, () => { + try { + observed.push(readFileSync(filename, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + observed.push('missing') + } + }) + + writeFileSync(filename, 'one', { flag: 'wx' }) + await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + writeFileSync(filename, 'two') + await eventually(() => observed.includes('two'), 'HMR did not observe config change') + unlinkSync(filename) + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + } finally { + await ctx.fiber.dispose() + } + }) + + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(root) + const dir = join(root, 'later') + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(root) + const observed: string[] = [] + try { + await watchConfig(ctx, filename, {}, () => { + observed.push(readFileSync(filename, 'utf8')) + }) + mkdirSync(dir) + writeFileSync(filename, 'created') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + } finally { + await ctx.fiber.dispose() + } + }) + + it('serializes refreshes and waits for them during disposal', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + onTestFinished(() => ctx.fiber.dispose()) + const watcher = new FSWatcher() + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + const started = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + let calls = 0 + let active = 0 + let maxActive = 0 + const dispose = await watchConfig(ctx, filename, {}, async () => { + active += 1 + maxActive = Math.max(maxActive, active) + if (++calls === 1) { + started.resolve(undefined) + await release.promise + } + active -= 1 + }) + watcher.emit('change', join(dir, 'unrelated.yml')) + expect(calls).toBe(0) + watcher.emit('add', filename) + await started.promise + watcher.emit('change', filename) + watcher.emit('unlink', filename) + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + await disposal + expect(maxActive).toBe(1) + expect(calls).toBe(2) + }) + + it('rejects a patch path whose parent is a regular file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-')) + hmrRoots.push(dir) + const parent = join(dir, 'file') + writeFileSync(parent, '') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await expect(watchConfig(ctx, join(parent, 'plugins.yml'), {}, () => {})) + .rejects.toThrow('config watch parent is not a directory') + }) + + it('stops searching when the filesystem root cannot be read', async () => { + const failure = Object.assign(new Error('filesystem root unavailable'), { code: 'ENOENT' }) + const read = vi.mocked(fsPromises.stat).mockClear().mockRejectedValueOnce(failure) + onTestFinished(() => { read.mockRestore() }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await expect(watchConfig(ctx, join(parse(tmpdir()).root, 'plugins.yml'), {}, () => {})).rejects.toBe(failure) + expect(read).toHaveBeenCalledOnce() + }) + + it.each(['creation', 'ready'] as const)('releases registration after watcher %s fails', async (phase) => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-watch-failure-')) + hmrRoots.push(dir) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const filename = join(dir, 'plugins.yml') + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + const failure = new Error('watcher unavailable') + const failed = new FSWatcher() + const closed = vi.spyOn(failed, 'close') + configWatch.create = () => { + if (phase === 'creation') throw failure + queueMicrotask(() => { failed.emit('error', failure) }) + return failed + } + await expect(watchConfig(ctx, filename, {}, () => {})).rejects.toBe(failure) + if (phase === 'ready') expect(closed).toHaveBeenCalledOnce() + const watcher = new FSWatcher() + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + await watchConfig(ctx, filename, {}, () => {}) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + watcher.emit('error', failure) + expect(warn).toHaveBeenCalledWith(failure) + }) + + it('closes a ready watcher when its context has already been disposed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-disposed-')) + hmrRoots.push(dir) + const root = new Context() + const fiber = root.plugin(() => {}) + await fiber + const ctx = fiber.ctx + await fiber.dispose() + const watcher = new FSWatcher() + const close = vi.spyOn(watcher, 'close') + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + await expect(watchConfig(ctx, join(dir, 'plugins.yml'), {}, () => {})) + .rejects.toThrow('cannot create effect on inactive context') + expect(close).toHaveBeenCalledOnce() + }) + + it('logs a normalized refresh failure and continues processing later events', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-failure-')) + hmrRoots.push(dir) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + onTestFinished(() => ctx.fiber.dispose()) + const watcher = new FSWatcher() + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + let calls = 0 + const recovered = Promise.withResolvers() + await watchConfig(ctx, filename, {}, () => { + if (++calls === 1) throw 42 + recovered.resolve(undefined) + }) + watcher.emit('change', filename) + await expect.poll(() => warn.mock.calls.length).toBe(2) + expect(warn.mock.calls[0]).toEqual(['config reload at %C failed', filename]) + expect(warn.mock.calls[1]?.[0]).toMatchObject({ message: '42' }) + watcher.emit('change', filename) + await recovered.promise + expect(calls).toBe(2) + }) +}) diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index d1cbdd5fdf..f99b115e71 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/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/extensions/cordis-client-runner/README.md -README.md: 260d4703bda2560a4254962d7b868221d0328ec8 -README.zh.md: a74128508c5b6a81d0600452b6ec5301bf7c8dcb +README.md: 153a8bf3b71d2c4c8edf3423dd0b7004083d1ecc +README.zh.md: 3f36f180043ce9074161889d3f1b0a7ab9b781ba diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index 260d4703bd..153a8bf3b7 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -51,7 +51,7 @@ This section explains the design behind the browser half; the observable behavio ### Design philosophy -The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload is entry removal plus factory invalidation plus style removal. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel. +The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload removes the entry, waits for its fiber's cleanup, then invalidates its factory and removes its styles. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel. ### Source map diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index a74128508c..3f36f18004 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 设计理念 -浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载 = 移除 entry + 失效 factory + 撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry 错误接缝,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。 +浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载先移除 entry,等待其 fiber 清理完成,再使 factory 失效并撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry 错误接缝,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。 ### 源码地图 diff --git a/packages/extensions/cordis-client-runner/src/client/runtime.ts b/packages/extensions/cordis-client-runner/src/client/runtime.ts index c16e420ffb..bed64e7519 100644 --- a/packages/extensions/cordis-client-runner/src/client/runtime.ts +++ b/packages/extensions/cordis-client-runner/src/client/runtime.ts @@ -451,7 +451,9 @@ export class DynamicCordisPackageRunner { this.failures.delete(id) // Entry removal disposes the fiber (slot entries and facade effects // cascade); the factory invalidation makes a later re-load legal. - await this.env.loader.remove(entryId) + const disposal = this.env.loader.resolve(entryId).fiber?.dispose() + this.env.loader.remove(entryId) + await disposal this.env.modules.invalidate(moduleIdOf(id)) styles.dispose() } diff --git a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts index 93cd0d3373..03314c0f96 100644 --- a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts @@ -13,7 +13,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Loader } from '@deepseek-ai/cordis-plugin-loader' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' @@ -52,7 +52,7 @@ interface Bench { invalidated: string[] removed: string[] created: string[] - invoke: ReturnType + invoke: ReturnType Promise>> /** Render failures the runner sent upstream, in order. */ reported: { agentId: SessionId @@ -103,15 +103,15 @@ async function boot(): Promise { return Promise.resolve(entryId) }, resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined }, - remove: async (entryId: string) => { + remove: (entryId: string) => { removed.push(entryId) const entry = fibers.get(entryId) fibers.delete(entryId) - await (entry?.fiber as { dispose(): Promise } | undefined)?.dispose() + void (entry?.fiber as { dispose(): Promise } | undefined)?.dispose() }, } as unknown as Loader - const invoke = vi.fn(() => Promise.resolve(null)) + const invoke = vi.fn<() => Promise>(() => Promise.resolve(null)) const reported: Bench['reported'] = [] // The crash seam is stood in so a test can report an entry failure without a // React render, exactly as the renderer's boundary would; registrations still @@ -298,6 +298,26 @@ describe('failure stages', () => { }) describe('retract', () => { + it('waits for plugin cleanup before invalidating its module factory', async () => { + const bench = await boot() + const started = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + bench.invoke.mockImplementation(() => { + started.resolve(undefined) + return release.promise + }) + await bench.runner.load(half({ code: 'return { apply: (ctx) => ctx.effect(() => () => host.call("cleanup", null)) }' })) + bench.runner.retract(PLUGIN, RUN) + await started.promise + await bench.settle() + expect(bench.removed).toEqual(['entry-1']) + expect(bench.invalidated).toEqual(['dyn/dyn-1']) + release.resolve(undefined) + await bench.settle() + expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1']) + }) + it('unloads at the named revision', async () => { const bench = await boot() await bench.runner.load(half()) diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index a95e981ad8..46a5dff2f7 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -79,14 +79,18 @@ export async function apply(ctx: Context): Promise { // nothing is left to unmount or await then. const entry = ctx.loader.store[id] if (entry === undefined) continue - const fiber = entry.fiber + const disposal = entry.fiber?.dispose() ctx.loader.remove(id) - await fiber?.dispose() + await disposal } } try { for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { - ids.push(await ctx.loader.create({ name })) + const id = await ctx.loader.create({ name }) + ids.push(id) + const entry = ctx.loader.resolve(id) + if (entry.fiber === undefined) throw new Error(`directory-picker-auto: failed to load ${name}`) + await entry.fiber.await() } } catch (cause) { // Setup owns the entries it created until it returns the disposer: leaving diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 77ad4b549d..83970e6686 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -12,7 +12,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' @@ -205,7 +205,21 @@ describe('real Loader composition', () => { // and the disposer joins the backend's teardown — the service is gone the // moment dispose() settles, with no further loader await. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! - await autoEntry.fiber!.dispose() + const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! + const cleanupStarted = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + onTestFinished(() => { releaseCleanup.resolve(undefined) }) + backendEntry.fiber!.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await releaseCleanup.promise + }) + let disposed = false + const disposal = autoEntry.fiber!.dispose().then(() => { disposed = true }) + await cleanupStarted.promise + await Promise.resolve() + expect(disposed).toBe(false) + releaseCleanup.resolve(undefined) + await disposal expect(entryNames(ctx)).not.toContain(NATIVE) expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) expect(ctx.get('directoryPicker')).toBeUndefined() @@ -245,7 +259,9 @@ describe('real Loader composition', () => { it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => { stubAttendedHost() - await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/) + const { ctx } = await loadComposition('127.0.0.1', { failSurface: true }) + const chooser = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + await expect(chooser.fiber!.await()).rejects.toThrow(/directory-picker-auto: failed to load/) // Setup owns both entries until it returns its disposer, so a failed surface // must take the mounted backend with it: otherwise a retry collides with the diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 2bf3516db2..1887897246 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -86,7 +86,7 @@ describe('PluginInventoryGateway', () => { fiberPhase: null, }) - await ctx.loader.remove(pendingId) + ctx.loader.remove(pendingId) expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false) }) diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 04bbe9b4cf..9db21bd537 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -361,25 +361,13 @@ describe('real Loader composition', () => { const firstRoot = root root = undefined // keep the first composition's files until the end - // loader.await() never rejects (allSettled); the bind failure surfaces as - // a FAILED fiber whose error escapes as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it here the same - // way, and assert it really is the bind error. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) let second: Context | undefined try { second = await loadComposition(takenPort) const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') expect(entry?.fiber?.state).toBe(FiberState.FAILED) - // The rejection escapes a tick after loader.await() settles; bounded poll. - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') + await expect(entry?.fiber?.await()).rejects.toThrow('EADDRINUSE') } finally { - process.off('unhandledRejection', onUnhandled) await second?.fiber.dispose() context = first if (root !== undefined) await rm(root, { recursive: true, force: true }) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 280afb90b8..d09a22a02f 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -194,6 +194,7 @@ describe('DeepSeek plugin package inventory', () => { await ctx.loader.create({ name: 'versioned-plugin/plugin.mjs' }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(composition).href } }) + await ctx.loader.await() const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([ diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 2ddf994845..c560b84ff8 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 6ef3374a5f1ae6b83005073f80d63d156ee92f9c -README.zh.md: a1be6d634e592126711befad4ffcb06ca0bdc26c +README.md: ff03d71ddd9360846597101026e5e8662fc2a370 +README.zh.md: 2d96b0948628f6a7c034b48d683fcfe737ae7d78 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 6ef3374a5f..ff03d71ddd 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -126,7 +126,7 @@ This section explains the design behind the roster and the standing mount; obser ### The mount audit -A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. +A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects an import or activation failure, an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. ### Authoring mechanics diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index a1be6d634e..2d96b09486 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -126,7 +126,7 @@ agent-presets: ### 挂载审计 -直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 +直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝导入或激活失败、无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 ### 创作机制 diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 1e273ba950..c13530f2c9 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -295,24 +295,28 @@ export function serviceForAgent( /** * Rows that did not reach a usable state, each rendered as one diagnostic line. * - * A row whose module failed to import or whose plugin threw already rejects the - * mount through the loader; what remains observable here is a row still waiting - * for a service the composition never supplies. + * Wait for the subtree, then report import failures, activation failures, and + * rows waiting for services the composition does not supply. * @param tree - the mounted subtree. * @returns one line per unusable row, empty when every enabled row is usable. */ -export function inactiveRows(tree: EntryTree): string[] { +export async function inactiveRows(tree: EntryTree): Promise { + await tree.await() const lines: string[] = [] for (const entry of tree.entries()) { if (entry.disabled) continue const fiber = entry.fiber - /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed, - so a settled tree never holds an enabled fiber-less entry; the branch exists - only because `Entry.fiber` is declared optional. */ if (fiber === undefined) { lines.push(`${entry.options.id} (${entry.options.name}): never started`) continue } + try { + await fiber.await() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`) + continue + } const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) if (missing.length > 0) { lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) @@ -400,7 +404,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */ if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree') const { tree, fiber } = subtree - const unusable = inactiveRows(tree) + const unusable = await inactiveRows(tree) if (unusable.length > 0) { throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`) } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 6c43c2192c..03b9daa7a0 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -255,20 +255,13 @@ describe('rejecting a composition that cannot be used', () => { }) it('names every failed row, not just the count', async () => { - // The Loader folds several failed rows into one AggregateError whose own - // message names none of them; unflattened, the operator is told only that - // "loader entries failed to apply" and has nothing to act on. await expect(agentOn(ctx, 'sess-two-broken', 'two-broken')) .rejects.toThrow(/first-refuses[\s\S]*second-refuses/) }) it('names the rows inside a failed group, not the group alone', async () => { - // The Loader's per-row wrapper keeps only `cause.message`, so a group's - // own AggregateError arrives with its `errors` reachable through `cause` - // alone. Reading the message stops at "loader entries failed to apply" - // and names neither row that actually refused. await expect(agentOn(ctx, 'sess-nested-broken', 'nested-broken')) - .rejects.toThrow(/outer[\s\S]*inner-first[\s\S]*inner-second/) + .rejects.toThrow(/inner-first[\s\S]*inner-second/) }) it('names the unresolved service when a row never activates', async () => { diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index b99909fa2a..7365651f21 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -87,6 +87,7 @@ async function boot(configLines: readonly string[]): Promise { } as unknown as NonNullable await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() + for (const entry of ctx.loader.entries()) await entry.fiber?.await() return ctx } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcea481379..3eb1aba574 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1255,6 +1255,9 @@ importers: '@deepseek-ai/dsh-package-manifest': specifier: workspace:^ version: link:../../util/package-manifest + chokidar: + specifier: 4.0.3 + version: 4.0.3 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -1292,9 +1295,6 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 - chokidar: - specifier: 4.0.3 - version: 4.0.3 packages/boot/cmdline: devDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cde062a553..68eff9cd1c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,10 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime +# Vendored framework packages keep their upstream semver ranges, while local +# builds must resolve those matching names to this workspace's pinned sources. +linkWorkspacePackages: true + overrides: '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' '@deepseek-ai/schemastery': 'link:vendor/schemastery' diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts new file mode 100644 index 0000000000..93b8390157 --- /dev/null +++ b/scripts/verify-vendored-links.ts @@ -0,0 +1,72 @@ +/** + * Verify that pnpm-lock.yaml resolves every vendored package name to its + * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` + * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the + * pinned vendored sources; a registry copy of the same name coexisting with + * the vendored one silently forks the framework layer (vendor/README.md). + */ +import { readdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import * as yaml from 'js-yaml' + +const root = resolve(import.meta.dirname, '..') + +async function vendoredNames(): Promise> { + const names = new Set() + for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue + let manifest: { name?: string } + try { + manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } + } catch { + continue // not a package directory (e.g. vendor/README.md siblings) + } + if (manifest.name !== undefined) names.add(manifest.name) + } + return names +} + +interface Lockfile { + importers?: Record> + packages?: Record + snapshots?: Record +} + +const names = await vendoredNames() +if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') +const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile + +const violations: string[] = [] + +// Importer resolutions: every dependency entry naming a vendored package must +// resolve to a link:, or the build silently uses a registry copy. +for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { + for (const [section, dependencies] of Object.entries(sections)) { + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const [dependency, entry] of Object.entries(dependencies as Record)) { + if (!names.has(dependency)) continue + const version = entry.version ?? '' + if (!version.startsWith('link:')) { + violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) + } + } + } +} + +// Package/snapshot keys: a registry copy materializes as a `@` +// key; vendored names must never appear there at all. +for (const section of ['packages', 'snapshots'] as const) { + for (const key of Object.keys(lockfile[section] ?? {})) { + const atIndex = key.lastIndexOf('@') + if (atIndex <= 0) continue + const packageName = key.slice(0, atIndex) + if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) + } +} + +if (violations.length > 0) { + console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) + for (const violation of violations) console.error(` - ${violation}`) + process.exit(1) +} +console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/vendor/README.md b/vendor/README.md index 47b3093b76..305f241929 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). @@ -33,17 +33,17 @@ Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. 2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. -4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. +4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. -7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially and undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. -12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. +9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/app-boot/src/watch-config.ts`. Covered by `packages/boot/app-boot/tests/watch-config.spec.ts`. +10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. HMR declares the same Loader and Timer injections with `static inject` instead of decorators. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned even without patches so Loader mutations cannot alter the cached parse. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +12. **`cordis/src/{events,fiber}.ts` and `loader/src/{index,config/entry,config/isolate}.ts` update completion**: `Fiber.update()` returns its update-waterfall result. Entry config updates forward and await that result, while the isolate listener forwards it after its synchronous context changes. This prevents asynchronous restart failures from escaping their callers without adding rollback. The detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. -14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. +14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index c7bf42632e..e9bd85e3e1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -340,7 +340,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 6bb1caa7a5..38a3197e29 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -730,8 +730,8 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * @returns the update waterfall result; the default restart returns a promise. + * @throws when validation, an update listener, or the restarted plugin fails. */ update(config: any, noSave = false) { this.assertActive() @@ -745,7 +745,7 @@ export class Fiber { return } config = this._resolveConfig(config) - this.context.waterfall(this, 'internal/update', config, noSave, () => { + return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index ccd399622a..d5d31305ae 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -20,7 +20,6 @@ declare module '@deepseek-ai/cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void - } } diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index cd7406bbab..c3bfdc60ee 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -44,7 +44,8 @@ function retryableWriteError(error: unknown): boolean { * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated: patching shared entry objects would bake earlier patch + * is never mutated and the result is always detached from it (even with no + * patches): patching or mounting shared entry objects would bake earlier * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -59,8 +60,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { - if (!patches?.length) return [...data] data = structuredClone(data) + if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -251,8 +252,8 @@ export class Include extends EntryTree { // never be mislabelled as absent or silently overwritten. if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error if (this.config.initial) { - this.writeFile(this.config.initial as any) - await this.read() + await this._writeFile(this.config.initial as any) + await this.read(true) } else { throw new Error(`config file not found: ${this.filename}`) } @@ -263,8 +264,12 @@ export class Include extends EntryTree { } async stop() { - await this.root.stop() - await this.flushWrite() + try { + await this.flushWrite() + } finally { + this.root.stop() + await this.flushWrite() + } } /** diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 7cf2520af3..9e1258dd6b 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -96,11 +96,11 @@ export class Entry { } private _patchContext(diff: string[]) { - this.context.waterfall('loader/patch-context', this, () => { + return this.context.waterfall('loader/patch-context', this, () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - this.fiber.update(this.options.config, true) + return this.fiber.update(this.options.config, true) } }) } @@ -142,7 +142,7 @@ export class Entry { .filter(key => !deepEqual(this.options[key], legacy[key])) if (!diff.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) - this._patchContext(diff) + await this._patchContext(diff) } else { await this.init() } @@ -165,10 +165,11 @@ export class Entry { } finally { this._initTask = undefined } - this.fiber?.await().finally(() => { + const notify = () => { if (this.loader.getTasks().length) return this.ctx.reflect.notify(['loader']) - }) + } + this.fiber?.await().then(notify, notify) } private async _init() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index e2fd539157..102c7c41c9 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - next() + const result = next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { @@ -150,6 +150,7 @@ export default function isolate(ctx: Context) { delete entry.ctx[delims[name]] } } + return result }) ctx.on('loader/partial-dispose', (entry, legacy, active) => { diff --git a/vendor/loader/src/config/utils.ts b/vendor/loader/src/config/utils.ts index cd706c0407..8e28be0c4c 100644 --- a/vendor/loader/src/config/utils.ts +++ b/vendor/loader/src/config/utils.ts @@ -8,7 +8,7 @@ export const evaluate = new Function('ctx', 'expr', ` } `) as ((ctx: object, expr: string) => any) -/** Recursively replace YAML `!js` expression nodes with evaluated values. */ +/** Recursively replace YAML `!!js` expression nodes with evaluated values. */ export function interpolate(ctx: object, value: any) { if (isJsExpr(value)) { return evaluate(ctx, value.__jsExpr) diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index ac736ae356..1231b4d357 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -26,7 +26,7 @@ declare module '@deepseek-ai/cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void): void + 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise } interface Context { diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 828339371e..92f9b09b82 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -14,6 +14,15 @@ "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/index.mjs", + "require": "./lib/index.cjs" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, "files": [ "lib/index.mjs", "lib/index.cjs", From d225dbba5034298505246fa81625d30e3db994a4 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 9 Sep 2026 18:34:11 +0800 Subject: [PATCH 15/60] Remove nonessential remnants of Cordis PR 932 --- ...26-09-09-nontransactional-loader.i18n.yaml | 4 +- .../2026-09-09-nontransactional-loader.md | 6 +- .../2026-09-09-nontransactional-loader.zh.md | 6 +- docs/cordis-api/fiber.i18n.yaml | 4 +- docs/cordis-api/fiber.md | 6 +- docs/cordis-api/fiber.zh.md | 6 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + docs/rescope.i18n.yaml | 4 +- docs/rescope.md | 2 +- docs/rescope.zh.md | 2 +- package.json | 1 - packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 1 + packages/boot/app-boot/README.zh.md | 1 + packages/boot/app-boot/src/index.ts | 6 ++ packages/boot/app-boot/tests/app-boot.spec.ts | 20 ++++++ .../boot/app-boot/tests/user-patches.spec.ts | 7 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 10 +-- packages/bundle/web-app/tests/web-app.spec.ts | 26 ++++++- packages/preset/agent-presets/src/mount.ts | 23 ++---- .../preset/agent-presets/tests/mount.spec.ts | 23 +++++- pnpm-workspace.yaml | 4 -- scripts/rescope-vendor.ts | 11 +-- scripts/run-gates.spec.ts | 2 +- scripts/run-gates.ts | 1 - scripts/verify-vendored-links.ts | 72 ------------------- vendor/README.md | 10 +-- vendor/cordis/src/events.ts | 2 +- vendor/cordis/src/fiber.ts | 6 +- vendor/hmr/README.md | 6 +- vendor/hmr/src/index.ts | 6 +- vendor/include/src/index.ts | 5 +- vendor/loader/src/config/entry.ts | 6 +- vendor/loader/src/config/isolate.ts | 3 +- vendor/loader/src/index.ts | 2 +- 40 files changed, 147 insertions(+), 165 deletions(-) delete mode 100644 scripts/verify-vendored-links.ts diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml index 0cb0ade9b5..0e0de823e8 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.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/simplification/2026-09-09-nontransactional-loader.md -2026-09-09-nontransactional-loader.md: e3d43e6d56235e35d1a273900a935a7df19318be -2026-09-09-nontransactional-loader.zh.md: 55abb03a1f72c9e88304033d76d3db80bb28f916 +2026-09-09-nontransactional-loader.md: 7ea4db0abf0fb65e443126dbca9fd822e47ce2d0 +2026-09-09-nontransactional-loader.zh.md: 5293efb58998f68a25e1143a2d043da32ef06bfb diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md index e3d43e6d56..7ea4db0abf 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md @@ -14,9 +14,11 @@ Revert the five commits in [#932](https://github.com/deepseek-harness/deepseek-h Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree. -Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. These adaptations are separate from the reverse patch. +Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch. -The small update-result extension forwards asynchronous restart failures through Fiber and Entry updates. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. Missing-file initialization waits for its write and rereads the file before mounting initial entries. +Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. + +Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:^` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md index 55abb03a1f..5293efb589 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md @@ -14,9 +14,11 @@ Status: implemented Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。 -应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。这些适配与反向补丁分开。 +应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。 -小范围的更新结果扩展通过 Fiber 和 Entry 更新传递异步重启失败。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。缺失文件的初始化等待写入完成,并重新读取文件后才挂载初始条目。 +Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。 + +保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:^` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 ## 考虑过的替代方案 diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 537be01dbc..98b54791f3 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.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/cordis-api/fiber.md -fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a -fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b +fiber.md: 3044301effde267bf580c3a5138481b10513f755 +fiber.zh.md: bee2f523f1fa2a2add2afc3a3fc322903f1ef6f0 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 182b77390b..3044301eff 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config. * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) ``` @@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o - `config` — the new raw config; validated before anything restarts. - `noSave` — hint for persistence hooks not to write the change back. -**Returns** the update waterfall result; the default restart returns a promise. +**Returns** nothing; the restart runs behind the `internal/update` waterfall. [Source](../../vendor/cordis/src/fiber.ts#L736) diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index 9ed3e52618..bee2f523f1 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -258,8 +258,8 @@ dispose 此插件,并立即使用其当前配置重新加载。 * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) ``` @@ -271,7 +271,7 @@ update(config: any, noSave = false) - `config`:新的原始配置;在任何内容重新启动前进行校验。 - `noSave`:提示持久化钩子不要写回此变更。 -**返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。 +**返回**无返回值;重启由 `internal/update` waterfall 执行。 [源码](../../vendor/cordis/src/fiber.ts#L736) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 09eecc6d42..477779842a 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 -event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b +event-producer-consumer.md: 90dad01703601b58f7f495905d94cffbf18999b8 +event-producer-consumer.zh.md: f617b910165fec096e4ed4347f89c4323fe6f8e9 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2239c9786f..90dad01703 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -84,5 +84,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | +| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e43d48e2ee..f617b91016 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -86,5 +86,6 @@ | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | +| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index 620421da84..ed6948f492 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.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/rescope.md -rescope.md: 676dee2507a974beb20a4c0e8059b6a74aa9c0c9 -rescope.zh.md: 1683111354b1ff7c8771a87b7b03bcbeb0ac0761 +rescope.md: 80a0b1dc67c8b3585f2c18cad3afff9dde5859f9 +rescope.zh.md: aaf65024520308c5d0013d85a128a42839a84573 diff --git a/docs/rescope.md b/docs/rescope.md index 676dee2507..80a0b1dc67 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@ ## What the rename does not touch - **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version. -- **Dependency ranges.** A dependency entry changes its key, never its range: `"cordis": "^4.0.0-rc.7"` becomes `"@deepseek-ai/cordis": "^4.0.0-rc.7"`. `linkWorkspacePackages` resolves those preserved ranges to the pinned workspaces. +- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use `workspace:^` for repository-owned runtime dependencies, so pnpm resolves the pinned local packages and substitutes release ranges when publishing. - **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name. - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 1683111354..aaf6502452 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R ## 改名不碰什么 - **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。 -- **依赖 range。** 依赖条目只换键、不换范围:`"cordis": "^4.0.0-rc.7"` 变成 `"@deepseek-ai/cordis": "^4.0.0-rc.7"`;`linkWorkspacePackages` 靠这些保留下来的范围把它们解析到固定的 workspace。 +- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单对仓库内的运行时依赖使用 `workspace:^`,因此 pnpm 会解析到固定的本地包,并在发布时替换为版本范围。 - **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。 - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 diff --git a/package.json b/package.json index a09fa3f997..d5a5b1c41b 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,6 @@ "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", - "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ce5f0f1cee..dec2c2a582 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: bbc3e3147a455149d3e263f0bf4f1091d93f46cc -README.zh.md: 53d8b58df6da74581539585bce81d885f7708cf8 +README.md: 8ead27c1a6a3544f34b391facfc3888f50ba479f +README.zh.md: c4b933249eb9c769afb4e49ec525e6174a1903b5 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index bbc3e3147a..8ead27c1a6 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -88,6 +88,7 @@ This section explains how the outcomes above are realized and points at the code - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. +- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. ### Helper behavior diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 53d8b58df6..c4b933249e 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -88,6 +88,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 +- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 ### Helper 行为 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 8cfc8f5678..05da302eb0 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -270,6 +270,7 @@ export async function watchUserPatches( }, }) await ctx.loader.await() + await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await()))) await assertEntriesActivated(ctx, binName) }) try { @@ -799,6 +800,11 @@ export async function boot( try { ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) + // Fiber.update() discards the restart promise. Observe it before the + // waterfall returns; activation audits still report the failed fiber. + ctx.on('internal/update', (_config, _noSave, next: () => unknown) => { + void Promise.resolve(next()).catch((error: unknown) => { ctx.logger.error(error) }) + }, { global: true, prepend: true }) await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 61fda39717..537d4efad5 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -786,6 +786,20 @@ describe('boot', () => { expect(ctx.get('loader')).toBeUndefined() }) + it('returns when disposal completes before root entry creation returns', async () => { + const dir = tmp() + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml'), [], (ctx) => { + const create = ctx.loader.create.bind(ctx.loader) + vi.spyOn(ctx.loader, 'create').mockImplementation(async (...args) => { + const id = await create(...args) + await ctx.fiber.dispose() + return id + }) + }) + expect(ctx.get('loader')).toBeUndefined() + }) + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') @@ -841,6 +855,12 @@ describe('boot', () => { ) }) + it('reports a non-Error rejection from host preparation', async () => { + await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], () => { + throw 'host refused' + })).rejects.toThrow(`${NAME}: host preparation failed: host refused`) + }) + it('expands a stackless aggregate at the deepest activation cause', async () => { const dir = tmp() const aggregate = new AggregateError([ diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 17c2f6bb84..efb89afb86 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -503,9 +503,14 @@ describe('boot with user patches', () => { await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) const previousFactory = configWatch.create onTestFinished(() => { configWatch.create = previousFactory }) + const release = Promise.withResolvers() + ctx.effect(() => () => release.promise) + let disposal: Promise | undefined + onTestFinished(async () => { release.resolve(undefined); await disposal }) configWatch.create = (options) => { const watcher = new FSWatcher(options) - void ctx.fiber.dispose().then(() => { watcher.emit('ready') }) + disposal = ctx.fiber.dispose() + queueMicrotask(() => { watcher.emit('ready') }) return watcher } const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index e04aaa88d7..d4b3f1a257 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: aea694942173a856861d00a69f15b451cc930976 -README.zh.md: f1b402c985ec2c21afdd67f8cb5477196aafeb44 +README.md: 94560cf45affe0ee4c9570054c40c00e9185901b +README.zh.md: 89195c9d8be67f73fe90e6d16af271b7a7ca35bd diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index aea6949421..94560cf45a 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -77,7 +77,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve ### Readiness -The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles and Connection authentication is available — or immediately in a hand-built tree without a Loader. A tree disposed mid-boot announces nothing. +The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, every enabled entry passes the activation audit, and Connection authentication is available — or immediately in a hand-built tree without a Loader. A failed tree or a tree disposed mid-boot announces nothing. ### LAN trust sampling diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index f1b402c985..89195c9d8b 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -77,7 +77,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己 ### 就绪宣告 -URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。启动中途被释放的树不会宣告任何内容。 +URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、每个启用的条目通过激活检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。失败的树或启动中途被释放的树不会宣告任何内容。 ### LAN 信任采样 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 63edf46529..b5debdc26f 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -18,7 +18,7 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import { addHarnessSourceSection, assertEntriesActivated } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' @@ -284,15 +284,17 @@ export function apply(ctx: Context, config: Config): void { const settled = connectionCtx.get('loader')?.await() if (settled === undefined) announceReady() else { - void settled.then(() => { + void settled.then(async () => { + await assertEntriesActivated(connectionCtx, 'dsh web') // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line or browser tab for a dead server would only // mislead, and reading torn-down services would turn a clean shutdown // into a crash. if (connectionCtx.get('webServer') !== undefined && connectionCtx.get('connection') !== undefined) announceReady() - // Loader reports a failed boot; this row only stays quiet. - }, () => {}) + }).catch(() => { + // Boot owns the failure diagnostic; readiness remains unpublished. + }) } }) } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 3e17147419..e963648038 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -11,8 +11,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { WebServer } from '@deepseek-ai/dsh-host-webserver' @@ -100,7 +101,7 @@ function provideConnection(ctx: Context): void { /** A fake Loader whose settlement the test controls (the URL line waits on it). */ function provideLoader(ctx: Context, settle: () => Promise = async () => {}): void { - ctx.provide('loader', { await: settle } as never) + ctx.provide('loader', { await: settle, entries: () => [] } as never) } interface BashContribution { @@ -313,6 +314,27 @@ describe('web-app runtime glue', () => { await torn.fiber.dispose() }) + it('keeps readiness private when Loader settles with a failed sibling', async () => { + stageDist() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.baseUrl = 'file:///' + ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.failure = () => { throw new Error('sibling rejected') } + const id = await ctx.loader.create({ name: 'cordis:failure' }) + await ctx.loader.await() + await expect(ctx.loader.resolve(id).fiber?.await()).rejects.toThrow('sibling rejected') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setImmediate(resolve)) + expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() + }) + it('fails loud when the prompt section resolves against a portless webserver', async () => { stageDist() const ctx = new Context() diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index c13530f2c9..e759230505 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -313,7 +313,7 @@ export async function inactiveRows(tree: EntryTree): Promise { try { await fiber.await() } catch (error) { - const detail = error instanceof Error ? error.message : String(error) + const detail = mountDetail(error) lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`) continue } @@ -328,14 +328,8 @@ export async function inactiveRows(tree: EntryTree): Promise { /** * The causes of `error` whose detail its own message does not already carry. * - * `AggregateError` names none of its causes in its own message, so its - * `errors` are the branches. The Loader's per-row wrapper takes the opposite - * approach: it appends `cause.message` to the message it builds and keeps the - * cause only as `error.cause`, so following a plain chain would print every - * line twice. That leaves exactly one lossy shape — a wrapped row whose cause - * is an `AggregateError`. Its message ends with the aggregate's own line and - * drops the `errors` behind it, which is how a failed group reports as - * "loader entries failed to apply" and names none of the rows that failed. + * Aggregate errors carry separate member messages. A wrapper can preserve the + * aggregate as its cause without including those messages in its own text. * @param error - the failure to read branches from. * @returns the branches to render beneath `error.message`, possibly empty. */ @@ -347,19 +341,12 @@ function detailBranches(error: Error): readonly unknown[] { /** * The reportable text of a mount failure. * - * The loader reports several failed rows as one `AggregateError`, whose own - * message names none of them; without flattening, a composition that fails on - * two rows says only "loader entries failed to apply" and the operator has - * nothing to act on. Nested groups indent under the row that owns them, so a - * composition failing inside a group still names the rows rather than the - * group alone. + * A plugin may reject with an aggregate or wrap one as its cause. Include its + * member messages beneath the row diagnostic so each failure is visible. * @param error - the value the mount rejected with. * @returns a single-line-per-cause description. */ function mountDetail(error: unknown): string { - /* v8 ignore next -- every path into the mount's catch throws an Error: the loader - wraps a row's thrown value before it propagates, and this module's own - rejections are Errors. The fallback keeps a hostile value readable. */ if (!(error instanceof Error)) return String(error) const branches = detailBranches(error) if (branches.length === 0) return error.message diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 03b9daa7a0..660fdcdecd 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -15,7 +15,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh- import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { - COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent, + COMPOSITION_FILE, inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-agent-presets/types' @@ -242,6 +242,27 @@ describe('composing a child agent from its parent', () => { }) describe('rejecting a composition that cannot be used', () => { + it('reports import failures and arbitrary plugin rejections after eager settlement', async () => { + ctx.loader.builtins.stringFailure = () => { throw 'string rejection' } + ctx.loader.builtins.aggregateFailure = () => { + throw new AggregateError([ + new Error('first member'), + new Error('wrapped member', { cause: new AggregateError(['nested member'], 'nested aggregate') }), + ], 'aggregate rejection') + } + await ctx.loader.root.update([ + { id: 'missing', name: 'cordis:missingBuiltin' }, + { id: 'disabled', name: 'cordis:missingBuiltin', disabled: true }, + { id: 'string', name: 'cordis:stringFailure' }, + { id: 'aggregate', name: 'cordis:aggregateFailure' }, + ]) + expect(await inactiveRows(ctx.loader)).toEqual([ + 'missing (cordis:missingBuiltin): never started', + 'string (cordis:stringFailure): string rejection', + 'aggregate (cordis:aggregateFailure): aggregate rejection\n- first member\n- wrapped member\n - nested member', + ]) + }) + it('refuses to mount into a context that carries no agent scope', async () => { await expect(ctx.agentPresets.mount(ctx, 'standard')) .rejects.toThrow(/unscoped context/) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68eff9cd1c..cde062a553 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,10 +14,6 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime -# Vendored framework packages keep their upstream semver ranges, while local -# builds must resolve those matching names to this workspace's pinned sources. -linkWorkspacePackages: true - overrides: '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' '@deepseek-ai/schemastery': 'link:vendor/schemastery' diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 5a73201bf0..ec75d2938b 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -222,15 +222,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [ { id: 'vendor-readme-preamble', file: 'vendor/README.md', - find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.', - replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.', - expect: 1, - }, - { - id: 'vendor-readme-schemastery-note', - file: 'vendor/README.md', - find: 'whose lazy `require(\'cosmokit\')` can race', - replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race', + find: 'All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo.', + replace: 'All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges.', expect: 1, }, { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index a5ad767c32..de9ca46d88 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -213,7 +213,7 @@ describe('gate graph validation', () => { 'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config', - 'runtime-closure', 'vendored-links', + 'runtime-closure', ]) expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ workers: 4, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3918c87d38..9dac9e6560 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -286,7 +286,6 @@ export function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates(), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('vendored-links', 'verify-vendored-links', { label: 'vendored links' }), ] case 'doc-sync': return docSyncLeafGates() diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts deleted file mode 100644 index 93b8390157..0000000000 --- a/scripts/verify-vendored-links.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Verify that pnpm-lock.yaml resolves every vendored package name to its - * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` - * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the - * pinned vendored sources; a registry copy of the same name coexisting with - * the vendored one silently forks the framework layer (vendor/README.md). - */ -import { readdir, readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import * as yaml from 'js-yaml' - -const root = resolve(import.meta.dirname, '..') - -async function vendoredNames(): Promise> { - const names = new Set() - for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { - if (!entry.isDirectory()) continue - let manifest: { name?: string } - try { - manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } - } catch { - continue // not a package directory (e.g. vendor/README.md siblings) - } - if (manifest.name !== undefined) names.add(manifest.name) - } - return names -} - -interface Lockfile { - importers?: Record> - packages?: Record - snapshots?: Record -} - -const names = await vendoredNames() -if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') -const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile - -const violations: string[] = [] - -// Importer resolutions: every dependency entry naming a vendored package must -// resolve to a link:, or the build silently uses a registry copy. -for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { - for (const [section, dependencies] of Object.entries(sections)) { - if (typeof dependencies !== 'object' || dependencies === null) continue - for (const [dependency, entry] of Object.entries(dependencies as Record)) { - if (!names.has(dependency)) continue - const version = entry.version ?? '' - if (!version.startsWith('link:')) { - violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) - } - } - } -} - -// Package/snapshot keys: a registry copy materializes as a `@` -// key; vendored names must never appear there at all. -for (const section of ['packages', 'snapshots'] as const) { - for (const key of Object.keys(lockfile[section] ?? {})) { - const atIndex = key.lastIndexOf('@') - if (atIndex <= 0) continue - const packageName = key.slice(0, atIndex) - if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) - } -} - -if (violations.length > 0) { - console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) - for (const violation of violations) console.error(` - ${violation}`) - process.exit(1) -} -console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/vendor/README.md b/vendor/README.md index 305f241929..106585c6fb 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges. Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). @@ -31,7 +31,7 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages. +2. **All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. Repository-owned dependencies use the workspace protocol. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. @@ -39,9 +39,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. 9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/app-boot/src/watch-config.ts`. Covered by `packages/boot/app-boot/tests/watch-config.spec.ts`. -10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. HMR declares the same Loader and Timer injections with `static inject` instead of decorators. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned even without patches so Loader mutations cannot alter the cached parse. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. -12. **`cordis/src/{events,fiber}.ts` and `loader/src/{index,config/entry,config/isolate}.ts` update completion**: `Fiber.update()` returns its update-waterfall result. Entry config updates forward and await that result, while the isolate listener forwards it after its synchronous context changes. This prevents asynchronous restart failures from escaping their callers without adding rollback. The detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). +11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned when patches are present; without patches, the returned array retains the entry objects. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +12. **`loader/src/config/entry.ts` activation observer**: the detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit consumer audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index e9bd85e3e1..c7bf42632e 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -340,7 +340,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 38a3197e29..6bb1caa7a5 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -730,8 +730,8 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) { this.assertActive() @@ -745,7 +745,7 @@ export class Fiber { return } config = this._resolveConfig(config) - return this.context.waterfall(this, 'internal/update', config, noSave, () => { + this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md index eec6ff940f..c9a9cca667 100644 --- a/vendor/hmr/README.md +++ b/vendor/hmr/README.md @@ -8,10 +8,8 @@ application files. Changes to framework-level dependencies fall back to `loader.exit()`, letting the host process restart. Module watches canonicalize their existing base directory before opening -Chokidar. Exact config watches likewise canonicalize the deepest existing -ancestor, then restore any missing suffix. Callbacks and diagnostics retain the -requested absolute filename, while the native backend receives one filesystem -spelling even when Windows supplied an 8.3 alias. +Chokidar, so the native backend receives one filesystem spelling even when +Windows supplied an 8.3 alias. ## Requirements diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index d5d31305ae..e7aed96164 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,4 +1,4 @@ -import { Context, Service, type Plugin } from '@deepseek-ai/cordis' +import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis' import type { Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import type { Include } from '@deepseek-ai/cordis-plugin-include' @@ -45,9 +45,9 @@ interface Reload { runtime?: Plugin.Runtime } +@Inject('loader') +@Inject('timer') class Hmr extends Service { - static inject = ['loader', 'timer'] - public baseDir: string private internal: ModuleLoader diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index c3bfdc60ee..ee4dd53fb8 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -44,8 +44,7 @@ function retryableWriteError(error: unknown): boolean { * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated and the result is always detached from it (even with no - * patches): patching or mounting shared entry objects would bake earlier + * is never mutated: patching shared entry objects would bake earlier patch * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted entries are indexed * as they are added, so a later patch in the same list can target a row an @@ -60,8 +59,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { + if (!patches?.length) return [...data] data = structuredClone(data) - if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 9e1258dd6b..11cffa24d7 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -96,11 +96,11 @@ export class Entry { } private _patchContext(diff: string[]) { - return this.context.waterfall('loader/patch-context', this, () => { + this.context.waterfall('loader/patch-context', this, () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - return this.fiber.update(this.options.config, true) + this.fiber.update(this.options.config, true) } }) } @@ -142,7 +142,7 @@ export class Entry { .filter(key => !deepEqual(this.options[key], legacy[key])) if (!diff.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) - await this._patchContext(diff) + this._patchContext(diff) } else { await this.init() } diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 102c7c41c9..e2fd539157 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - const result = next() + next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { @@ -150,7 +150,6 @@ export default function isolate(ctx: Context) { delete entry.ctx[delims[name]] } } - return result }) ctx.on('loader/partial-dispose', (entry, legacy, active) => { diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 1231b4d357..ac736ae356 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -26,7 +26,7 @@ declare module '@deepseek-ai/cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise + 'loader/patch-context'(entry: Entry, next: () => void): void } interface Context { From 2376d210bd776d2a529fc4e0e4342b7a83ed7510 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 13:36:05 +0800 Subject: [PATCH 16/60] Preserve nested plugin errors after Cordis revert --- packages/boot/app-boot/README.i18n.yaml | 4 ++-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 12 ++++++++---- packages/boot/app-boot/tests/app-boot.spec.ts | 18 ++++++++++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 389919113a..a9eadabcc5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 8ead27c1a6a3544f34b391facfc3888f50ba479f -README.zh.md: a074afa1dde0ff9eb90b7dc5a8348ac9bb3920b6 +README.md: 2d67d7061c48205a5592e54e404a23d93ed17b3a +README.zh.md: e177bfa86ca28768b08a4be3a48617b20a68a1e7 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8ead27c1a6..2d67d7061c 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -89,7 +89,7 @@ This section explains how the outcomes above are realized and points at the code - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. -- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. +- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. ### Helper behavior diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a074afa1dd..e177bfa86c 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -89,7 +89,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 -- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 +- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。 ### Helper 行为 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 05da302eb0..5f1906e2b4 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -706,15 +706,19 @@ const FIBER_PENDING = 0 as FiberState.PENDING const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_FAILED = 3 as FiberState.FAILED -/** Render a thrown plugin value without discarding an Error's original stack. */ +/** Render plugin stacks, nested causes, and aggregate member failures. */ function formatActivationError(error: unknown): string { - return error instanceof Error ? error.stack ?? error.message : String(error) + if (!(error instanceof Error)) return String(error) + const details = [error.stack ?? error.message] + if (error.cause !== undefined) details.push(formatActivationError(error.cause)) + if (error instanceof AggregateError) details.push(...error.errors.map(formatActivationError)) + return details.join('\n') } /** * Reject a settled Loader tree when an enabled entry failed or remains inactive. - * Plugin failures include the original thrown stack; pending entries name their - * unresolved services because no plugin error exists for that state. Active + * Plugin failures include original stacks, nested causes, and aggregate members. + * Pending entries name their unresolved services because no plugin error exists for that state. Active * entries require no further wait; only failed fibers are awaited to recover * their private rejection reason. * @param ctx - the settled context whose Loader entries to audit. diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 537d4efad5..bfb8e24355 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -580,6 +580,24 @@ describe('assertEntriesActivated', () => { ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`) }) + it('preserves nested activation causes and aggregate member failures', async () => { + const original = new Error('tool discovery failed') + const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', { + cause: new Error('server rejected discovery'), + }) + const wrapper = new Error('plugin activation failed', { cause: aggregate }) + await expect(assertEntriesActivated(ctxWith([ + { fiber: fiber(3, wrapper), options: { name: 'wrapped-plugin' } }, + ]), NAME)).rejects.toThrow([ + `${NAME}: 1 entry did not activate`, + `wrapped-plugin: ${wrapper.stack!}`, + aggregate.stack!, + (aggregate.cause as Error).stack!, + original.stack!, + 'transport closed', + ].join('\n')) + }) + it('reports unresolved services for pending entries', async () => { let awaitCalls = 0 const expected = [ From e4e0e78dd2d95aed7f5828fb5cd6dc71bd43dac7 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 14:16:35 +0800 Subject: [PATCH 17/60] feat(home-paths): add dshCachePath --- ...7-24-single-harness-home-resolver.i18n.yaml | 4 ++-- .../2026-07-24-single-harness-home-resolver.md | 2 ++ ...26-07-24-single-harness-home-resolver.zh.md | 2 ++ packages/util/home-paths/README.i18n.yaml | 4 ++-- packages/util/home-paths/README.md | 5 ++++- packages/util/home-paths/README.zh.md | 5 ++++- packages/util/home-paths/src/index.ts | 9 +++++++++ .../util/home-paths/tests/home-paths.spec.ts | 18 ++++++++++++++++++ 8 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index b8ff3f88c2..ffd6042015 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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-24-single-harness-home-resolver.md -2026-07-24-single-harness-home-resolver.md: 02578674b4547bae54b2edce99c6c4ef53748588 -2026-07-24-single-harness-home-resolver.zh.md: 5764be3f85901196cd2a2db77d1fc3838668163f +2026-07-24-single-harness-home-resolver.md: c4a0023797c5946f979cfc7b895512e362ac956b +2026-07-24-single-harness-home-resolver.zh.md: 0bda1e8405b788ce2939b47a9ca3199ef21fc81c diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md index 02578674b4..c4a0023797 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -23,6 +23,8 @@ explicit configured path > $DSH_HOME > ~/.dsh An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces agent-instructions's bespoke default-vs-`$DSH_HOME` check. +`dshCachePath(...segments)` derives cache paths through `dshHomePath('cache', ...segments)`, keeping cache placement under the same home policy. It resolves paths without creating directories; callers own directory creation. + `@deepseek-ai/dsh-home` is deleted. Home-owning providers and boot packages import `resolveDshHome` from `dsh-home-paths`; composition bundles contain only the resolved configuration rows. `dsh-telemetry` and its separate home policy are absent under the [SDK project toolchain removal](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md), leaving this resolver as the sole home policy. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 5764be3f85..0bda1e8405 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -23,6 +23,8 @@ explicit configured path > $DSH_HOME > ~/.dsh 空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 agent-instructions 中自定义的「默认值 vs `$DSH_HOME`」判断。 +`dshCachePath(...segments)` 通过 `dshHomePath('cache', ...segments)` 派生缓存路径,使缓存位置遵循同一套 home 策略。它只解析路径,不创建目录;目录创建由调用方负责。 + `@deepseek-ai/dsh-home` 被删除。拥有 home 配置的提供方与 boot 包从 `dsh-home-paths` 导入 `resolveDshHome`;组合包只包含解析后的配置行。 `dsh-telemetry` 及其独立 home 策略已随 [SDK 项目工具链移除](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md)一并消失,因此该解析器是唯一的 home 策略。 diff --git a/packages/util/home-paths/README.i18n.yaml b/packages/util/home-paths/README.i18n.yaml index 686bf4de8b..25241e9680 100644 --- a/packages/util/home-paths/README.i18n.yaml +++ b/packages/util/home-paths/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/util/home-paths/README.md -README.md: 2ce9810bd29bdb00f5eb2e09d886f31e2ca3b176 -README.zh.md: 0bd46c223992c37be01876597aa239cfc8eb768d +README.md: 0fb09c3a9d2deede5d23e9d720e23a3e534bbf81 +README.zh.md: f02f49f799d28904241c2ca4550e5c42e03c5186 diff --git a/packages/util/home-paths/README.md b/packages/util/home-paths/README.md index 2ce9810bd2..0fb09c3a9d 100644 --- a/packages/util/home-paths/README.md +++ b/packages/util/home-paths/README.md @@ -29,14 +29,17 @@ Use these helpers wherever a package must agree with the rest of the harness abo ### Resolving the home ```ts -import { resolveDshHome, dshHomePath } from '@deepseek-ai/dsh-home-paths' +import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home-paths' const home = resolveDshHome() // configured path, else $DSH_HOME, else ~/.dsh const settings = dshHomePath('settings') // join one child onto the resolved home +const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models ``` An explicit configured path has the highest precedence, then `$DSH_HOME`, then the default `~/.dsh`. An empty or whitespace-only `$DSH_HOME` is treated as unset, so a blank override never resolves the home to the current working directory. +`dshCachePath(...segments)` derives paths from the resolved home's `cache` directory. With no segments it returns the cache directory itself. It returns an absolute path without creating directories. + ### Displaying a home For user-facing paths, render the root symbolically rather than as a machine path: the default home displays as `~/.dsh` and any configured home displays as `$DSH_HOME`. The display form never leaks an absolute machine path. diff --git a/packages/util/home-paths/README.zh.md b/packages/util/home-paths/README.zh.md index 0bd46c2239..f02f49f799 100644 --- a/packages/util/home-paths/README.zh.md +++ b/packages/util/home-paths/README.zh.md @@ -29,14 +29,17 @@ kind: "package-library" ### 解析主目录 ```ts -import { resolveDshHome, dshHomePath } from '@deepseek-ai/dsh-home-paths' +import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home-paths' const home = resolveDshHome() // configured path, else $DSH_HOME, else ~/.dsh const settings = dshHomePath('settings') // join one child onto the resolved home +const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models ``` 显式配置的路径优先级最高,然后是 `$DSH_HOME`,最后是默认的 `~/.dsh`。空或仅含空白的 `$DSH_HOME` 视为未设置,因此空白的覆盖值绝不会把主目录解析到当前工作目录。 +`dshCachePath(...segments)` 从解析出的主目录下的 `cache` 目录派生路径。不传路径段时返回缓存目录本身。它返回绝对路径,不会创建目录。 + ### 展示主目录 面向用户的路径请以符号形式渲染根目录,而不是机器路径:默认主目录显示为 `~/.dsh`,任何已配置的主目录显示为 `$DSH_HOME`。展示形式绝不会泄露机器的绝对路径。 diff --git a/packages/util/home-paths/src/index.ts b/packages/util/home-paths/src/index.ts index 356489ef9f..bc43dcc32b 100644 --- a/packages/util/home-paths/src/index.ts +++ b/packages/util/home-paths/src/index.ts @@ -99,6 +99,15 @@ export function dshHomePath(...segments: string[]): string { return join(resolveDshHome(), ...segments) } +/** + * Join path segments onto the resolved Harness home's `cache` directory without creating it. + * @param segments - path segments appended to the cache directory; an empty list returns the cache directory itself. + * @returns the normalized absolute cache path. + */ +export function dshCachePath(...segments: string[]): string { + return dshHomePath('cache', ...segments) +} + /** * Describe a resolved harness home symbolically for user-facing display. * diff --git a/packages/util/home-paths/tests/home-paths.spec.ts b/packages/util/home-paths/tests/home-paths.spec.ts index 2162febc69..48fbcb8944 100644 --- a/packages/util/home-paths/tests/home-paths.spec.ts +++ b/packages/util/home-paths/tests/home-paths.spec.ts @@ -7,6 +7,7 @@ import { DSH_HOME_DIR_NAME, canonicalizeWatchPath, defaultDshHome, + dshCachePath, dshHomeDisplay, dshHomePath, expandHomePath, @@ -56,6 +57,22 @@ describe('dsh path helpers', () => { expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') }) + it.each([ + [undefined, join(homedir(), '.dsh')], + ['', join(homedir(), '.dsh')], + [' ', join(homedir(), '.dsh')], + ['~/env-dsh', join(homedir(), 'env-dsh')], + ['./relative-dsh', resolve('./relative-dsh')], + ] as const)('resolves cache paths with DSH_HOME=%j', (home, expectedHome) => { + vi.stubEnv('DSH_HOME', home) + try { + expect(dshCachePath()).toBe(join(expectedHome, 'cache')) + expect(dshCachePath('models', 'index.json')).toBe(join(expectedHome, 'cache', 'models', 'index.json')) + } finally { + vi.unstubAllEnvs() + } + }) + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) const target = join(root, 'target') @@ -63,6 +80,7 @@ describe('dsh path helpers', () => { try { await mkdir(target) await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expect(canonicalizeWatchPath(alias)).resolves.toBe(await realpath(target)) await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe( join(await realpath(target), 'later', 'config.yml'), ) From 85df0e76322a17ce06a39d8bfa3d06537f27a9d3 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 14:24:44 +0800 Subject: [PATCH 18/60] docs(home-paths): align cache helper example --- packages/util/home-paths/README.i18n.yaml | 4 ++-- packages/util/home-paths/README.md | 2 +- packages/util/home-paths/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/util/home-paths/README.i18n.yaml b/packages/util/home-paths/README.i18n.yaml index 25241e9680..4ae627bdd6 100644 --- a/packages/util/home-paths/README.i18n.yaml +++ b/packages/util/home-paths/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/util/home-paths/README.md -README.md: 0fb09c3a9d2deede5d23e9d720e23a3e534bbf81 -README.zh.md: f02f49f799d28904241c2ca4550e5c42e03c5186 +README.md: 64582dd0847896f8b262cc7ded5df3579bea0f24 +README.zh.md: 90cb3cec67877620f09aae5316444887b68ca25f diff --git a/packages/util/home-paths/README.md b/packages/util/home-paths/README.md index 0fb09c3a9d..64582dd084 100644 --- a/packages/util/home-paths/README.md +++ b/packages/util/home-paths/README.md @@ -33,7 +33,7 @@ import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home const home = resolveDshHome() // configured path, else $DSH_HOME, else ~/.dsh const settings = dshHomePath('settings') // join one child onto the resolved home -const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models +const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models ``` An explicit configured path has the highest precedence, then `$DSH_HOME`, then the default `~/.dsh`. An empty or whitespace-only `$DSH_HOME` is treated as unset, so a blank override never resolves the home to the current working directory. diff --git a/packages/util/home-paths/README.zh.md b/packages/util/home-paths/README.zh.md index f02f49f799..90cb3cec67 100644 --- a/packages/util/home-paths/README.zh.md +++ b/packages/util/home-paths/README.zh.md @@ -33,7 +33,7 @@ import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home const home = resolveDshHome() // configured path, else $DSH_HOME, else ~/.dsh const settings = dshHomePath('settings') // join one child onto the resolved home -const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models +const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models ``` 显式配置的路径优先级最高,然后是 `$DSH_HOME`,最后是默认的 `~/.dsh`。空或仅含空白的 `$DSH_HOME` 视为未设置,因此空白的覆盖值绝不会把主目录解析到当前工作目录。 From d911a7b422bcec506f8687a102215ced97950c2a Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 15:08:46 +0800 Subject: [PATCH 19/60] feat(attachment-local): move request images into shared cache --- ...-24-single-harness-home-resolver.i18n.yaml | 4 +- ...2026-07-24-single-harness-home-resolver.md | 2 +- ...6-07-24-single-harness-home-resolver.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/index.ts | 11 +++-- .../attachment-local/src/request-image.ts | 4 +- .../tests/request-image.spec.ts | 41 ++++++++++++++++--- packages/util/home-paths/README.i18n.yaml | 4 +- packages/util/home-paths/README.md | 2 +- packages/util/home-paths/README.zh.md | 2 +- packages/util/home-paths/src/index.ts | 11 +++-- .../util/home-paths/tests/home-paths.spec.ts | 12 ++++++ 14 files changed, 76 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index ffd6042015..83d23518ec 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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-24-single-harness-home-resolver.md -2026-07-24-single-harness-home-resolver.md: c4a0023797c5946f979cfc7b895512e362ac956b -2026-07-24-single-harness-home-resolver.zh.md: 0bda1e8405b788ce2939b47a9ca3199ef21fc81c +2026-07-24-single-harness-home-resolver.md: 1191c52bfa222b807e9aa301b250d2efcc8b0953 +2026-07-24-single-harness-home-resolver.zh.md: cefa124b7cb46bab92157af34576f59d3053da9f diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md index c4a0023797..1191c52bfa 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -23,7 +23,7 @@ explicit configured path > $DSH_HOME > ~/.dsh An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces agent-instructions's bespoke default-vs-`$DSH_HOME` check. -`dshCachePath(...segments)` derives cache paths through `dshHomePath('cache', ...segments)`, keeping cache placement under the same home policy. It resolves paths without creating directories; callers own directory creation. +`dshCachePath(...segments)` derives paths below the resolved home's `cache` directory. An initial `{ dshHome }` option preserves a provider's explicit home override. It resolves paths without creating directories; callers own directory creation. `attachment-local` uses this helper for regenerable request-image variants while retaining durable attachment objects in their versioned storage tree, so clearing the cache cannot remove Session attachments. Existing request-image cache entries are left in place and are not read or copied; a cache miss regenerates the variant from its durable attachment. `@deepseek-ai/dsh-home` is deleted. Home-owning providers and boot packages import `resolveDshHome` from `dsh-home-paths`; composition bundles contain only the resolved configuration rows. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 0bda1e8405..cefa124b7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -23,7 +23,7 @@ explicit configured path > $DSH_HOME > ~/.dsh 空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 agent-instructions 中自定义的「默认值 vs `$DSH_HOME`」判断。 -`dshCachePath(...segments)` 通过 `dshHomePath('cache', ...segments)` 派生缓存路径,使缓存位置遵循同一套 home 策略。它只解析路径,不创建目录;目录创建由调用方负责。 +`dshCachePath(...segments)` 在解析出的主目录下的 `cache` 目录中派生路径。首个 `{ dshHome }` 选项保留提供方显式配置的主目录覆盖值。它只解析路径,不创建目录;目录创建由调用方负责。`attachment-local` 将此函数用于可重新生成的请求图片版本,持久附件对象仍保留在其带版本的存储树中,因此清空缓存不会删除 Session 附件。已有请求图片缓存条目保留在原处,不再读取或复制;缓存未命中时从持久附件重新生成请求版本。 `@deepseek-ai/dsh-home` 被删除。拥有 home 配置的提供方与 boot 包从 `dsh-home-paths` 导入 `resolveDshHome`;组合包只包含解析后的配置行。 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index ebacfdd1df..d2a221e646 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: a14490f855268cdaa65bc0d3d8968ece56d25bf3 -README.zh.md: 44e8205dfdc7debd625c1ad4819f56bcfbe94728 +README.md: c7d5e58cca77bfb182964a22139d6925bdc64501 +README.zh.md: fdfcf02f20cece5f015e438283b6cd4e426f66d9 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index a14490f855..c7d5e58cca 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -85,7 +85,7 @@ Objects land at `/attachments/v1/objects//`; eq Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Request versions live below `/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 44e8205dfd..fdfcf02f20 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 -请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +请求版本位于由 `dshCachePath` 解析的 `/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 7415bf940f..2d9f82bbc5 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -1,6 +1,6 @@ /** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' @@ -15,7 +15,7 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { dshCachePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter, compressionFailure } from './compression-limiter.ts' import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' @@ -166,12 +166,15 @@ export class LocalAttachmentStore extends AttachmentStore { readonly normalizationPolicy: Readonly /** Resolved instance-level compression limit. */ readonly imageCompressionConcurrency: number + private readonly cacheRoot: string private readonly compression: CompressionLimiter private readonly requestInflight = new Map>() constructor(ctx: Context, config: Config) { super(ctx) - this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) + const dshHome = resolveDshHome(config.dshHome) + this.root = join(dshHome, 'attachments', 'v1') + this.cacheRoot = dshCachePath({ dshHome }, 'attachments') this.imageLimits = Object.freeze({ maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, @@ -267,7 +270,7 @@ export class LocalAttachmentStore extends AttachmentStore { if (operation === undefined) { const shared = new SharedRequest(sharedSignal => this.compression.run(async () => { const request = await readRequestImageFile( - this.root, + this.cacheRoot, stored ?? await this.readImage(ref, sharedSignal), policy, sharedSignal, diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 237dc5811f..6497da276f 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -166,8 +166,8 @@ async function writeCached(path: string, data: Uint8Array): Promise { } /** - * Generate or reuse one request image below the local attachment root. - * @param root - absolute versioned attachment storage root. + * Generate or reuse one request image below the local attachment cache root. + * @param root - absolute attachment cache root; variants use its `request-images` child. * @param attachment - verified normalized attachment bytes and reference. * @param policy - exact route request-image policy. * @param signal - optional cancellation for cache I/O and image transformation. diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 821480d028..1aebcbe954 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -9,10 +9,14 @@ import LocalAttachmentStore from '../src/index.ts' const homes: string[] = [] -async function store(): Promise { +async function home(): Promise { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-image-')) homes.push(dshHome) - return new LocalAttachmentStore(new Context(), { dshHome }) + return dshHome +} + +async function store(): Promise { + return new LocalAttachmentStore(new Context(), { dshHome: await home() }) } async function image(width: number, height: number): Promise { @@ -43,6 +47,32 @@ afterEach(async () => { }) describe('local request-image cache', () => { + it('rebuilds a cleared cache without moving or losing durable attachments', async () => { + const dshHome = await home() + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) + const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) + const stored = await attachments.readImage(attachment) + const fileData = Uint8Array.of(0, 1, 2, 255) + const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' }) + const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } + const initial = await attachments.readImageRequest(attachment, policy) + const hash = String(initial.variantId).slice('sha256:'.length) + const cacheRoot = join(dshHome, 'cache') + const path = join(cacheRoot, 'attachments', 'request-images', hash.slice(0, 2), hash) + + expect(attachments.root).toBe(join(dshHome, 'attachments', 'v1')) + await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + await expect(readFile(join(attachments.root, 'request-images', hash.slice(0, 2), hash))) + .rejects.toMatchObject({ code: 'ENOENT' }) + await rm(cacheRoot, { recursive: true }) + + const reopened = new LocalAttachmentStore(new Context(), { dshHome }) + await expect(reopened.readImage(attachment)).resolves.toEqual(stored) + await expect(readFile(reopened.fileHostPath(file))).resolves.toEqual(Buffer.from(fileData)) + await expect(reopened.readImageRequest(attachment, policy)).resolves.toEqual(initial) + await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + }) + it('passes through an in-budget attachment and composes ordered request reads', async () => { const attachments = await store() const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) @@ -81,12 +111,13 @@ describe('local request-image cache', () => { }) it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { - const attachments = await store() + const dshHome = await home() + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } const initial = await attachments.readImageRequest(attachment, policy) const hash = String(initial.variantId).slice('sha256:'.length) - const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) + const path = join(dshHome, 'cache', 'attachments', 'request-images', hash.slice(0, 2), hash) const noisyPixels = new Uint8Array(64 * 64 * 3) let state = 0x2545f491 for (let index = 0; index < noisyPixels.length; index += 1) { diff --git a/packages/util/home-paths/README.i18n.yaml b/packages/util/home-paths/README.i18n.yaml index 4ae627bdd6..0268c25623 100644 --- a/packages/util/home-paths/README.i18n.yaml +++ b/packages/util/home-paths/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/util/home-paths/README.md -README.md: 64582dd0847896f8b262cc7ded5df3579bea0f24 -README.zh.md: 90cb3cec67877620f09aae5316444887b68ca25f +README.md: eb595a7c468ac47128d30e95771a1c0905571834 +README.zh.md: 215317cd42d5d72319cc7af008ba2f4dbe1a26fb diff --git a/packages/util/home-paths/README.md b/packages/util/home-paths/README.md index 64582dd084..eb595a7c46 100644 --- a/packages/util/home-paths/README.md +++ b/packages/util/home-paths/README.md @@ -38,7 +38,7 @@ const cache = dshCachePath('models') // $DSH_HOME/cache/models, default An explicit configured path has the highest precedence, then `$DSH_HOME`, then the default `~/.dsh`. An empty or whitespace-only `$DSH_HOME` is treated as unset, so a blank override never resolves the home to the current working directory. -`dshCachePath(...segments)` derives paths from the resolved home's `cache` directory. With no segments it returns the cache directory itself. It returns an absolute path without creating directories. +`dshCachePath(...segments)` derives paths from the resolved home's `cache` directory. With no segments it returns the cache directory itself. Pass an initial options object, `dshCachePath({ dshHome: home }, ...segments)`, to use an explicit configured home with the same precedence and tilde expansion. It returns an absolute path without creating directories. ### Displaying a home diff --git a/packages/util/home-paths/README.zh.md b/packages/util/home-paths/README.zh.md index 90cb3cec67..215317cd42 100644 --- a/packages/util/home-paths/README.zh.md +++ b/packages/util/home-paths/README.zh.md @@ -38,7 +38,7 @@ const cache = dshCachePath('models') // $DSH_HOME/cache/models, default 显式配置的路径优先级最高,然后是 `$DSH_HOME`,最后是默认的 `~/.dsh`。空或仅含空白的 `$DSH_HOME` 视为未设置,因此空白的覆盖值绝不会把主目录解析到当前工作目录。 -`dshCachePath(...segments)` 从解析出的主目录下的 `cache` 目录派生路径。不传路径段时返回缓存目录本身。它返回绝对路径,不会创建目录。 +`dshCachePath(...segments)` 从解析出的主目录下的 `cache` 目录派生路径。不传路径段时返回缓存目录本身。传入首个选项对象 `dshCachePath({ dshHome: home }, ...segments)` 可使用显式配置的主目录,遵循相同的优先级与波浪号展开规则。它返回绝对路径,不会创建目录。 ### 展示主目录 diff --git a/packages/util/home-paths/src/index.ts b/packages/util/home-paths/src/index.ts index bc43dcc32b..02a479fb89 100644 --- a/packages/util/home-paths/src/index.ts +++ b/packages/util/home-paths/src/index.ts @@ -100,12 +100,15 @@ export function dshHomePath(...segments: string[]): string { } /** - * Join path segments onto the resolved Harness home's `cache` directory without creating it. - * @param segments - path segments appended to the cache directory; an empty list returns the cache directory itself. + * Join path segments onto the resolved Harness home's `cache` directory without creating it; no arguments returns the directory itself. + * @param optionsOrSegment - explicit home override, or the first path segment; omission uses the default home resolution. + * @param segments - additional path segments after the first child, if any. * @returns the normalized absolute cache path. */ -export function dshCachePath(...segments: string[]): string { - return dshHomePath('cache', ...segments) +export function dshCachePath(optionsOrSegment: { dshHome?: string } | string = {}, ...segments: string[]): string { + const configured = typeof optionsOrSegment === 'string' ? undefined : optionsOrSegment.dshHome + const children = typeof optionsOrSegment === 'string' ? [optionsOrSegment, ...segments] : segments + return join(resolveDshHome(configured), 'cache', ...children) } /** diff --git a/packages/util/home-paths/tests/home-paths.spec.ts b/packages/util/home-paths/tests/home-paths.spec.ts index 48fbcb8944..908f7481b3 100644 --- a/packages/util/home-paths/tests/home-paths.spec.ts +++ b/packages/util/home-paths/tests/home-paths.spec.ts @@ -73,6 +73,18 @@ describe('dsh path helpers', () => { } }) + it('resolves configured cache homes before the environment', () => { + vi.stubEnv('DSH_HOME', '~/env-dsh') + try { + expect(dshCachePath({ dshHome: '~/explicit-dsh' })).toBe(join(homedir(), 'explicit-dsh', 'cache')) + expect(dshCachePath({ dshHome: './explicit-dsh' }, 'attachments', 'request-images')) + .toBe(resolve('./explicit-dsh/cache/attachments/request-images')) + expect(dshCachePath({}, 'attachments')).toBe(join(homedir(), 'env-dsh', 'cache', 'attachments')) + } finally { + vi.unstubAllEnvs() + } + }) + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) const target = join(root, 'target') From 7f005e2c0fba29336c922e6d36f834e299bd4210 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 15:17:35 +0800 Subject: [PATCH 20/60] test(attachment-local): isolate fallback cache home --- .../tests/request-image.spec.ts | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 1aebcbe954..d1a7b6404e 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -43,34 +43,42 @@ async function complexOpaqueAlphaImage(width: number, height: number): Promise { + vi.unstubAllEnvs() await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) }) describe('local request-image cache', () => { it('rebuilds a cleared cache without moving or losing durable attachments', async () => { - const dshHome = await home() - const attachments = new LocalAttachmentStore(new Context(), { dshHome }) - const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) - const stored = await attachments.readImage(attachment) - const fileData = Uint8Array.of(0, 1, 2, 255) - const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' }) - const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } - const initial = await attachments.readImageRequest(attachment, policy) - const hash = String(initial.variantId).slice('sha256:'.length) - const cacheRoot = join(dshHome, 'cache') - const path = join(cacheRoot, 'attachments', 'request-images', hash.slice(0, 2), hash) + const fallbackHome = await home() + vi.stubEnv('DSH_HOME', fallbackHome) + try { + const dshHome = await home() + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) + const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) + const stored = await attachments.readImage(attachment) + const fileData = Uint8Array.of(0, 1, 2, 255) + const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' }) + const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } + const initial = await attachments.readImageRequest(attachment, policy) + const hash = String(initial.variantId).slice('sha256:'.length) + const cacheRoot = join(dshHome, 'cache') + const path = join(cacheRoot, 'attachments', 'request-images', hash.slice(0, 2), hash) - expect(attachments.root).toBe(join(dshHome, 'attachments', 'v1')) - await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) - await expect(readFile(join(attachments.root, 'request-images', hash.slice(0, 2), hash))) - .rejects.toMatchObject({ code: 'ENOENT' }) - await rm(cacheRoot, { recursive: true }) + expect(attachments.root).toBe(join(dshHome, 'attachments', 'v1')) + await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + await expect(readFile(join(attachments.root, 'request-images', hash.slice(0, 2), hash))) + .rejects.toMatchObject({ code: 'ENOENT' }) + await rm(cacheRoot, { recursive: true }) - const reopened = new LocalAttachmentStore(new Context(), { dshHome }) - await expect(reopened.readImage(attachment)).resolves.toEqual(stored) - await expect(readFile(reopened.fileHostPath(file))).resolves.toEqual(Buffer.from(fileData)) - await expect(reopened.readImageRequest(attachment, policy)).resolves.toEqual(initial) - await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + const reopened = new LocalAttachmentStore(new Context(), { dshHome }) + await expect(reopened.readImage(attachment)).resolves.toEqual(stored) + await expect(readFile(reopened.fileHostPath(file))).resolves.toEqual(Buffer.from(fileData)) + await expect(reopened.readImageRequest(attachment, policy)).resolves.toEqual(initial) + await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + await expect(readdir(fallbackHome)).resolves.toEqual([]) + } finally { + vi.unstubAllEnvs() + } }) it('passes through an in-budget attachment and composes ordered request reads', async () => { From d76704170b0ec11b5cf82527f2c8fc5d0ca076de Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 15:24:27 +0800 Subject: [PATCH 21/60] refactor(home-paths): simplify cache path dispatch --- packages/util/home-paths/src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/util/home-paths/src/index.ts b/packages/util/home-paths/src/index.ts index 02a479fb89..0d7b934003 100644 --- a/packages/util/home-paths/src/index.ts +++ b/packages/util/home-paths/src/index.ts @@ -106,9 +106,8 @@ export function dshHomePath(...segments: string[]): string { * @returns the normalized absolute cache path. */ export function dshCachePath(optionsOrSegment: { dshHome?: string } | string = {}, ...segments: string[]): string { - const configured = typeof optionsOrSegment === 'string' ? undefined : optionsOrSegment.dshHome - const children = typeof optionsOrSegment === 'string' ? [optionsOrSegment, ...segments] : segments - return join(resolveDshHome(configured), 'cache', ...children) + if (typeof optionsOrSegment === 'string') return dshHomePath('cache', optionsOrSegment, ...segments) + return join(resolveDshHome(optionsOrSegment.dshHome), 'cache', ...segments) } /** From 06c491508f5f2fa78afc6b0a3e8e9f6dc3fccfa9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 10 Sep 2026 16:09:18 +0800 Subject: [PATCH 22/60] =?UTF-8?q?fix(llm-deepseek):=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=8C=89=20V4.1=20token=20=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageRequestPolicy 改为投影规则加可选单边上限加字节目标,投影是 pixel-budget 或 token-grid 的封闭联合。token 网格求解器放在 dsh-attachment 的 request-projection 里,deepSeekImageTokens 复用同一个求解器计价。DeepSeek 路由默认按 14 px patch、3:1 降采样、1024 token 上限的网格投影,每张请求图片加 4096 像素单边上限,字节目标提高到 2 MiB;显式 imagePixelBudget 与 pi-ai 路由仍走总像素预算。缩放只按长边给定,短边由编码器四舍五入,与投影预测一致。变换版本升到 request-image-v6。 Closes #3929 --- .../attachment-local/src/normalization.ts | 12 +- .../attachment-local/src/request-image.ts | 38 ++++- .../attachment-local/tests/index.spec.ts | 2 +- .../tests/request-image-verification.spec.ts | 2 +- .../tests/request-image.spec.ts | 69 ++++++--- packages/attachment/attachment/src/index.ts | 7 +- .../attachment/src/request-projection.ts | 126 ++++++++++++++-- packages/attachment/attachment/src/types.ts | 40 +++++- .../attachment/attachment/tests/index.spec.ts | 4 +- .../tests/request-projection.spec.ts | 66 ++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 8 +- packages/llm/llm-deepseek/src/adapter.ts | 6 +- packages/llm/llm-deepseek/src/image-tokens.ts | 134 ++++-------------- packages/llm/llm-deepseek/src/index.ts | 14 +- .../llm/llm-deepseek/src/request-pricing.ts | 39 ++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 16 +-- .../tests/request-pricing.spec.ts | 29 +++- packages/llm/llm-pi-ai/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/context.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- packages/llm/llm-pi-ai/tests/convert.spec.ts | 2 +- 21 files changed, 407 insertions(+), 213 deletions(-) diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index 7d514786a2..199a50af6b 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -79,14 +79,10 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar /** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */ function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } { - const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels) - const longEdge = Math.max(budgeted.width, budgeted.height) - if (longEdge <= policy.maxDimension) return budgeted - const scale = policy.maxDimension / longEdge - return { - width: Math.max(1, Math.floor(budgeted.width * scale)), - height: Math.max(1, Math.floor(budgeted.height * scale)), - } + return requestImageDimensions(detected.width, detected.height, { + projection: { kind: 'pixel-budget', maxPixels: policy.maxPixels }, + maxDimension: policy.maxDimension, + }) } /** diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 237dc5811f..f1bfe435c6 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -9,6 +9,7 @@ import type { ImageMediaType, ImageAttachmentRef, ImageRequestPolicy, + ImageRequestProjection, RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -22,7 +23,7 @@ import { import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5' +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v6' interface EncodedRequestImage { data: Uint8Array @@ -47,15 +48,36 @@ function checkedInteger(value: number, name: string): number { } function validatePolicy(policy: ImageRequestPolicy): void { - checkedInteger(policy.maxPixels, 'Image request maxPixels') + const { projection } = policy + if (projection.kind === 'pixel-budget') { + checkedInteger(projection.maxPixels, 'Image request maxPixels') + } else { + checkedInteger(projection.patchSize, 'Image request patchSize') + checkedInteger(projection.downsampleRatio, 'Image request downsampleRatio') + checkedInteger(projection.maxTokens, 'Image request maxTokens') + } + if (policy.maxDimension !== undefined) checkedInteger(policy.maxDimension, 'Image request maxDimension') checkedInteger(policy.maxBytes, 'Image request maxBytes') } +/** Projection fields in a fixed key order so equal policies digest identically. */ +function projectionDescriptor(projection: ImageRequestProjection): Record { + return projection.kind === 'pixel-budget' + ? { kind: projection.kind, maxPixels: projection.maxPixels } + : { + kind: projection.kind, + patchSize: projection.patchSize, + downsampleRatio: projection.downsampleRatio, + maxTokens: projection.maxTokens, + } +} + function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string { return JSON.stringify({ transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, attachmentId: attachment.attachmentId, - routePixelBudget: policy.maxPixels, + projection: projectionDescriptor(policy.projection), + maxDimension: policy.maxDimension ?? null, encodedByteBudget: policy.maxBytes, encoding: { webpQualities: IMAGE_ENCODING_QUALITIES, @@ -70,7 +92,7 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): /** * Complete deterministic identity for one attachment and route-owned request policy. * @param attachment - provider-independent durable normalized attachment reference. - * @param policy - route-owned pixel and byte policy. + * @param policy - route-owned projection, per-side cap, and byte policy. * @returns branded digest over every request transform input. */ export function requestImageVariantId( @@ -80,9 +102,11 @@ export function requestImageVariantId( return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`) } +/** Resize by the source long edge only, so the encoder rounds the short edge exactly as the projection did. */ function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp { + const byWidth = attachment.ref.width >= attachment.ref.height return sourcePipeline(attachment) - .resize({ width, height, fit: 'inside', withoutEnlargement: true }) + .resize({ ...byWidth ? { width } : { height }, withoutEnlargement: true }) } function sourcePipeline(attachment: StoredImageAttachment): Sharp { @@ -94,7 +118,7 @@ async function createRequestImage( policy: ImageRequestPolicy, hasAlpha: boolean, ): Promise { - const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy) if (dimensions.width === attachment.ref.width && dimensions.height === attachment.ref.height && attachment.data.byteLength <= policy.maxBytes) { @@ -126,7 +150,7 @@ async function readCached( try { const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) - const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy) if (detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 5711c6d35d..a46d6f0b10 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -85,7 +85,7 @@ describe('local attachment service', () => { String(ref.attachmentId).slice('sha256:'.length), )) await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) - const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) + const request = await service.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1024 }) expect(request).not.toHaveProperty('access') const fileData = Uint8Array.of(0, 1, 2, 255) diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts index 32bc005c94..eabc735229 100644 --- a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -38,7 +38,7 @@ describe('request image verification', () => { const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) control.mismatch = true - await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) + await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 821480d028..73c0c366b1 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -48,7 +48,7 @@ describe('local request-image cache', () => { const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' }) const firstStored = await attachments.readImage(first) - const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 } + const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 1_000 }, maxBytes: 1024 * 1024 } const request = await attachments.readImageRequest(first, policy) const batch = await Promise.all([first, second].map( @@ -63,17 +63,52 @@ describe('local request-image cache', () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 })) + await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 0 }, maxBytes: 100 })) .rejects.toThrow('Image request maxPixels must be a positive integer') - await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 })) + await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxBytes: 0 })) .rejects.toThrow('Image request maxBytes must be a positive integer') + await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxDimension: 0, maxBytes: 100 })) + .rejects.toThrow('Image request maxDimension must be a positive integer') + for (const field of ['patchSize', 'downsampleRatio', 'maxTokens'] as const) { + const projection = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024, [field]: 1.5 } + await expect(attachments.readImageRequest(attachment, { projection, maxBytes: 100 })) + .rejects.toThrow(`Image request ${field} must be a positive integer`) + } + }) + + it('projects onto a token grid, caps the long edge, and keys the cache by projection', async () => { + const attachments = await store() + const grid = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024 } + const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' }) + const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' }) + const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' }) + const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' }) + const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' }) + + const squareRequest = await attachments.readImageRequest(square, { projection: grid, maxBytes: 2 * 1024 * 1024 }) + const smallRequest = await attachments.readImageRequest(small, { projection: grid, maxBytes: 2 * 1024 * 1024 }) + const thinRequest = await attachments.readImageRequest(thin, { projection: grid, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }) + const budgeted = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 1302 * 1302 }, maxBytes: 2 * 1024 * 1024 }) + const uncapped = await attachments.readImageRequest(thin, { projection: grid, maxBytes: 2 * 1024 * 1024 }) + const wideRequest = await attachments.readImageRequest(wide, { projection: grid, maxBytes: 2 * 1024 * 1024 }) + const tallRequest = await attachments.readImageRequest(tall, { projection: grid, maxBytes: 2 * 1024 * 1024 }) + + expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' }) + expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' }) + expect(smallRequest.data).toEqual((await attachments.readImage(small)).data) + expect(thinRequest).toMatchObject({ width: 4096, height: 20 }) + expect(uncapped).toMatchObject({ width: 8000, height: 40 }) + expect(wideRequest).toMatchObject({ width: 1708, height: 961 }) + expect(tallRequest).toMatchObject({ width: 961, height: 1708 }) + expect(budgeted.variantId).not.toBe(squareRequest.variantId) + expect(uncapped.variantId).not.toBe(thinRequest.variantId) }) it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 }) + const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }) expect(request.mediaType).toBe('image/jpeg') expect(request.bytes).toBeGreaterThan(1) @@ -83,7 +118,7 @@ describe('local request-image cache', () => { it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) - const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } + const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 4_096 } const initial = await attachments.readImageRequest(attachment, policy) const hash = String(initial.variantId).slice('sha256:'.length) const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) @@ -132,10 +167,10 @@ describe('local request-image cache', () => { data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', }) - const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }) + const squareRequest = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) + const wideRequest = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) + const repeated = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) + const low = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxBytes: 1024 * 1024 }) expect(squareRequest).toMatchObject({ width: 800, height: 800 }) expect(wideRequest).toMatchObject({ width: 1130, height: 565 }) @@ -175,8 +210,8 @@ describe('local request-image cache', () => { const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' }) const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' }) - const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 }) - const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) + const photoRequest = await attachments.readImageRequest(photo, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 1024 * 1024 }) + const alphaRequest = await attachments.readImageRequest(alpha, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 4_096 }) expect(photoRequest.mediaType).toBe('image/jpeg') expect(alphaRequest.mediaType).toBe('image/webp') @@ -192,7 +227,7 @@ describe('local request-image cache', () => { }).toColourspace('rgb16').png().toBuffer()) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) @@ -206,7 +241,7 @@ describe('local request-image cache', () => { const source = await complexOpaqueAlphaImage(64, 32) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 }) expect(request.mediaType).toBe('image/webp') await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false }) @@ -228,7 +263,7 @@ describe('local request-image cache', () => { }).png().toBuffer()) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) expect(request).toMatchObject({ width: 800, height: 800 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) @@ -241,7 +276,7 @@ describe('local request-image cache', () => { }) const run = vi.spyOn(CompressionLimiter.prototype, 'run') const controller = new AbortController() - const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) const completed = attachments.readImageRequest(attachment, policy) @@ -271,7 +306,7 @@ describe('local request-image cache', () => { const controller = new AbortController() const request = attachments.readImageRequest( attachment, - { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }, controller.signal, ) await vi.waitFor(() => { @@ -304,7 +339,7 @@ describe('local request-image cache', () => { return actualRead(ref, signal) }) const controller = new AbortController() - const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) await vi.waitFor(() => { expect(calls).toBe(1) diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 5df42a8bda..312bd2a3e7 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -22,7 +22,8 @@ export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedFile, admitEncodedImages } from './admission.ts' -export { requestImageDimensions } from './request-projection.ts' +export { requestImageDimensions, tokenGridProjection } from './request-projection.ts' +export type { ProjectedDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, @@ -33,6 +34,7 @@ export type { ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, + ImageRequestProjection, ImageMediaType, PromptContentPart, RequestImageAttachment, @@ -40,6 +42,7 @@ export type { SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, + TokenGridProjection, } from './types.ts' declare module '@deepseek-ai/cordis' { @@ -241,7 +244,7 @@ export abstract class AttachmentStore extends Service { /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/packages/attachment/attachment/src/request-projection.ts b/packages/attachment/attachment/src/request-projection.ts index ac9a56c983..f1a9d855ed 100644 --- a/packages/attachment/attachment/src/request-projection.ts +++ b/packages/attachment/attachment/src/request-projection.ts @@ -3,18 +3,19 @@ * provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection */ -/** - * Compute aspect-preserving integer dimensions within a hard total-pixel budget. - * @param width - positive source width. - * @param height - positive source height. - * @param maxPixels - positive width-times-height cap. - * @returns inward-rounded dimensions; small images are not enlarged. - */ -export function requestImageDimensions( - width: number, - height: number, - maxPixels: number, -): { width: number; height: number } { +import type { ImageRequestPolicy, ImageRequestProjection, TokenGridProjection } from './types.ts' + +/** Integer width and height of one projected image. */ +export interface ProjectedDimensions { + width: number + height: number +} + +const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor) +const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor) + +/** Aspect-preserving integer dimensions within a hard total-pixel budget; small images are not enlarged. */ +function pixelBudgetDimensions(width: number, height: number, maxPixels: number): ProjectedDimensions { const scale = Math.min(1, Math.sqrt(maxPixels / (width * height))) if (scale === 1) return { width, height } if (width >= height) { @@ -34,3 +35,104 @@ export function requestImageDimensions( } return { width: projectedWidth, height: projectedHeight } } + +/** Token count of one grid: every row carries a separator, plus two framing tokens. */ +function gridTokens(rows: number, columns: number): number { + return rows * (columns + 1) + 2 +} + +/** + * Project one image onto the token grid of a `token-grid` projection. The + * provider pads each edge up to a whole patch, groups patches into token + * cells, and keeps the padded source when its cell grid fits `maxTokens`; + * otherwise it solves the largest aspect-preserving grid inside the cap, whose + * edges are whole patches. The closed-form solve always lands inside the cap. + * @param width - positive integer source width in pixels. + * @param height - positive integer source height in pixels. + * @param grid - patch size, per-axis downsampling ratio, and token cap. + * @returns the dimensions the provider retains and the tokens it charges. + */ +export function tokenGridProjection( + width: number, + height: number, + grid: Extract, +): TokenGridProjection { + const { patchSize, downsampleRatio, maxTokens } = grid + const cells = (paddedLength: number): number => ceilDiv(intDiv(paddedLength, patchSize), downsampleRatio) + const paddedWidth = ceilDiv(width, patchSize) * patchSize + const paddedHeight = ceilDiv(height, patchSize) * patchSize + const directTokens = gridTokens(cells(paddedHeight), cells(paddedWidth)) + if (directTokens <= maxTokens) { + return { width: paddedWidth, height: paddedHeight, tokens: directTokens, unscaled: true } + } + const cellSize = patchSize * downsampleRatio + const aspect = height / width + const idealColumns = Math.sqrt((maxTokens - 2) / aspect + 0.25) - 0.5 + const idealRows = idealColumns * aspect + let bestWidth: number + let bestHeight: number + if (idealColumns < 1) { + bestWidth = cellSize + bestHeight = intDiv(maxTokens - 2, 2) * cellSize + } else if (idealRows < 1) { + bestWidth = (maxTokens - 3) * cellSize + bestHeight = cellSize + } else { + const columns = Math.trunc(idealColumns) + const rows = Math.trunc(idealRows) + const scale = Math.min(columns * cellSize / width, rows * cellSize / height) + bestWidth = Math.trunc(width * scale / patchSize) * patchSize + bestHeight = Math.trunc(height * scale / patchSize) * patchSize + } + return { + width: bestWidth, + height: bestHeight, + tokens: gridTokens(cells(bestHeight), cells(bestWidth)), + unscaled: false, + } +} + +/** Aspect-preserving dimensions with an exact long edge; the short edge rounds to the nearest pixel. */ +function fromLongEdge(width: number, height: number, longEdge: number): ProjectedDimensions { + return width >= height + ? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) } + : { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge } +} + +function projectDimensions(width: number, height: number, projection: ImageRequestProjection): ProjectedDimensions { + switch (projection.kind) { + case 'pixel-budget': + return pixelBudgetDimensions(width, height, projection.maxPixels) + case 'token-grid': { + const fit = tokenGridProjection(width, height, projection) + if (fit.unscaled) return { width, height } + return fromLongEdge(width, height, width >= height ? fit.width : fit.height) + } + /* v8 ignore next 4 -- ImageRequestProjection is a closed union; this branch is only the static exhaustiveness guard. */ + default: { + const unreachable: never = projection + throw new Error(`unknown image request projection ${JSON.stringify(unreachable)}`) + } + } +} + +/** + * Compute the aspect-preserving integer dimensions of one request image: + * the policy projection first, then the optional per-side cap. Small images + * are never enlarged. A downscaled result keeps the source long edge exact + * and rounds the short edge, matching a long-edge-only encoder resize. + * @param width - positive source width. + * @param height - positive source height. + * @param policy - route projection rule and optional per-side cap. + * @returns integer dimensions. + */ +export function requestImageDimensions( + width: number, + height: number, + policy: Pick, +): ProjectedDimensions { + const projected = projectDimensions(width, height, policy.projection) + const longEdge = Math.max(projected.width, projected.height) + if (policy.maxDimension === undefined || longEdge <= policy.maxDimension) return projected + return fromLongEdge(width, height, policy.maxDimension) +} diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index a69b2363dc..0c8482a1eb 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -132,10 +132,46 @@ export interface StoredImageAttachment { data: Uint8Array } +/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */ +export type ImageRequestProjection = + | { + /** Hard cap on width multiplied by height. */ + kind: 'pixel-budget' + /** Maximum width multiplied by height after projection. */ + maxPixels: number + } + | { + /** + * Largest aspect-preserving patch grid whose token count + * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout. + */ + kind: 'token-grid' + /** Patch edge in pixels; a downscaled edge is a whole number of patches. */ + patchSize: number + /** Patches per token cell along each axis. */ + downsampleRatio: number + /** Token cap for one image. */ + maxTokens: number + } + +/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */ +export interface TokenGridProjection { + /** Retained width: the patch-padded source when it fits, otherwise the solved width. */ + width: number + /** Retained height: the patch-padded source when it fits, otherwise the solved height. */ + height: number + /** Tokens charged for the retained grid. */ + tokens: number + /** Whether the patch-padded source already fits `maxTokens` without downscaling. */ + unscaled: boolean +} + /** Deterministic request-image policy selected by one exact model route. */ export interface ImageRequestPolicy { - /** Maximum width multiplied by height after aspect-preserving projection. */ - maxPixels: number + /** Downscale rule applied before the per-side cap. */ + projection: ImageRequestProjection + /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */ + maxDimension?: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 923c1a4360..63c0471f40 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -154,12 +154,12 @@ describe('AttachmentStore.readImageRequest', () => { it('reports unsupported request projection while preserving cancellation', async () => { const store = new UnsupportedProjectionStore(new Context()) const ref = await new RecordingStore(new Context()).saveImage(image(1)) - await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) + await expect(store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) const controller = new AbortController() const reason = new Error('cancel unsupported projection') controller.abort(reason) - expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) + expect(() => store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }, controller.signal)).toThrow(reason) }) it('rejects generic-file storage and exposes no provider-owned host path by default', async () => { diff --git a/packages/attachment/attachment/tests/request-projection.spec.ts b/packages/attachment/attachment/tests/request-projection.spec.ts index 5e8a740779..0a7d604b5b 100644 --- a/packages/attachment/attachment/tests/request-projection.spec.ts +++ b/packages/attachment/attachment/tests/request-projection.spec.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from 'vitest' -import { requestImageDimensions } from '../src/index.ts' +import { requestImageDimensions, tokenGridProjection } from '../src/index.ts' +import type { ImageRequestProjection } from '../src/index.ts' -describe('request image dimensions', () => { +const PIXEL_BUDGET: ImageRequestProjection = { kind: 'pixel-budget', maxPixels: 640_000 } +const TOKEN_GRID = { kind: 'token-grid', patchSize: 14, downsampleRatio: 3, maxTokens: 1024 } as const + +describe('pixel-budget projection', () => { it.each([ [4096, 4096, 800, 800], [4096, 2048, 1130, 565], [3840, 2160, 1066, 600], [320, 240, 320, 240], ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => { - const projected = requestImageDimensions(width, height, 640_000) + const projected = requestImageDimensions(width, height, { projection: PIXEL_BUDGET }) expect(projected).toEqual({ width: expectedWidth, height: expectedHeight, @@ -17,13 +21,65 @@ describe('request image dimensions', () => { }) it('projects a portrait within the same total-pixel budget', () => { - const projected = requestImageDimensions(2160, 3840, 640_000) + const projected = requestImageDimensions(2160, 3840, { projection: PIXEL_BUDGET }) expect(projected).toEqual({ width: 600, height: 1066 }) expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) }) it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => { - expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 }) + expect(requestImageDimensions(2, 4, { projection: { kind: 'pixel-budget', maxPixels: 5 } })).toEqual({ width: 1, height: 2 }) + }) +}) + +describe('token-grid projection', () => { + it('keeps the patch-padded source when its grid fits the token cap', () => { + expect(tokenGridProjection(800, 800, TOKEN_GRID)).toEqual({ width: 812, height: 812, tokens: 422, unscaled: true }) + expect(tokenGridProjection(1302, 1302, TOKEN_GRID)).toEqual({ width: 1302, height: 1302, tokens: 994, unscaled: true }) + }) + + it.each([ + [1303, 1303, 1302, 1302, 994], + [4096, 4096, 1302, 1302, 994], + [3840, 2160, 1708, 966, 968], + [2160, 3840, 966, 1708, 986], + [4000, 1000, 2520, 630, 917], + ])('solves %sx%s onto the largest in-cap grid %sx%s', (width, height, expectedWidth, expectedHeight, tokens) => { + expect(tokenGridProjection(width, height, TOKEN_GRID)).toEqual({ + width: expectedWidth, + height: expectedHeight, + tokens, + unscaled: false, + }) + expect(expectedWidth % TOKEN_GRID.patchSize).toBe(0) + expect(expectedHeight % TOKEN_GRID.patchSize).toBe(0) + }) + + it('solves one-row and one-column grids for extreme aspect ratios', () => { + expect(tokenGridProjection(100_000, 1, TOKEN_GRID)).toEqual({ width: 42_882, height: 42, tokens: 1024, unscaled: false }) + expect(tokenGridProjection(1, 100_000, TOKEN_GRID)).toEqual({ width: 42, height: 21_462, tokens: 1024, unscaled: false }) + }) + + it('sends a fitting source at its own dimensions and a larger source at the solved grid', () => { + expect(requestImageDimensions(800, 800, { projection: TOKEN_GRID })).toEqual({ width: 800, height: 800 }) + expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID })).toEqual({ width: 8192, height: 78 }) + expect(requestImageDimensions(2048, 1024, { projection: TOKEN_GRID })).toEqual({ width: 1848, height: 924 }) + }) + + it('keeps the solved long edge and rounds the short edge to the source aspect ratio', () => { + expect(requestImageDimensions(3840, 2160, { projection: TOKEN_GRID })).toEqual({ width: 1708, height: 961 }) + expect(requestImageDimensions(1080, 2400, { projection: TOKEN_GRID })).toEqual({ width: 838, height: 1862 }) + }) +}) + +describe('per-side cap', () => { + it('scales the projected image down to the cap on its long edge', () => { + expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 4096, height: 39 }) + expect(requestImageDimensions(1, 8192, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1, height: 4096 }) + expect(requestImageDimensions(10_000, 100, { projection: PIXEL_BUDGET, maxDimension: 4096 })).toEqual({ width: 4096, height: 41 }) + }) + + it('leaves an image within the cap untouched', () => { + expect(requestImageDimensions(2048, 2048, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1302, height: 1302 }) }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6f16abb7d0..c7a984b12a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -550,7 +550,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', - parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], + parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, ], @@ -4360,7 +4360,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageRequestPolicy', - declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n}', + declaration: 'export interface ImageRequestPolicy {\n projection: ImageRequestProjection;\n maxDimension?: number;\n maxBytes: number;\n}', + }, + { + name: 'ImageRequestProjection', + declaration: 'export type ImageRequestProjection = {\n kind: \'pixel-budget\';\n maxPixels: number;\n} | {\n kind: \'token-grid\';\n patchSize: number;\n downsampleRatio: number;\n maxTokens: number;\n};', }, { name: 'ImageVariantId', diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index be165c1d4c..432902ca6b 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -60,7 +60,11 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + /** + * Total-pixel budget replacing the published token-grid projection for one + * deterministic request preview, or the 512-by-512 `low` preset; omission + * projects onto the token grid. + */ imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number diff --git a/packages/llm/llm-deepseek/src/image-tokens.ts b/packages/llm/llm-deepseek/src/image-tokens.ts index 609eb7269c..318ee1f399 100644 --- a/packages/llm/llm-deepseek/src/image-tokens.ts +++ b/packages/llm/llm-deepseek/src/image-tokens.ts @@ -1,119 +1,39 @@ /** * DeepSeek vision-token accounting: the provider's published image-token - * calculator (api-docs.deepseek.com, Token & Token Usage) ported verbatim in - * its current `v41` configuration. The provider scales an image below - * 544×544 total pixels up, aligns it to a 14px-patch grid, downsamples 3:1 - * per axis into token cells, and caps one image at 1024 tokens by solving the - * largest aspect-preserving grid inside that budget. The count is exact: this - * configuration has no alignment pad and no aspect-ratio clamp. Actual usage - * remains authoritative. + * calculator (api-docs.deepseek.com, Token & Token Usage) in its current + * `v41` configuration. The provider scales an image below 544×544 total + * pixels up, then projects it onto the token grid shared with request + * projection, iterating to a fixpoint. The count is exact: this configuration + * has no alignment pad and no aspect-ratio clamp. Actual usage remains + * authoritative. * * @module dsh-llm-deepseek/image-tokens */ -/** Vision patch edge in pixels. */ -const PATCH_SIZE = 14 -/** Per-axis patch-to-token downsampling ratio. */ -const DOWNSAMPLE_RATIO = 3 -/** Provider cap on tokens for one request image. */ -const MAX_IMAGE_TOKENS = 1024 +import { tokenGridProjection } from '@deepseek-ai/dsh-attachment' +import type { ImageRequestProjection, TokenGridProjection } from '@deepseek-ai/dsh-attachment' + +/** Published DeepSeek vision grid: 14px patches, 3:1 per-axis downsampling, at most 1024 tokens per image. */ +export const DEEPSEEK_IMAGE_TOKEN_GRID = { + kind: 'token-grid', + patchSize: 14, + downsampleRatio: 3, + maxTokens: 1024, +} as const satisfies ImageRequestProjection + /** Total-pixel floor; smaller images are scaled up before grid projection. */ const MIN_PIXELS = 544 * 544 -/** Pixels covered by one token cell along either axis. */ -const CELL_SIZE = PATCH_SIZE * DOWNSAMPLE_RATIO -const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor) -const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor) - -interface GridResize { - readonly gridHeight: number - readonly gridWidth: number - readonly bestHeight: number - readonly bestWidth: number - readonly numTokens: number +/** One scale-up-then-project pass; the caller iterates it to a fixpoint. */ +function resizeOnce(width: number, height: number): TokenGridProjection { + const pixels = width * height + if (pixels >= MIN_PIXELS) return tokenGridProjection(width, height, DEEPSEEK_IMAGE_TOKEN_GRID) + const scale = Math.sqrt(MIN_PIXELS / pixels) + return tokenGridProjection(Math.trunc(width * scale), Math.trunc(height * scale), DEEPSEEK_IMAGE_TOKEN_GRID) } -/** Token count of one grid: every row carries a separator, plus two framing tokens. */ -function gridTokens(gridHeight: number, gridWidth: number): number { - return gridHeight * (gridWidth + 1) + 2 -} - -/** Token-cell count along one padded pixel axis. */ -function gridCells(paddedLength: number): number { - return ceilDiv(intDiv(paddedLength, PATCH_SIZE), DOWNSAMPLE_RATIO) -} - -/** Solve the largest grid within `budget` tokens preserving the aspect ratio. */ -function solveResizeRatio(height: number, width: number, budget: number): GridResize { - const aspect = height / width - const idealGridWidth = Math.sqrt((budget - 2) / aspect + 0.25) - 0.5 - const idealGridHeight = idealGridWidth * aspect - let bestHeight: number - let bestWidth: number - if (idealGridWidth < 1) { - const solvedGridWidth = 1 - const solvedGridHeight = intDiv(budget - 2, solvedGridWidth + 1) - bestWidth = solvedGridWidth * CELL_SIZE - bestHeight = solvedGridHeight * CELL_SIZE - } else if (idealGridHeight < 1) { - const solvedGridHeight = 1 - const solvedGridWidth = intDiv(budget - 2, solvedGridHeight) - 1 - bestWidth = solvedGridWidth * CELL_SIZE - bestHeight = solvedGridHeight * CELL_SIZE - } else { - const solvedGridWidth = Math.trunc(idealGridWidth) - const solvedGridHeight = Math.trunc(idealGridHeight) - const scale = Math.min(solvedGridWidth * CELL_SIZE / width, solvedGridHeight * CELL_SIZE / height) - bestWidth = Math.trunc(width * scale / PATCH_SIZE) * PATCH_SIZE - bestHeight = Math.trunc(height * scale / PATCH_SIZE) * PATCH_SIZE - } - const gridHeight = gridCells(bestHeight) - const gridWidth = gridCells(bestWidth) - return { gridHeight, gridWidth, bestHeight, bestWidth, numTokens: gridTokens(gridHeight, gridWidth) } -} - -/** Project padded pixel dimensions onto the largest in-budget token grid. */ -function safeResize(height: number, width: number, paddedHeight: number, paddedWidth: number): GridResize { - const gridHeight = gridCells(paddedHeight) - const gridWidth = gridCells(paddedWidth) - const direct: GridResize = { - gridHeight, - gridWidth, - bestHeight: paddedHeight, - bestWidth: paddedWidth, - numTokens: gridTokens(gridHeight, gridWidth), - } - if (direct.numTokens <= MAX_IMAGE_TOKENS) return direct - const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS) - /* v8 ignore next 3 -- the published solver's assertion; the closed-form - solve stays within the budget for every positive geometry. */ - if (solved.numTokens > MAX_IMAGE_TOKENS) { - throw new Error(`deepseek image tokens: no grid fits the token budget for ${width}x${height}`) - } - return solved -} - -/** One scale-pad-project pass; the caller iterates it to a fixpoint. */ -function resizeOnce(width: number, height: number): GridResize { - let scaledWidth = width - let scaledHeight = height - const pixels = scaledWidth * scaledHeight - if (pixels < MIN_PIXELS && pixels > 0) { - const scale = Math.sqrt(MIN_PIXELS / pixels) - scaledWidth = Math.trunc(scaledWidth * scale) - scaledHeight = Math.trunc(scaledHeight * scale) - } - const paddedWidth = ceilDiv(scaledWidth, PATCH_SIZE) * PATCH_SIZE - const paddedHeight = ceilDiv(scaledHeight, PATCH_SIZE) * PATCH_SIZE - return safeResize(scaledHeight, scaledWidth, paddedHeight, paddedWidth) -} - -function sameResize(a: GridResize, b: GridResize): boolean { - return a.gridHeight === b.gridHeight - && a.gridWidth === b.gridWidth - && a.bestHeight === b.bestHeight - && a.bestWidth === b.bestWidth - && a.numTokens === b.numTokens +function sameResize(a: TokenGridProjection, b: TokenGridProjection): boolean { + return a.width === b.width && a.height === b.height && a.tokens === b.tokens } /** @@ -126,8 +46,8 @@ function sameResize(a: GridResize, b: GridResize): boolean { export function deepSeekImageTokens(width: number, height: number): number { let result = resizeOnce(width, height) for (let iteration = 1; iteration < 10; iteration += 1) { - const next = resizeOnce(result.bestWidth, result.bestHeight) - if (sameResize(next, result)) return result.numTokens + const next = resizeOnce(result.width, result.height) + if (sameResize(next, result)) return result.tokens result = next } /* v8 ignore next 2 -- the published solver's non-convergence guard; every diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index d536fc1f9b..1e41a4f5f7 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -38,11 +38,9 @@ import { } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' import { - DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES, - DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, } from './request-pricing.ts' export { @@ -65,11 +63,11 @@ export { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES, - DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + REQUEST_IMAGE_MAX_DIMENSION, deepSeekImageRequestPricing, resolveRequestImagePolicy, } from './request-pricing.ts' -export { deepSeekImageTokens } from './image-tokens.ts' +export { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts' export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts' export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts' export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts' @@ -95,8 +93,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ name: 'DeepSeek-V41-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW, inputModalities: ['text', 'image'], - imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, - imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, systemPromptUpdate: 'in-history', }, { @@ -116,8 +112,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ name: 'DeepSeek-V4-Flash-Vision-Exp', contextWindow: DEFAULT_CONTEXT_WINDOW, inputModalities: ['text', 'image'], - imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, - imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, }, ] @@ -285,9 +279,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee inputModalities: [...inputModalities], ...hasImage ? { - imagePixelBudget: model.imagePixelBudget === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + ...model.imagePixelBudget === undefined ? {} : { imagePixelBudget: model.imagePixelBudget }, imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, } : {}, diff --git a/packages/llm/llm-deepseek/src/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts index acdf4dc15a..732038da28 100644 --- a/packages/llm/llm-deepseek/src/request-pricing.ts +++ b/packages/llm/llm-deepseek/src/request-pricing.ts @@ -1,6 +1,6 @@ /** * Provider-side request-image pricing for DeepSeek routes: reproduces the - * adapter's deterministic request projection (per-model pixel budget, + * adapter's deterministic request projection (per-model projection, * oldest-first offload under the raw-byte and count budgets) and prices every * retained image with the published vision-token accounting. Consumed * synchronously by the token meter through `LlmAdapter.imageRequestPricing`; @@ -12,36 +12,43 @@ import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageRequestPolicy } from '@deepseek-ai/dsh-attachment' -import { deepSeekImageTokens } from './image-tokens.ts' +import type { ImageAttachmentRef, ImageRequestPolicy, ImageRequestProjection } from '@deepseek-ai/dsh-attachment' +import { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' /** Default bound on accumulated file-referenced image bytes per request. */ export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024 /** Provider request image-count limit. */ export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600 -/** Default total-pixel budget for harness request-image projection. */ -export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000 /** Total-pixel budget matching provider low-detail image input. */ export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512 /** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */ -export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 +export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024 +/** + * Provider per-side limit for a request carrying 15 or more images, applied + * to every request image so the image count never changes a projection. + */ +export const REQUEST_IMAGE_MAX_DIMENSION = 4096 /** - * Resolve the request-image budgets owned by one DeepSeek model route. + * Resolve the request-image policy owned by one DeepSeek model route: the + * published token grid unless the model overrides it with a pixel budget, + * the provider per-side limit, and the encoded-byte target. * @param model - Advertised model route and its optional image overrides. - * @returns Complete pixel and encoded-byte budgets. + * @returns Complete projection, per-side cap, and encoded-byte target. * @internal */ export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { - const maxPixels = model.imagePixelBudget === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET + const projection: ImageRequestProjection = model.imagePixelBudget === undefined + ? DEEPSEEK_IMAGE_TOKEN_GRID + : { + kind: 'pixel-budget', + maxPixels: model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget, + } return { - maxPixels, - maxBytes: model.imageMaxBytes === undefined - ? DEFAULT_REQUEST_IMAGE_MAX_BYTES - : model.imageMaxBytes, + projection, + maxDimension: REQUEST_IMAGE_MAX_DIMENSION, + maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, } } @@ -95,7 +102,7 @@ export function deepSeekImageRequestPricing( if (index < offloaded) { return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) } } - const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels) + const dimensions = requestImageDimensions(ref.width, ref.height, policy) return { visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height), text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)), diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index fefe4815b4..4122023b2c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -20,7 +20,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { DEEPSEEK_IMAGE_TOKEN_GRID, DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' import { resolveRequestImagePolicy } from '../src/request-pricing.ts' import { assemble } from './assemble.ts' @@ -145,15 +145,15 @@ describe('request image policy', () => { it.each([ [ { id: 'default' }, - { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, ], [ { id: 'low', imagePixelBudget: 'low' as const }, - { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }, + { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, ], [ { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, - { maxPixels: 320_000, maxBytes: 512_000 }, + { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 512_000 }, ], ])('resolves route-owned defaults and overrides for %s', (model, expected) => { expect(resolveRequestImagePolicy(model)).toEqual(expected) @@ -407,7 +407,7 @@ describe('DeepSeekAdapter against a mock server', () => { bytes: 3, }]) expect(signalSeen[0]).toBeInstanceOf(AbortSignal) - expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }]) + expect(policies).toEqual([{ projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }]) }) it('falls back to one all-base64 request when Files API resolution fails', async () => { @@ -621,7 +621,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( recent, - { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, expect.any(AbortSignal), ) const body = server.requests[0] as { messages: unknown[] } @@ -672,13 +672,13 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 1, imageRef, - { maxPixels: 512 * 512, maxBytes: 512_000 }, + { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 512_000 }, expect.any(AbortSignal), ) expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 2, imageRef, - { maxPixels: 320_000, maxBytes: 1024 * 1024 }, + { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, expect.any(AbortSignal), ) }) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index ccaec2e51a..b082cbc7ed 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -44,17 +44,32 @@ describe('DeepSeek request-image pricing', () => { const image = ref('photo', 1920, 1080) const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image]) expect(prices).toEqual([{ - visualTokens: 407, - text: requestImageHandleText(image, { width: 1066, height: 600 }), + visualTokens: 968, + text: requestImageHandleText(image, { width: 1708, height: 961 }), }]) }) - it.each([[8192, 1], [1, 8192]])('prices a %sx%s image at the token cap within the default pixel budget', (width, height) => { + it.each([ + [8192, 1, 4096, 1, 832], + [1, 8192, 1, 4096, 1024], + ])('prices a %sx%s image at its per-side-capped %sx%s request dimensions', (width, height, cappedWidth, cappedHeight, tokens) => { const image = ref('thin', width, height) const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image]) expect(prices).toEqual([{ - visualTokens: 1024, - text: requestImageHandleText(image, { width, height }), + visualTokens: tokens, + text: requestImageHandleText(image, { width: cappedWidth, height: cappedHeight }), + }]) + }) + + it('honors a numeric pixel budget override', () => { + const image = ref('photo', 4096, 4096) + const options = resolveAdapterOptions({ + models: [{ ...VISION_MODEL, imagePixelBudget: 640_000 }], + }) + const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([image]) + expect(prices).toEqual([{ + visualTokens: 422, + text: requestImageHandleText(image, { width: 800, height: 800 }), }]) }) @@ -97,7 +112,7 @@ describe('DeepSeek request-image pricing', () => { }) it('caps each occurrence at the per-image byte target before the byte budget', () => { - // Each 5 MiB source counts as the 1 MiB request target, so a 2 MiB budget + // Each 5 MiB source counts as the 2 MiB request target, so a 4 MiB budget // with a one-byte quantum removes exactly the oldest occurrence. const oversized = 5 * 1024 * 1024 const images = [ @@ -106,7 +121,7 @@ describe('DeepSeek request-image pricing', () => { ref('third', 800, 800, oversized), ] const prices = deepSeekImageRequestPricing( - connection({ maxRequestFilesBytes: 2 * 1024 * 1024, imageOffloadByteQuantum: 1 }), + connection({ maxRequestFilesBytes: 4 * 1024 * 1024, imageOffloadByteQuantum: 1 }), 'vision', ).priceImages(images) expect(prices.map(price => price.visualTokens)).toEqual([0, 422, 422]) diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 8dba978c31..c1e0d9b586 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -373,7 +373,7 @@ export class PiAiAdapter extends LlmAdapter { resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), maxRequestImageBytes: profile.maxRequestImageBytes, requestImagePolicy: { - maxPixels: profile.requestImagePixelBudget, + projection: { kind: 'pixel-budget', maxPixels: profile.requestImagePixelBudget }, maxBytes: profile.requestImageMaxBytes, }, }, onReplayDegrade) diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 2a4369a5ba..2c072166e8 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -272,7 +272,7 @@ async function toPiContextWithImages( ): Promise { const { attachments, resolveImageAccess, maxRequestImageBytes } = images const requestImagePolicy = images.requestImagePolicy ?? { - maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + projection: { kind: 'pixel-budget', maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET }, maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, } assertSupportedImageRoles(options.messages) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a54ec17697..4746ee4be1 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -325,7 +325,7 @@ describe('PiAiAdapter provider routing', () => { expect(result.finish.kind).toBe('error') expect(readImageRequest).toHaveBeenCalledWith(ref, { - maxPixels: 2048 * 2048, + projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 }, maxBytes: 1024 * 1024, }, expect.any(AbortSignal)) expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 74ecb3d71d..650c8222ae 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -119,7 +119,7 @@ describe('toPiContext', () => { expect(readImageRequest).toHaveBeenCalledWith( attachment, - { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 }, + { projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 }, maxBytes: 1024 * 1024 }, undefined, ) expect(context.messages[0]).toEqual({ From e17a736cc3823463dbdb00994bf4b979bf2f18ad Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 10 Sep 2026 16:09:20 +0800 Subject: [PATCH 23/60] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=9B=BE=E7=89=87=E6=8A=95=E5=BD=B1=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=20Agent=20Note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新 attachment、attachment-local、llm-deepseek 的 README,附件与 LLM 流式子系统文档,配置目录,type-equiv 清单,以及双语配对记录;新增请求图片投影的 Agent Note,并修正 v41 计价 Note 里关于 640,000 像素预算的说明。 --- ...pseek-image-token-calculator-v41.i18n.yaml | 4 +- ...-10-deepseek-image-token-calculator-v41.md | 2 +- ...-deepseek-image-token-calculator-v41.zh.md | 2 +- ...eek-v41-request-image-projection.i18n.yaml | 6 +++ ...0-deepseek-v41-request-image-projection.md | 31 ++++++++++++ ...eepseek-v41-request-image-projection.zh.md | 31 ++++++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +++- docs/config-catalog.zh.md | 8 +++- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 48 +++++++++++++++++-- docs/subsystems/attachment.zh.md | 48 +++++++++++++++++-- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 4 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- scripts/type-equiv.manifest.json | 10 ++++ 25 files changed, 204 insertions(+), 42 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml index 656c874bda..37a4a4b687 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.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-09-10-deepseek-image-token-calculator-v41.md -2026-09-10-deepseek-image-token-calculator-v41.md: d8042b04c1ed7824720485aa3ccf584f913d0726 -2026-09-10-deepseek-image-token-calculator-v41.zh.md: 6be386da1c66c469329d03e7b86c8c0e2c22ce3a +2026-09-10-deepseek-image-token-calculator-v41.md: 4e57118987ddb7c2e2574d0dc40b02d8acf49e1a +2026-09-10-deepseek-image-token-calculator-v41.zh.md: 209ddb99616d5d94fd4f390706304a722137bd15 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md index d8042b04c1..4e57118987 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md @@ -24,4 +24,4 @@ The test vectors are re-pinned from the published calculator. The request-pricin ## Consequences -An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected. +An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The request projection later moved onto the same grid ([successor](2026-09-10-deepseek-v41-request-image-projection.md)), so the 640,000-pixel budget that made a square request image cost 422 tokens no longer applies. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md index 6be386da1c..209ddb9961 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md @@ -24,4 +24,4 @@ Status: implemented ## 后果 -800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。 +800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。请求投影后来改为同一套网格([后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)),让正方形请求图片计 422 token 的 640,000 像素预算已不再适用。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml new file mode 100644 index 0000000000..2a3977f21e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.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/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md +2026-09-10-deepseek-v41-request-image-projection.md: 32b1eb4251ec12005879551bfad363a98034291b +2026-09-10-deepseek-v41-request-image-projection.zh.md: 0360dcc655e1b366ca7169784b63e2650ee3dd09 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md new file mode 100644 index 0000000000..32b1eb4251 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md @@ -0,0 +1,31 @@ +# Agent Note: DeepSeek request images on the published token grid + +Status: implemented + +English | [中文](2026-09-10-deepseek-v41-request-image-projection.zh.md) + +## Problem + +The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps 1708×966, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid. + +Two smaller gaps sat beside it. The request version had no per-side cap: the provider rejects any image over 4096 pixels per side once a request carries 15 or more images, while normalization admits an 8192-pixel long edge, so a many-image session could fail on one thin image. The 1 MiB encoded-byte target was sized for 640,000-pixel outputs and would push a 1302×1302 photograph down the JPEG quality ladder. + +## Decision + +`ImageRequestPolicy` in `dsh-attachment` becomes a projection rule plus an optional per-side cap plus the byte target. The projection is a closed union: `pixel-budget` keeps the previous total-pixel geometry, and `token-grid` carries the patch size, downsampling ratio, and token cap of the provider grid. `request-projection.ts` owns the grid solver as `tokenGridProjection`, and `deepSeekImageTokens` in `llm-deepseek` reuses it for pricing, so the projection that sizes the request and the estimator that prices it are one solver. `requestImageDimensions` applies the projection, then the per-side cap, and keeps the source long edge exact while rounding the short edge; the local store resizes by the long edge only, so the encoded dimensions equal the predicted ones and the pricing handle text names what is actually sent. The provider pads the short edge to whole patches on its side. + +DeepSeek routes resolve the grid when `imagePixelBudget` is omitted, a `pixel-budget` projection for a positive integer or the 512×512 `low` preset, a 4096-pixel per-side cap on every request image so the image count never changes a projection, and a 2 MiB byte target. The pi-ai route keeps its 2048×2048 pixel budget. The request-image transform version moves to `request-image-v6`, and the cache descriptor records the projection fields in a fixed order and the per-side cap, so no earlier cache entry or upload mapping is reused. Sources within the grid are sent at their own dimensions; small images are never enlarged because the provider scales up below 544×544 pixels itself. + +## Alternatives considered + +**Raise the pixel budget to 1302×1302.** A total-pixel budget is right only for squares: a 16:9 source would be sent at 1.69 million pixels when the grid keeps 1.65 million, and a 4:1 source when it keeps 1.59 million, while extreme ratios would lose detail the grid keeps. One rule that reproduces the provider removes the guesswork. + +**Send the solver's exact dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; the issue requires the aspect ratio preserved, the padding on the provider side does not change the token count, and pricing reproduces the provider from the sent dimensions either way. + +**A function-valued projection on the policy.** The cache and upload identity is a digest over serialized policy fields, so the projection has to be data. + +**Keep the 1 MiB target.** The target is not a cap: an output over it is still sent at the smallest ladder quality. At 1302×1302 a JPEG photograph at quality 85 lands between 400 KB and 1.2 MB, so 2 MiB keeps most images at the top quality and lets more PNG screenshots pass through losslessly, while the inline base64 fallback still holds about seven such images under its 20 MiB bound. + +## Consequences + +A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator matches provider usage for the sent version. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots are unchanged, and the `llm-deepseek` adapter tests with mocked attachments pin the resolved policy and the projected handle text. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md new file mode 100644 index 0000000000..0360dcc655 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md @@ -0,0 +1,31 @@ +# Agent Note: DeepSeek 请求图片按官方 token 网格投影 + +Status: implemented + +[English](2026-09-10-deepseek-v41-request-image-projection.md) | 中文 + +## 问题 + +harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。 + +旁边还有两个较小的缺口。请求版本没有单边上限:请求包含 15 张及以上图片时,提供方拒绝任何单边超过 4096 像素的图片,而规范化允许 8192 像素长边,多图会话可能因一张细长图失败。1 MiB 编码字节目标是按 640,000 像素输出定的,会把 1302×1302 的照片压到 JPEG 质量阶梯的低档。 + +## 决策 + +`dsh-attachment` 里的 `ImageRequestPolicy` 改为投影规则加可选单边上限加字节目标。投影是封闭联合类型:`pixel-budget` 保留原来的总像素几何,`token-grid` 携带提供方网格的 patch 大小、降采样比和 token 上限。`request-projection.ts` 以 `tokenGridProjection` 承载网格求解器,`llm-deepseek` 的 `deepSeekImageTokens` 复用它计价,于是决定请求尺寸的投影和为它计价的预估器是同一个求解器。`requestImageDimensions` 先应用投影,再应用单边上限,保持源图长边精确、短边四舍五入;本地存储只按长边缩放,因此编码后的尺寸等于预测尺寸,计价用的句柄文本写的就是实际发送的尺寸。提供方在自己那侧把短边补齐到整数个 patch。 + +DeepSeek 路由在省略 `imagePixelBudget` 时解析为网格,正整数或 512×512 的 `low` 预设解析为 `pixel-budget` 投影,每张请求图片都加 4096 像素单边上限,使图片数量不会改变投影,字节目标为 2 MiB。pi-ai 路由保留 2048×2048 像素预算。请求图片变换版本升到 `request-image-v6`,缓存描述符按固定顺序记录投影字段和单边上限,因此之前的缓存条目和上传映射都不会被复用。网格内的源图按自身尺寸发送;小图不放大,因为提供方自己会放大 544×544 像素以下的图片。 + +## 备选方案 + +**把像素预算提高到 1302×1302。** 总像素预算只对正方形正确:16:9 的源图会按 169 万像素发送而网格只保留 165 万,4:1 的源图网格只保留 159 万,极端比例又会丢掉网格本会保留的细节。一条复现提供方的规则消除了猜测。 + +**用填充缩放发送求解器的精确尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;issue 要求保持宽高比,提供方那侧的补齐不改变 token 数,而计价无论如何都从发送尺寸复现提供方。 + +**策略里放函数形式的投影。** 缓存和上传身份是序列化策略字段的摘要,投影必须是数据。 + +**保留 1 MiB 目标。** 目标不是上限:超过它的输出仍会以阶梯最小质量发送。1302×1302 的 JPEG 照片在质量 85 时约 400 KB 到 1.2 MB,2 MiB 让多数图片停在最高质量,也让更多 PNG 截图无损直发,而内联 base64 回退在 20 MiB 上界内仍能容纳约七张这样的图片。 + +## 后果 + +正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器对发送版本的计价与提供方 usage 一致。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照不变;`llm-deepseek` 适配器用 mock 附件的测试固定了解析出的策略和投影后的句柄文本。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 35f35a3352..46500b4add 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: 7c975892a0a5f04a80dd13186ef2b3732b419400 -config-catalog.zh.md: 57dac94311814e87a2bf7385c404811a910d3d4b +config-catalog.md: 259cbe8afe509cf448a54fa9fcddf297d4f2f588 +config-catalog.zh.md: 49e1064239f20fc6236c3dde6f2698791a9a1183 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7c975892a0..259cbe8afe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1079,7 +1079,11 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + /** + * Total-pixel budget replacing the published token-grid projection for one + * deterministic request preview, or the 512-by-512 `low` preset; omission + * projects onto the token grid. + */ imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number @@ -1094,7 +1098,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:128`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 57dac94311..49e1064239 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1081,7 +1081,11 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + /** + * Total-pixel budget replacing the published token-grid projection for one + * deterministic request preview, or the 512-by-512 `low` preset; omission + * projects onto the token grid. + */ imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number @@ -1096,7 +1100,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:128`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 3e121e78c9..faa0c08226 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: 53b3e51e5b1c87625256178287061386342bc055 -attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1 +attachment.md: bbcf438646eb4cae78ef5b86ccbf9e04b927fa55 +attachment.zh.md: 0e3c58fc59cb319bde681bdfa7124c852cb6da19 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 53b3e51e5b..bbcf438646 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -125,11 +125,51 @@ interface StoredImageAttachment { } ``` +```ts type-equiv +/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */ +type ImageRequestProjection = + | { + /** Hard cap on width multiplied by height. */ + kind: 'pixel-budget' + /** Maximum width multiplied by height after projection. */ + maxPixels: number + } + | { + /** + * Largest aspect-preserving patch grid whose token count + * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout. + */ + kind: 'token-grid' + /** Patch edge in pixels; a downscaled edge is a whole number of patches. */ + patchSize: number + /** Patches per token cell along each axis. */ + downsampleRatio: number + /** Token cap for one image. */ + maxTokens: number + } +``` + +```ts type-equiv +/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */ +interface TokenGridProjection { + /** Retained width: the patch-padded source when it fits, otherwise the solved width. */ + width: number + /** Retained height: the patch-padded source when it fits, otherwise the solved height. */ + height: number + /** Tokens charged for the retained grid. */ + tokens: number + /** Whether the patch-padded source already fits `maxTokens` without downscaling. */ + unscaled: boolean +} +``` + ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { - /** Maximum width multiplied by height after aspect-preserving projection. */ - maxPixels: number + /** Downscale rule applied before the per-side cap. */ + projection: ImageRequestProjection + /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */ + maxDimension?: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } @@ -157,7 +197,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route projection rule, optional per-side cap, and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -280,7 +320,7 @@ fileHostPath(ref: FileAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 549c06cc5a..0e3c58fc59 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -125,11 +125,51 @@ interface StoredImageAttachment { } ``` +```ts type-equiv +/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */ +type ImageRequestProjection = + | { + /** Hard cap on width multiplied by height. */ + kind: 'pixel-budget' + /** Maximum width multiplied by height after projection. */ + maxPixels: number + } + | { + /** + * Largest aspect-preserving patch grid whose token count + * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout. + */ + kind: 'token-grid' + /** Patch edge in pixels; a downscaled edge is a whole number of patches. */ + patchSize: number + /** Patches per token cell along each axis. */ + downsampleRatio: number + /** Token cap for one image. */ + maxTokens: number + } +``` + +```ts type-equiv +/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */ +interface TokenGridProjection { + /** Retained width: the patch-padded source when it fits, otherwise the solved width. */ + width: number + /** Retained height: the patch-padded source when it fits, otherwise the solved height. */ + height: number + /** Tokens charged for the retained grid. */ + tokens: number + /** Whether the patch-padded source already fits `maxTokens` without downscaling. */ + unscaled: boolean +} +``` + ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { - /** Maximum width multiplied by height after aspect-preserving projection. */ - maxPixels: number + /** Downscale rule applied before the per-side cap. */ + projection: ImageRequestProjection + /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */ + maxDimension?: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } @@ -157,7 +197,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的投影规则、可选单边上限和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -280,7 +320,7 @@ fileHostPath(ref: FileAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 8ae88da75e..a31580cd95 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: 77d3313b3c1a025f756e984453bd041abd7b0534 -llm-streaming.zh.md: cac7897ab412f45bd5b5ab20c6460644f18f4ff2 +llm-streaming.md: b3d2468a41833139e3c9c9ded69cbb863e5fd8d3 +llm-streaming.zh.md: 2d849206746ee44736ff402a364f913301ed87a8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 77d3313b3c..b3d2468a41 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -249,7 +249,7 @@ interface LlmFailure { ## Request-image pricing -An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests. +An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model projection and per-side cap, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests. ```ts type-equiv /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index cac7897ab4..2d84920674 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -251,7 +251,7 @@ interface LlmFailure { ## 请求图片定价 -提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 +提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的投影与单边上限、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 ```ts type-equiv /** diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index ebacfdd1df..9e2317f470 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: a14490f855268cdaa65bc0d3d8968ece56d25bf3 -README.zh.md: 44e8205dfdc7debd625c1ad4819f56bcfbe94728 +README.md: 3689ee4d497cf2e6dbf74450a9ec71c5138170c3 +README.zh.md: 3c208d1626daa1d93c6e1cc6bd483ded21338f75 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index a14490f855..3689ee4d49 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -85,7 +85,7 @@ Objects land at `/attachments/v1/objects//`; eq Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to the route projection, a total-pixel budget or a provider token grid, then to the route per-side cap; it resizes by the long edge only, so the encoder rounds the short edge exactly as the projection predicts, and then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, projection, per-side cap, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 44e8205dfd..3c208d1626 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 -请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下先缩放到路由投影,即总像素预算或提供方 token 网格,再缩放到路由单边上限。缩放只按长边给定,短边由编码器四舍五入,与投影预测的尺寸一致。随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、投影、单边上限、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 6a5e2093df..bab63d6a8a 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 35fde71833fa13b26662244cfa68cec17b90b663 -README.zh.md: b0e98d1e4a0c35082a8eef23f69659f47916d3f7 +README.md: e17d6bea53127d54babad919bdd546388b742d5f +README.zh.md: 5bf061304684cbdd8be00709ba9d1224b6c68ffa diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 35fde71833..e17d6bea53 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -66,13 +66,13 @@ This section explains the design decisions behind the seam and the service opera - **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization. - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. -- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. +- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions under an explicit projection rule, per-side cap, and byte target, while execution filesystems may map the immutable host object to a model-readable process path. - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, projection rule, per-side cap, byte target, and encoder settings. The pure `requestImageDimensions` and `tokenGridProjection` exports compute each projection's aspect-preserving dimensions from a total-pixel budget or a provider token grid, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index b0e98d1e4a..5bf0613046 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -66,13 +66,13 @@ kind: "package-reference" - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。 -- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 +- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按显式的投影规则、单边上限与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、投影规则、单边上限、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `tokenGridProjection` 按总像素预算或提供方 token 网格计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index a33c4084ce..734062fae9 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 41646097f80dfad9cc523595b9d2820c28fde66b -README.zh.md: c7a1e40952dff7a804880dcd266114cecb20918f +README.md: c99431b0d415ef82d4833d7ca38b531b3cd38937 +README.zh.md: 1d515c5ee8a855981e529b7fff03cbf18fa9dde7 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 41646097f8..c99431b0d4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -74,7 +74,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Streaming with thinking and images -An image-capable route resolves each durable reference into a deterministic request version under its pixel and byte budgets. `imagePixelBudget` accepts a positive integer or `low`; omission uses 640,000 total pixels, `low` uses 512×512 total pixels, and `imageMaxBytes` defaults to 1 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. +An image-capable route resolves each durable reference into a deterministic request version under its projection, per-side cap, and byte target. Omitting `imagePixelBudget` projects onto the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and other aspect ratios keep the largest grid inside the cap; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. The adapter normally uploads those exact request bytes through the DeepSeek Files API and sends file-id blocks. A failed or timed-out file resolution rebuilds the whole chat request with the same request versions as base64 data URLs; one request never mixes file ids and inline images. Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. @@ -190,8 +190,6 @@ These limits define where the adapter stops and future work begins. They are cur - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. - The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts). -- The default request-image projection caps total pixels at 640,000, below the provider's roughly 1300×1300 processing budget, so it can discard usable detail. Each model's `imagePixelBudget` can override this default; changing the default affects request content and needs separate snapshot verification ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md)). - ### Dev Note diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c7a1e40952..1d515c5ee8 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -74,7 +74,7 @@ kind: "package-reference" ### 带 thinking 与图片的流式调用 -支持图片的路由会在自身像素与字节预算内把每个持久引用解析为确定性请求版本。`imagePixelBudget` 接受正整数或 `low`;省略时使用总计 640,000 像素,`low` 使用总计 512×512 像素,`imageMaxBytes` 默认为 1 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 +支持图片的路由会在自身投影、单边上限与字节目标内把每个持久引用解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格投影,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,其他宽高比保留上限内最大的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 适配器通常通过 DeepSeek Files API 上传这些确切请求字节,并发送 file-id 块。文件解析失败或超时会用相同请求版本的 base64 data URL 重建整份 chat 请求;一次请求绝不混用 file id 与内联图片。缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 @@ -190,8 +190,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 - 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。 -- 默认请求图片投影限制为 640,000 总像素,低于提供方约 1300×1300 的处理预算,可能丢弃可用细节。每个模型的 `imagePixelBudget` 可以覆盖默认值。更改默认值会改变请求内容,需要单独验证快照([决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md))。 - ### 开发备注 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e7436d98be..cd6f18083a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1001,6 +1001,16 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageRequestProjection", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "TokenGridProjection", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "ImageRequestPolicy", From 529434e1dfe2a25a2cfa1af64d039aaa4a3a4416 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 16:28:45 +0800 Subject: [PATCH 24/60] fix(ci): admit any `.css` refusal for the dockkit corpus exemption The built-bundle import sweep pinned the Dockkit exemption to `packages/client/ui-dockkit/lib/components/dockkit.module.css`, but the built bundle imports `@deepseek-ai/dsh-client-ui-primitives` before its own stylesheet, and the tsx launcher resolves that specifier through tsconfig `paths` into the dependency's `src` tree. The sweep therefore reports `packages/client/ui-primitives/src/StateDot.module.css`, so the pinned path can never match where client build output exists; the Windows complete-gate inventory reports the exempt bundle as an unexpected baseline failure. Admit Node's unknown-`.css`-extension refusal for any stylesheet and keep every other failure a finding. The classification controls cover an admitted Dockkit stylesheet, the dependency's source stylesheet, another extension, an arbitrary message, another error code, and a stale exemption. Agent Note: .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md --- ...09-10-built-bundle-css-exemption.i18n.yaml | 6 +++++ .../2026-09-10-built-bundle-css-exemption.md | 27 +++++++++++++++++++ ...026-09-10-built-bundle-css-exemption.zh.md | 27 +++++++++++++++++++ ...09-08-ci-completion-observations.i18n.yaml | 4 +-- .../2026-09-08-ci-completion-observations.md | 2 +- ...026-09-08-ci-completion-observations.zh.md | 2 +- .../webworker-runtime/README.i18n.yaml | 4 +-- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../tests/compile/transform-corpus-check.ts | 21 +++++++++++---- .../tests/compile/transform-corpus.spec.ts | 18 ++++++++++--- 11 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml new file mode 100644 index 0000000000..4fdb59676b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.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/bug-fix/2026-09-10-built-bundle-css-exemption.md +2026-09-10-built-bundle-css-exemption.md: 81ff2770c675675cc43b34ecbad2b03fb1b2e34b +2026-09-10-built-bundle-css-exemption.zh.md: 77de7138017ca053eda0ed0d6b16f2e32c5a5857 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md new file mode 100644 index 0000000000..81ff2770c6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md @@ -0,0 +1,27 @@ +# Agent Note: Built-bundle exemption for a failing stylesheet + +Status: implemented + +English | [中文](2026-09-10-built-bundle-css-exemption.zh.md) + +## Problem + +The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) exempts the Dockkit bundle because Node cannot load its stylesheets, and admitted one exact stylesheet path as the evidence: `packages/client/ui-dockkit/lib/components/dockkit.module.css`. The built bundle imports the workspace package `@deepseek-ai/dsh-client-ui-primitives` before its own stylesheet, and the `tsx` launcher resolves that specifier through tsconfig `paths` into the dependency's `src` tree, so the sweep reports `ERR_UNKNOWN_FILE_EXTENSION` for `packages/client/ui-primitives/src/StateDot.module.css`. The pinned path cannot match on a tree with client build output, and the Windows complete-gate inventory reported the exempt bundle as an unexpected baseline failure. + +## Decision + +The Dockkit exemption admits Node's unknown-`.css`-extension refusal for any stylesheet. Another extension, another error code, and an unrelated error message stay findings, as does an exempt bundle that imports cleanly. + +## Alternatives considered + +**Admit the dependency's source stylesheet alongside the pinned one.** That file is what the sweep reports, but the bundle's import order and the launcher's path mapping select it. Pinning it would certify those two details instead of the `.css` exemption. + +**Classify every CSS exemption by the same rule.** The Dockkit pin is the recorded evidence this change corrects; the other two stylesheet exemptions were never classified, and tightening them would change what they admit beyond the reported defect. + +**Drop the classification and admit any failure.** A bundle that stopped importing for an unrelated reason would then hide inside the exemption total. + +## Consequences + +The sweep reports the Dockkit bundle when it stops importing for any reason other than a `.css` import, and the entry no longer asserts which stylesheet fails. [Scoped resolve/load hooks](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts) exercise an admitted Dockkit stylesheet, the dependency's source stylesheet, another extension, an arbitrary message, another error code, and a stale exemption without modifying shared build artifacts. + +The [CI observation decision](../testing/2026-09-08-ci-completion-observations.md) keeps the fixture completion and isolation decisions it owns; its built-client classification paragraph keeps the sweep summary and links here for the admitted evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md new file mode 100644 index 0000000000..77de713801 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 构建产物豁免以失败的样式表为准 + +Status: implemented + +[English](2026-09-10-built-bundle-css-exemption.md) | 中文 + +## 问题 + +[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)因 Node 无法加载 Dockkit bundle 的样式表而豁免该 bundle,并曾以一个精确的样式表路径作为接受证据:`packages/client/ui-dockkit/lib/components/dockkit.module.css`。该已构建 bundle 在导入自身样式表之前先导入 workspace 包 `@deepseek-ai/dsh-client-ui-primitives`,`tsx` 启动器又通过 tsconfig `paths` 把这个说明符解析进依赖的 `src` 树,因此 sweep 报告的是 `packages/client/ui-primitives/src/StateDot.module.css` 的 `ERR_UNKNOWN_FILE_EXTENSION`。固定路径在带有 client 构建输出的树上无法匹配,Windows 完整门禁的清单于是把这个豁免 bundle 报告为意外的基线失败。 + +## 决策 + +Dockkit 豁免接受 Node 对任意样式表因未知 `.css` 扩展名而拒绝加载。其他扩展名、其他错误码和无关的错误消息仍计为发现,能顺利完成导入的豁免 bundle 同样计为发现。 + +## 考虑过的替代方案 + +**在固定路径之外同时接受依赖的源样式表。** sweep 报告的正是该文件,但选中它的是 bundle 的导入顺序和启动器的路径映射。固定该路径认定的将是这两处细节,而非 `.css` 豁免。 + +**用同一规则分类每个 CSS 豁免。** Dockkit 固定路径是本次修改所纠正的已记录证据;另外两个样式表豁免从未被分类,收窄它们会在所报告缺陷之外改变其接受的内容。 + +**放弃分类,接受任何失败。** 届时因无关原因停止导入的 bundle 会隐藏在豁免总数之内。 + +## 后果 + +只要 Dockkit bundle 因 `.css` 导入之外的任何原因停止导入,sweep 就会报告它,该条目也不再断言失败的是哪个样式表。[限定范围的 resolve/load hook](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts)覆盖被接受的 Dockkit 样式表、依赖的源样式表、其他扩展名、任意消息、其他错误码和陈旧豁免,不修改共享构建产物。 + +[CI 观察决策](../testing/2026-09-08-ci-completion-observations.zh.md)保留其拥有的 fixture 完成与隔离决策;其已构建 Client 导入分类段落保留 sweep 摘要,并就被接受的证据链接到本文。 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml index c5a83678cc..0409fff118 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.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/testing/2026-09-08-ci-completion-observations.md -2026-09-08-ci-completion-observations.md: 8af4685a5e2c51c1edf4a41b47088916223e7a6c -2026-09-08-ci-completion-observations.zh.md: e3147bb7ac39877b46820862e9af4645803a37a2 +2026-09-08-ci-completion-observations.md: e3de87a70419778407b5eb230cec64ce088dd0c0 +2026-09-08-ci-completion-observations.zh.md: 4e95d6fb9dc9b1ecac2e3d0dfa262b819de16261 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md index 8af4685a5e..e3de87a704 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md @@ -30,7 +30,7 @@ The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.sp ### Built-client import classification -The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its exact `dockkit.module.css` path. Other errors and unexpectedly successful exempt imports fail. Scoped resolve/load hooks exercise expected CSS failure, arbitrary failure, another stylesheet, another error code, and stale exemption without modifying shared build artifacts. +The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for a `.css` file, and fails every other error and every unexpectedly successful exempt import; the [stylesheet exemption decision](../bug-fix/2026-09-10-built-bundle-css-exemption.md) owns which stylesheets that covers. Scoped resolve/load hooks exercise expected CSS failure, another stylesheet, another extension, arbitrary failure, another error code, and stale exemption without modifying shared build artifacts. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md index e3147bb7ac..4e95d6fb9d 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md @@ -30,7 +30,7 @@ Status: implemented ### 已构建 Client 的导入分类 -[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对准确的 `dockkit.module.css` 路径报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle。其他错误以及意外成功的豁免导入都会失败。限定范围的 resolve/load hook 覆盖预期 CSS 失败、任意失败、其他 stylesheet、其他错误码和过期豁免,不修改共享构建产物。 +[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对某个 `.css` 文件报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle,其他每个错误以及每个意外成功的豁免导入都会失败;该豁免覆盖哪些样式表由[样式表豁免决策](../bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)拥有。限定范围的 resolve/load hook 覆盖预期 CSS 失败、其他样式表、其他扩展名、任意失败、其他错误码和陈旧豁免,不修改共享构建产物。 ## 考虑过的替代方案 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index ea8de3d587..79bc2c7587 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: 67604adeb60f83953bc7c6841aa5d3d85ca38448 -README.zh.md: 985345efc3bc4fe63226a3e76b3432fa4bde06fa +README.md: 072194713d3038fd714855c13a05dc213d20f4b1 +README.zh.md: 79f17edb958516dab94b42869a3a6d6cf11cb8b9 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 67604adeb6..072194713d 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -32,7 +32,7 @@ Three build artifacts and one source-owned process layer: Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The fixture generator owns current-generation logs and the projection cache; committed predecessor logs remain byte-identical alongside them. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. -The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts only Node’s unknown-extension error for the expected stylesheet; other failures and unexpectedly successful exempt imports remain errors. See the [CI observation decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). +The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts Node’s unknown-`.css`-extension refusal for any stylesheet rather than one pinned path; another extension or error code, and an unexpectedly successful exempt import, remain errors. See the [stylesheet exemption decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md). ----- diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 985345efc3..79f17edb95 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -32,7 +32,7 @@ kind: "package-library" 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。fixture 生成器负责当前代日志与投影缓存;已提交的前代日志逐字节保持不变,与它们并存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 -[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外仅接受 Node 针对预期样式表报告的未知扩展名错误;其他失败以及意外成功的豁免导入仍然报错。参见 [CI 观察决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 +[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外接受 Node 针对任意样式表报告的未知 `.css` 扩展名错误,而不限定某一固定路径;其他扩展名或错误码,以及意外成功的豁免导入,仍然报错。参见 [样式表豁免决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)。 ----- diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts index 377166ee1b..4139846db2 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts @@ -24,7 +24,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url' const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) const DOCKKIT_BUNDLE = 'packages/client/ui-dockkit/lib/index.js' -const DOCKKIT_CSS = join(repositoryRoot, 'packages/client/ui-dockkit/lib/components/dockkit.module.css') /** * Files Node's ESM loader cannot import in this repository. None is a finding: @@ -34,6 +33,11 @@ const DOCKKIT_CSS = join(repositoryRoot, 'packages/client/ui-dockkit/lib/compone * imports the win32-process package earlier in the serial sweep (a distinct * module instance under its node_modules URL), so win32-process's own file-URL * import re-registers koffi's type names and fails as the second load. + * + * The Dockkit entry admits only Node's refusal of a `.css` import, not one + * exact stylesheet: the swept bundle's first unresolvable stylesheet depends on + * its import graph and on how the launcher resolves workspace packages, so a + * pinned path would certify an import order instead of the `.css` exemption. */ const BASELINE_EXEMPT: ReadonlyMap = new Map([ [DOCKKIT_BUNDLE, 'imports .css, which bare Node cannot load'], @@ -95,6 +99,16 @@ function discover(): string[] { */ const relative = (path: string): string => path.slice(repositoryRoot.length).replaceAll('\\', '/') +/** + * @param reason - the value the corpus import rejected with. + * @returns whether bare Node refused a `.css` import, the failure the Dockkit + * exemption admits. + */ +const isUnknownCssExtension = (reason: unknown): boolean => + reason instanceof Error + && 'code' in reason && reason.code === 'ERR_UNKNOWN_FILE_EXTENSION' + && reason.message.startsWith('Unknown file extension ".css" for ') + const files = process.argv.slice(2).length > 0 ? process.argv.slice(2).map(path => (path.startsWith('/') ? path : join(process.cwd(), path))) : discover() @@ -111,10 +125,7 @@ if (files.length === 0) { try { await import(pathToFileURL(file).href) } catch (reason) { - const expectedDockkitCss = reason instanceof Error - && 'code' in reason && reason.code === 'ERR_UNKNOWN_FILE_EXTENSION' - && reason.message === `Unknown file extension ".css" for ${DOCKKIT_CSS}` - if (exemption === undefined || (key === DOCKKIT_BUNDLE && !expectedDockkitCss)) { + if (exemption === undefined || (key === DOCKKIT_BUNDLE && !isUnknownCssExtension(reason))) { // A bundle that stopped being importable is a real finding, so it // fails rather than joining a tolerated total. fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`) diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts index 8f8819b454..e09ca108e0 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts @@ -32,19 +32,29 @@ test('every built bundle imports under Node', (context) => { }, 900_000) // The hook replaces only the chosen bundle; shared build artifacts stay intact. +// The admitted evidence is Node's unknown-`.css`-extension refusal whatever +// stylesheet it names; another extension, error code, or message is a finding. test.each([ ['expected-css', 0, 'baselineExempt=1 unexpectedBaselineFailure=0'], + ['other-css', 0, 'baselineExempt=1 unexpectedBaselineFailure=0'], + ['other-extension', 1, '- UNEXPECTED BASELINE FAILURE'], ['error', 1, '- UNEXPECTED BASELINE FAILURE'], - ['other-css', 1, '- UNEXPECTED BASELINE FAILURE'], ['other-code', 1, '- UNEXPECTED BASELINE FAILURE'], ['clean', 1, '- STALE EXEMPTION'], ] as const)('classifies dockkit import: %s', (mode, status, finding) => { const root = new URL('../../../../../', import.meta.url) const bundle = 'packages/client/ui-dockkit/lib/index.js' - const css = fileURLToPath(new URL('packages/client/ui-dockkit/lib/components/dockkit.module.css', root)) + const cssBase = fileURLToPath(new URL('packages/client/ui-dockkit/lib/components/dockkit.module', root)) + const extension = mode === 'other-extension' ? '.wasm' : '.css' + // The other stylesheet is the dependency's, which the launcher resolves into + // its source tree: the sweep reports it where an exact-path admission would + // reject the bundle. + const file = mode === 'other-css' + ? fileURLToPath(new URL('packages/client/ui-primitives/src/StateDot.module.css', root)) + : `${cssBase}${extension}` const message = mode === 'error' ? 'dockkit-negative-control' - : `Unknown file extension ".css" for ${mode === 'other-css' ? `${css}.other.css` : css}` + : `Unknown file extension "${extension}" for ${file}` const source = mode === 'clean' ? 'export {}' : `throw Object.assign(new Error(${JSON.stringify(message)}), { code: ${JSON.stringify(mode === 'other-code' ? 'ERR_OTHER' : 'ERR_UNKNOWN_FILE_EXTENSION')} })` @@ -74,7 +84,7 @@ test.each([ expect(finished.signal).toBeNull() expect(finished.status, output).toBe(status) expect(output).toContain(finding) - if (mode === 'error' || mode === 'other-css' || mode === 'other-code') { + if (finding.startsWith('- UNEXPECTED BASELINE FAILURE')) { expect(output).toContain(`- UNEXPECTED BASELINE FAILURE ${bundle}: ${message}\n`) } }) From ba30b73f7b387d153ee46744ad1e8babd5d5debd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 10 Sep 2026 16:46:28 +0800 Subject: [PATCH 25/60] =?UTF-8?q?refactor(attachment):=20=E6=B1=82?= =?UTF-8?q?=E8=A7=A3=E5=99=A8=E7=95=99=E5=9C=A8=20llm-deepseek=EF=BC=8C?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=B1=82=E5=8F=AA=E6=8E=A5=E6=94=B6=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageRequestPolicy 改为 ImageRequestTarget,只含目标宽、高和字节目标;dsh-attachment 删除 token-grid 投影联合和 tokenGridProjection,只保留提供方无关的 requestImageDimensions 与 longEdgeDimensions。v41 求解器回到 llm-deepseek 的 image-tokens.ts,新增 deepSeekRequestImageDimensions,resolveRequestImageTarget 为每张图算出目标并加 4096 单边上限;pi-ai 路由按自身像素预算推导目标。 按 review 意见修正:v41 计价 Note 里已删除常量的过期句子就地改写;ProjectedDimensions 登记到 attachment 子系统页与 type-equiv 清单;Note 区分发送尺寸与提供方补齐后的网格;短边取整的措辞改为「按路由预测」。 --- ...pseek-image-token-calculator-v41.i18n.yaml | 4 +- ...-10-deepseek-image-token-calculator-v41.md | 2 +- ...-deepseek-image-token-calculator-v41.zh.md | 2 +- ...eek-v41-request-image-projection.i18n.yaml | 4 +- ...0-deepseek-v41-request-image-projection.md | 12 +- ...eepseek-v41-request-image-projection.zh.md | 12 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 56 ++----- docs/subsystems/attachment.zh.md | 56 ++----- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/index.ts | 12 +- .../attachment-local/src/normalization.ts | 12 +- .../attachment-local/src/request-image.ts | 85 ++++------ .../attachment-local/tests/index.spec.ts | 2 +- .../tests/request-image-verification.spec.ts | 2 +- .../tests/request-image.spec.ts | 71 ++++---- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 4 +- packages/attachment/attachment/src/index.ts | 14 +- .../attachment/src/request-projection.ts | 126 +++----------- packages/attachment/attachment/src/types.ts | 42 +---- .../attachment/attachment/tests/index.spec.ts | 8 +- .../tests/request-projection.spec.ts | 70 ++------ .../extensions/tool-cordis/src/api-catalog.ts | 12 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 11 +- packages/llm/llm-deepseek/src/image-tokens.ts | 154 +++++++++++++++--- packages/llm/llm-deepseek/src/index.ts | 5 +- .../llm/llm-deepseek/src/request-pricing.ts | 62 ++++--- .../llm/llm-deepseek/tests/adapter.e2e.ts | 4 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 31 ++-- .../llm-deepseek/tests/dynamic-config.spec.ts | 4 +- .../llm-deepseek/tests/image-tokens.spec.ts | 25 ++- packages/llm/llm-pi-ai/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/context.ts | 24 ++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 9 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 4 +- packages/llm/llm-pi-ai/tests/convert.spec.ts | 10 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 4 +- scripts/gen-cordis-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 11 +- 52 files changed, 463 insertions(+), 552 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml index 37a4a4b687..2ff4007876 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.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-09-10-deepseek-image-token-calculator-v41.md -2026-09-10-deepseek-image-token-calculator-v41.md: 4e57118987ddb7c2e2574d0dc40b02d8acf49e1a -2026-09-10-deepseek-image-token-calculator-v41.zh.md: 209ddb99616d5d94fd4f390706304a722137bd15 +2026-09-10-deepseek-image-token-calculator-v41.md: 668738221ebe3e634be7e4fe9d07c9627e0ceb4a +2026-09-10-deepseek-image-token-calculator-v41.zh.md: a483282c6da667c2b8e32accb091e065419d44fc diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md index 4e57118987..668738221e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md @@ -12,7 +12,7 @@ English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md) `image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged. -The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged. +The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers. This change left the 640,000 total-pixel projection the harness applied before pricing and the catalog model ids as they were; the [successor](2026-09-10-deepseek-v41-request-image-projection.md) later replaced that projection with the same grid, so omitting `imagePixelBudget` now projects onto the token grid while a positive integer or `low` keeps a total-pixel budget. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md index 209ddb9961..a483282c6d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md @@ -12,7 +12,7 @@ Status: implemented `image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。 -测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。 +测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字。本次改动保留了 harness 在定价前应用的 640,000 总像素投影和 catalog 模型 id;[后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)把该投影换成了同一套网格,现在省略 `imagePixelBudget` 走 token 网格,正整数或 `low` 仍走总像素预算。 ## 备选方案 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml index 2a3977f21e..964d3e2a81 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.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-09-10-deepseek-v41-request-image-projection.md -2026-09-10-deepseek-v41-request-image-projection.md: 32b1eb4251ec12005879551bfad363a98034291b -2026-09-10-deepseek-v41-request-image-projection.zh.md: 0360dcc655e1b366ca7169784b63e2650ee3dd09 +2026-09-10-deepseek-v41-request-image-projection.md: 87f79f244dcf61039fe27ef170ba654ee7a1a62a +2026-09-10-deepseek-v41-request-image-projection.zh.md: a5d8a4e99a3c7b4f3fc26f385ae1e764b535fe15 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md index 32b1eb4251..87f79f244d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md @@ -6,26 +6,26 @@ English | [中文](2026-09-10-deepseek-v41-request-image-projection.zh.md) ## Problem -The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps 1708×966, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid. +The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps a 1708×966 grid, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid. Two smaller gaps sat beside it. The request version had no per-side cap: the provider rejects any image over 4096 pixels per side once a request carries 15 or more images, while normalization admits an 8192-pixel long edge, so a many-image session could fail on one thin image. The 1 MiB encoded-byte target was sized for 640,000-pixel outputs and would push a 1302×1302 photograph down the JPEG quality ladder. ## Decision -`ImageRequestPolicy` in `dsh-attachment` becomes a projection rule plus an optional per-side cap plus the byte target. The projection is a closed union: `pixel-budget` keeps the previous total-pixel geometry, and `token-grid` carries the patch size, downsampling ratio, and token cap of the provider grid. `request-projection.ts` owns the grid solver as `tokenGridProjection`, and `deepSeekImageTokens` in `llm-deepseek` reuses it for pricing, so the projection that sizes the request and the estimator that prices it are one solver. `requestImageDimensions` applies the projection, then the per-side cap, and keeps the source long edge exact while rounding the short edge; the local store resizes by the long edge only, so the encoded dimensions equal the predicted ones and the pricing handle text names what is actually sent. The provider pads the short edge to whole patches on its side. +The route chooses each request image's dimensions; the attachment provider only resizes and encodes to them. `ImageRequestPolicy` in `dsh-attachment` becomes `ImageRequestTarget`: a width, a height, and the byte target for one attachment. `readImageRequest` resizes by the source long edge alone without enlargement, so the encoder derives the short edge as the route predicted, and keys its cache by the attachment id, target dimensions, byte target, encoder settings, and the new `request-image-v6` transform version, so no earlier cache entry or upload mapping is reused. `dsh-attachment` keeps two provider-neutral geometry exports: `requestImageDimensions` for a total-pixel budget and `longEdgeDimensions` for an exact long edge with a rounded short edge. -DeepSeek routes resolve the grid when `imagePixelBudget` is omitted, a `pixel-budget` projection for a positive integer or the 512×512 `low` preset, a 4096-pixel per-side cap on every request image so the image count never changes a projection, and a 2 MiB byte target. The pi-ai route keeps its 2048×2048 pixel budget. The request-image transform version moves to `request-image-v6`, and the cache descriptor records the projection fields in a fixed order and the per-side cap, so no earlier cache entry or upload mapping is reused. Sources within the grid are sent at their own dimensions; small images are never enlarged because the provider scales up below 544×544 pixels itself. +`llm-deepseek` owns the provider rule. `image-tokens.ts` keeps the verbatim `v41` solver and adds `deepSeekRequestImageDimensions`: the source itself when its patch-padded grid fits the cap, otherwise the source aspect ratio at the solved grid's long edge, so a 3840×2160 source is sent as 1708×961 and the provider pads it to its 1708×966 grid. `resolveRequestImageTarget` applies that solver when `imagePixelBudget` is omitted, `requestImageDimensions` for a positive integer or the 512×512 `low` preset, then a 4096-pixel per-side cap on every request image so the image count never changes a target, and the route's 2 MiB byte target. Pricing prices `deepSeekImageTokens` of the same target, so the estimator and the sent image come from one solver. The pi-ai route derives its targets from its unchanged 2048×2048 pixel budget. Small images are never enlarged because the provider scales up below 544×544 pixels itself. ## Alternatives considered **Raise the pixel budget to 1302×1302.** A total-pixel budget is right only for squares: a 16:9 source would be sent at 1.69 million pixels when the grid keeps 1.65 million, and a 4:1 source when it keeps 1.59 million, while extreme ratios would lose detail the grid keeps. One rule that reproduces the provider removes the guesswork. -**Send the solver's exact dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; the issue requires the aspect ratio preserved, the padding on the provider side does not change the token count, and pricing reproduces the provider from the sent dimensions either way. +**A `token-grid` projection kind on the attachment policy, with the solver in `dsh-attachment`.** This was built first: the policy became a closed union of `pixel-budget` and `token-grid`, the solver moved into `request-projection.ts`, and `deepSeekImageTokens` imported it back. It put one provider's layout formula and patch constants into the provider-neutral package under a generic-looking name, needed an `unscaled` flag so the store could tell "send the source" from "send the solved size", and would grow a new union member for every provider rule. Handing the store a finished target keeps the provider rule beside the provider's pricing and leaves the store with no projection vocabulary at all. -**A function-valued projection on the policy.** The cache and upload identity is a digest over serialized policy fields, so the projection has to be data. +**Send the solver's exact grid dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; the issue requires the aspect ratio preserved, the padding on the provider side does not change the token count, and pricing reproduces the provider from the sent dimensions either way. **Keep the 1 MiB target.** The target is not a cap: an output over it is still sent at the smallest ladder quality. At 1302×1302 a JPEG photograph at quality 85 lands between 400 KB and 1.2 MB, so 2 MiB keeps most images at the top quality and lets more PNG screenshots pass through losslessly, while the inline base64 fallback still holds about seven such images under its 20 MiB bound. ## Consequences -A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator matches provider usage for the sent version. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots are unchanged, and the `llm-deepseek` adapter tests with mocked attachments pin the resolved policy and the projected handle text. +A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator matches provider usage for the sent version. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots cannot record the sent dimensions; the `llm-deepseek` adapter tests pin the resolved targets and the projected handle text against a mock server, and the local store tests resize real images to targets. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md index 0360dcc655..a5d8a4e99a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md @@ -6,26 +6,26 @@ Status: implemented ## 问题 -harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。 +harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966 的网格,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。 旁边还有两个较小的缺口。请求版本没有单边上限:请求包含 15 张及以上图片时,提供方拒绝任何单边超过 4096 像素的图片,而规范化允许 8192 像素长边,多图会话可能因一张细长图失败。1 MiB 编码字节目标是按 640,000 像素输出定的,会把 1302×1302 的照片压到 JPEG 质量阶梯的低档。 ## 决策 -`dsh-attachment` 里的 `ImageRequestPolicy` 改为投影规则加可选单边上限加字节目标。投影是封闭联合类型:`pixel-budget` 保留原来的总像素几何,`token-grid` 携带提供方网格的 patch 大小、降采样比和 token 上限。`request-projection.ts` 以 `tokenGridProjection` 承载网格求解器,`llm-deepseek` 的 `deepSeekImageTokens` 复用它计价,于是决定请求尺寸的投影和为它计价的预估器是同一个求解器。`requestImageDimensions` 先应用投影,再应用单边上限,保持源图长边精确、短边四舍五入;本地存储只按长边缩放,因此编码后的尺寸等于预测尺寸,计价用的句柄文本写的就是实际发送的尺寸。提供方在自己那侧把短边补齐到整数个 patch。 +请求图片的尺寸由路由决定,附件提供方只负责缩放和编码。`dsh-attachment` 里的 `ImageRequestPolicy` 改为 `ImageRequestTarget`,即一张附件的目标宽、高和字节目标。`readImageRequest` 只按源图长边缩放且不放大,编码器按路由预测的方式推出短边;缓存按附件 id、目标尺寸、字节目标、编码参数和新的 `request-image-v6` 变换版本取键,因此之前的缓存条目和上传映射都不会被复用。`dsh-attachment` 保留两个提供方无关的几何导出:按总像素预算的 `requestImageDimensions`,以及长边精确、短边四舍五入的 `longEdgeDimensions`。 -DeepSeek 路由在省略 `imagePixelBudget` 时解析为网格,正整数或 512×512 的 `low` 预设解析为 `pixel-budget` 投影,每张请求图片都加 4096 像素单边上限,使图片数量不会改变投影,字节目标为 2 MiB。pi-ai 路由保留 2048×2048 像素预算。请求图片变换版本升到 `request-image-v6`,缓存描述符按固定顺序记录投影字段和单边上限,因此之前的缓存条目和上传映射都不会被复用。网格内的源图按自身尺寸发送;小图不放大,因为提供方自己会放大 544×544 像素以下的图片。 +提供方规则归 `llm-deepseek`。`image-tokens.ts` 保留逐字移植的 `v41` 求解器,并新增 `deepSeekRequestImageDimensions`:补齐 patch 后的网格在上限内就发源图本身,否则按源图宽高比取求解网格的长边,于是 3840×2160 的源图以 1708×961 发送,提供方再把它补齐到 1708×966 的网格。`resolveRequestImageTarget` 在省略 `imagePixelBudget` 时用这个求解器,正整数或 512×512 的 `low` 预设用 `requestImageDimensions`,然后对每张请求图片加 4096 像素单边上限,使图片数量不会改变目标,最后带上路由的 2 MiB 字节目标。计价对同一个目标算 `deepSeekImageTokens`,预估器和发出的图片来自同一个求解器。pi-ai 路由从它不变的 2048×2048 像素预算推导目标。小图不放大,因为提供方自己会放大 544×544 像素以下的图片。 ## 备选方案 **把像素预算提高到 1302×1302。** 总像素预算只对正方形正确:16:9 的源图会按 169 万像素发送而网格只保留 165 万,4:1 的源图网格只保留 159 万,极端比例又会丢掉网格本会保留的细节。一条复现提供方的规则消除了猜测。 -**用填充缩放发送求解器的精确尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;issue 要求保持宽高比,提供方那侧的补齐不改变 token 数,而计价无论如何都从发送尺寸复现提供方。 +**在附件策略上加 `token-grid` 投影种类,求解器放进 `dsh-attachment`。** 最初就是这样做的:策略变成 `pixel-budget` 和 `token-grid` 的封闭联合,求解器搬进 `request-projection.ts`,`deepSeekImageTokens` 再从那里引回来。这把一家提供方的布局公式和 patch 常量放进了提供方无关的包,还起了个看似通用的名字;存储层需要一个 `unscaled` 标志来区分「发源图」和「发求解尺寸」;以后每多一家提供方规则,联合就要多长一个分支。把算好的目标交给存储层,提供方规则和它的计价放在一起,存储层不需要任何投影词汇。 -**策略里放函数形式的投影。** 缓存和上传身份是序列化策略字段的摘要,投影必须是数据。 +**用填充缩放发送求解器的精确网格尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;issue 要求保持宽高比,提供方那侧的补齐不改变 token 数,而计价无论如何都从发送尺寸复现提供方。 **保留 1 MiB 目标。** 目标不是上限:超过它的输出仍会以阶梯最小质量发送。1302×1302 的 JPEG 照片在质量 85 时约 400 KB 到 1.2 MB,2 MiB 让多数图片停在最高质量,也让更多 PNG 截图无损直发,而内联 base64 回退在 20 MiB 上界内仍能容纳约七张这样的图片。 ## 后果 -正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器对发送版本的计价与提供方 usage 一致。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照不变;`llm-deepseek` 适配器用 mock 附件的测试固定了解析出的策略和投影后的句柄文本。 +正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器对发送版本的计价与提供方 usage 一致。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照记录不到发送尺寸;`llm-deepseek` 适配器测试对着 mock 服务器固定了解析出的目标和投影后的句柄文本,本地存储测试把真实图片缩放到目标尺寸。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 46500b4add..24421501fa 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: 259cbe8afe509cf448a54fa9fcddf297d4f2f588 -config-catalog.zh.md: 49e1064239f20fc6236c3dde6f2698791a9a1183 +config-catalog.md: de3b5c686121781860fae92a20f02e848508ee55 +config-catalog.zh.md: 9a2f1e86168ce5ebfe6a07ea258b4a94bba4c3fc diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 259cbe8afe..de3b5c6861 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1098,7 +1098,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:128`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 49e1064239..9a2f1e8616 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1100,7 +1100,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:128`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index faa0c08226..b0560c8499 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: bbcf438646eb4cae78ef5b86ccbf9e04b927fa55 -attachment.zh.md: 0e3c58fc59cb319bde681bdfa7124c852cb6da19 +attachment.md: db1c1cfa15f5d0815236f96b6472cbbd864decbd +attachment.zh.md: 82379de5049012c0912734dc94257d6b8f354ad1 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index bbcf438646..db1c1cfa15 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -126,52 +126,22 @@ interface StoredImageAttachment { ``` ```ts type-equiv -/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */ -type ImageRequestProjection = - | { - /** Hard cap on width multiplied by height. */ - kind: 'pixel-budget' - /** Maximum width multiplied by height after projection. */ - maxPixels: number - } - | { - /** - * Largest aspect-preserving patch grid whose token count - * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout. - */ - kind: 'token-grid' - /** Patch edge in pixels; a downscaled edge is a whole number of patches. */ - patchSize: number - /** Patches per token cell along each axis. */ - downsampleRatio: number - /** Token cap for one image. */ - maxTokens: number - } -``` - -```ts type-equiv -/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */ -interface TokenGridProjection { - /** Retained width: the patch-padded source when it fits, otherwise the solved width. */ +/** Deterministic request-image target selected by one exact model route for one attachment. */ +interface ImageRequestTarget { + /** Target width in pixels; a target above the source keeps the source width. */ width: number - /** Retained height: the patch-padded source when it fits, otherwise the solved height. */ + /** Target height in pixels; a target above the source keeps the source height. */ height: number - /** Tokens charged for the retained grid. */ - tokens: number - /** Whether the patch-padded source already fits `maxTokens` without downscaling. */ - unscaled: boolean + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ + maxBytes: number } ``` ```ts type-equiv -/** Deterministic request-image policy selected by one exact model route. */ -interface ImageRequestPolicy { - /** Downscale rule applied before the per-side cap. */ - projection: ImageRequestProjection - /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */ - maxDimension?: number - /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ - maxBytes: number +/** Integer width and height of one projected image. */ +interface ProjectedDimensions { + width: number + height: number } ``` @@ -197,7 +167,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route projection rule, optional per-side cap, and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -320,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output. + * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ -readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise +readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 0e3c58fc59..82379de504 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -126,52 +126,22 @@ interface StoredImageAttachment { ``` ```ts type-equiv -/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */ -type ImageRequestProjection = - | { - /** Hard cap on width multiplied by height. */ - kind: 'pixel-budget' - /** Maximum width multiplied by height after projection. */ - maxPixels: number - } - | { - /** - * Largest aspect-preserving patch grid whose token count - * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout. - */ - kind: 'token-grid' - /** Patch edge in pixels; a downscaled edge is a whole number of patches. */ - patchSize: number - /** Patches per token cell along each axis. */ - downsampleRatio: number - /** Token cap for one image. */ - maxTokens: number - } -``` - -```ts type-equiv -/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */ -interface TokenGridProjection { - /** Retained width: the patch-padded source when it fits, otherwise the solved width. */ +/** Deterministic request-image target selected by one exact model route for one attachment. */ +interface ImageRequestTarget { + /** Target width in pixels; a target above the source keeps the source width. */ width: number - /** Retained height: the patch-padded source when it fits, otherwise the solved height. */ + /** Target height in pixels; a target above the source keeps the source height. */ height: number - /** Tokens charged for the retained grid. */ - tokens: number - /** Whether the patch-padded source already fits `maxTokens` without downscaling. */ - unscaled: boolean + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ + maxBytes: number } ``` ```ts type-equiv -/** Deterministic request-image policy selected by one exact model route. */ -interface ImageRequestPolicy { - /** Downscale rule applied before the per-side cap. */ - projection: ImageRequestProjection - /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */ - maxDimension?: number - /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ - maxBytes: number +/** Integer width and height of one projected image. */ +interface ProjectedDimensions { + width: number + height: number } ``` @@ -197,7 +167,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的投影规则、可选单边上限和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -320,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output. + * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ -readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise +readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index a31580cd95..d543a5f4e7 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: b3d2468a41833139e3c9c9ded69cbb863e5fd8d3 -llm-streaming.zh.md: 2d849206746ee44736ff402a364f913301ed87a8 +llm-streaming.md: 28492b46435e3288609129b0dfc5cb40dd5f7296 +llm-streaming.zh.md: fe02e2ebe5b558162e849e26ff05b8d633c07ba0 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index b3d2468a41..28492b4643 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -249,7 +249,7 @@ interface LlmFailure { ## Request-image pricing -An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model projection and per-side cap, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests. +An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model request target, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests. ```ts type-equiv /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 2d84920674..fe02e2ebe5 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -251,7 +251,7 @@ interface LlmFailure { ## 请求图片定价 -提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的投影与单边上限、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 +提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的请求目标、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 ```ts type-equiv /** diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 9e2317f470..082dc5f45f 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: 3689ee4d497cf2e6dbf74450a9ec71c5138170c3 -README.zh.md: 3c208d1626daa1d93c6e1cc6bd483ded21338f75 +README.md: cf055cf7682c7c97de25a58795ee15e4797ae63b +README.zh.md: 0b14fed2c2eb972e12d655aba31fd0cfbd9e1473 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3689ee4d49..cf055cf768 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -85,7 +85,7 @@ Objects land at `/attachments/v1/objects//`; eq Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to the route projection, a total-pixel budget or a provider token grid, then to the route per-side cap; it resizes by the long edge only, so the encoder rounds the short edge exactly as the projection predicts, and then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, projection, per-side cap, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 3c208d1626..0b14fed2c2 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 -请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下先缩放到路由投影,即总像素预算或提供方 token 网格,再缩放到路由单边上限。缩放只按长边给定,短边由编码器四舍五入,与投影预测的尺寸一致。随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、投影、单边上限、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 7415bf940f..0a37a3304a 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -8,7 +8,7 @@ import type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -244,20 +244,20 @@ export class LocalAttachmentStore extends AttachmentStore { override async readImageRequest( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ): Promise { - return this.requestVersion(ref, policy, undefined, signal) + return this.requestVersion(ref, target, undefined, signal) } private requestVersion( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, stored: StoredImageAttachment | undefined, signal: AbortSignal | undefined, ): Promise { signal?.throwIfAborted() - const variantId = requestImageVariantId(ref, policy) + const variantId = requestImageVariantId(ref, target) const key = String(variantId) let operation = this.requestInflight.get(key) if (operation?.controller.signal.aborted) { @@ -269,7 +269,7 @@ export class LocalAttachmentStore extends AttachmentStore { const request = await readRequestImageFile( this.root, stored ?? await this.readImage(ref, sharedSignal), - policy, + target, sharedSignal, ) return request diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index 199a50af6b..7d514786a2 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -79,10 +79,14 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar /** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */ function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } { - return requestImageDimensions(detected.width, detected.height, { - projection: { kind: 'pixel-budget', maxPixels: policy.maxPixels }, - maxDimension: policy.maxDimension, - }) + const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels) + const longEdge = Math.max(budgeted.width, budgeted.height) + if (longEdge <= policy.maxDimension) return budgeted + const scale = policy.maxDimension / longEdge + return { + width: Math.max(1, Math.floor(budgeted.width * scale)), + height: Math.max(1, Math.floor(budgeted.height * scale)), + } } /** diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index f1bfe435c6..76873742c6 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -4,12 +4,11 @@ import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import sharp, { type Sharp } from 'sharp' -import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType, ImageAttachmentRef, - ImageRequestPolicy, - ImageRequestProjection, + ImageRequestTarget, RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -47,38 +46,19 @@ function checkedInteger(value: number, name: string): number { return value } -function validatePolicy(policy: ImageRequestPolicy): void { - const { projection } = policy - if (projection.kind === 'pixel-budget') { - checkedInteger(projection.maxPixels, 'Image request maxPixels') - } else { - checkedInteger(projection.patchSize, 'Image request patchSize') - checkedInteger(projection.downsampleRatio, 'Image request downsampleRatio') - checkedInteger(projection.maxTokens, 'Image request maxTokens') - } - if (policy.maxDimension !== undefined) checkedInteger(policy.maxDimension, 'Image request maxDimension') - checkedInteger(policy.maxBytes, 'Image request maxBytes') +function validateTarget(target: ImageRequestTarget): void { + checkedInteger(target.width, 'Image request width') + checkedInteger(target.height, 'Image request height') + checkedInteger(target.maxBytes, 'Image request maxBytes') } -/** Projection fields in a fixed key order so equal policies digest identically. */ -function projectionDescriptor(projection: ImageRequestProjection): Record { - return projection.kind === 'pixel-budget' - ? { kind: projection.kind, maxPixels: projection.maxPixels } - : { - kind: projection.kind, - patchSize: projection.patchSize, - downsampleRatio: projection.downsampleRatio, - maxTokens: projection.maxTokens, - } -} - -function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string { +function descriptor(attachment: ImageAttachmentRef, target: ImageRequestTarget): string { return JSON.stringify({ transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, attachmentId: attachment.attachmentId, - projection: projectionDescriptor(policy.projection), - maxDimension: policy.maxDimension ?? null, - encodedByteBudget: policy.maxBytes, + targetWidth: target.width, + targetHeight: target.height, + encodedByteBudget: target.maxBytes, encoding: { webpQualities: IMAGE_ENCODING_QUALITIES, webpEffort: WEBP_ENCODING_EFFORT, @@ -90,23 +70,23 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): } /** - * Complete deterministic identity for one attachment and route-owned request policy. + * Complete deterministic identity for one attachment and route-chosen request target. * @param attachment - provider-independent durable normalized attachment reference. - * @param policy - route-owned projection, per-side cap, and byte policy. + * @param target - route-chosen dimensions and byte target. * @returns branded digest over every request transform input. */ export function requestImageVariantId( attachment: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, ): ReturnType { - return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`) + return ImageVariantId(`sha256:${digest(descriptor(attachment, target))}`) } -/** Resize by the source long edge only, so the encoder rounds the short edge exactly as the projection did. */ -function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp { +/** Resize by the source long edge only, so the encoder derives the short edge as the route predicts. */ +function pipeline(attachment: StoredImageAttachment, target: ImageRequestTarget): Sharp { const byWidth = attachment.ref.width >= attachment.ref.height return sourcePipeline(attachment) - .resize({ ...byWidth ? { width } : { height }, withoutEnlargement: true }) + .resize({ ...byWidth ? { width: target.width } : { height: target.height }, withoutEnlargement: true }) } function sourcePipeline(attachment: StoredImageAttachment): Sharp { @@ -115,13 +95,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp { async function createRequestImage( attachment: StoredImageAttachment, - policy: ImageRequestPolicy, + target: ImageRequestTarget, hasAlpha: boolean, ): Promise { - const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy) - if (dimensions.width === attachment.ref.width - && dimensions.height === attachment.ref.height - && attachment.data.byteLength <= policy.maxBytes) { + if (target.width >= attachment.ref.width + && target.height >= attachment.ref.height + && attachment.data.byteLength <= target.maxBytes) { return { data: attachment.data, mediaType: attachment.ref.mediaType, @@ -130,8 +109,8 @@ async function createRequestImage( } } const encodedVersion = await encodeFirstWithinLimit( - encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha), - policy.maxBytes, + encodingLadder(pipeline(attachment, target), hasAlpha), + target.maxBytes, ) return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion } @@ -142,17 +121,15 @@ function cachePath(root: string, hash: string): string { async function readCached( path: string, - attachment: StoredImageAttachment, - policy: ImageRequestPolicy, + target: ImageRequestTarget, expectedAlpha: boolean, signal?: AbortSignal, ): Promise { try { const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) - const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy) if (detected.depth !== 'uchar' || detected.space !== 'srgb' - || detected.width > maximum.width || detected.height > maximum.height + || detected.width > target.width || detected.height > target.height || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } } catch (error: unknown) { @@ -193,24 +170,24 @@ async function writeCached(path: string, data: Uint8Array): Promise { * Generate or reuse one request image below the local attachment root. * @param root - absolute versioned attachment storage root. * @param attachment - verified normalized attachment bytes and reference. - * @param policy - exact route request-image policy. + * @param target - exact route-chosen dimensions and byte target; a target above the source keeps the source size. * @param signal - optional cancellation for cache I/O and image transformation. * @returns verified request bytes and deterministic variant identity. */ export async function readRequestImageFile( root: string, attachment: StoredImageAttachment, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() - validatePolicy(policy) + validateTarget(target) const source = await probeImage(attachment.data) - const variantId = requestImageVariantId(attachment.ref, policy) + const variantId = requestImageVariantId(attachment.ref, target) const hash = String(variantId).slice('sha256:'.length) const path = cachePath(root, hash) - const cached = await readCached(path, attachment, policy, source.hasAlpha, signal) - const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha) + const cached = await readCached(path, target, source.hasAlpha, signal) + const created = cached ?? await createRequestImage(attachment, target, source.hasAlpha) const version = cached ?? (created.data === attachment.data ? { ...created, hasAlpha: source.hasAlpha } : await verifyRequestImage(created, source.hasAlpha)) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index a46d6f0b10..00a427e16e 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -85,7 +85,7 @@ describe('local attachment service', () => { String(ref.attachmentId).slice('sha256:'.length), )) await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) - const request = await service.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1024 }) + const request = await service.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1024 }) expect(request).not.toHaveProperty('access') const fileData = Uint8Array.of(0, 1, 2, 255) diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts index eabc735229..693b36f737 100644 --- a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -38,7 +38,7 @@ describe('request image verification', () => { const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) control.mismatch = true - await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 })) + await expect(attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 73c0c366b1..4aa63865a3 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -48,7 +48,7 @@ describe('local request-image cache', () => { const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' }) const firstStored = await attachments.readImage(first) - const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 1_000 }, maxBytes: 1024 * 1024 } + const policy = { width: 8, height: 8, maxBytes: 1024 * 1024 } const request = await attachments.readImageRequest(first, policy) const batch = await Promise.all([first, second].map( @@ -59,56 +59,51 @@ describe('local request-image cache', () => { expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) }) - it('rejects invalid request policies', async () => { + it('rejects invalid request targets', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 0 }, maxBytes: 100 })) - .rejects.toThrow('Image request maxPixels must be a positive integer') - await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxBytes: 0 })) + await expect(attachments.readImageRequest(attachment, { width: 0, height: 4, maxBytes: 100 })) + .rejects.toThrow('Image request width must be a positive integer') + await expect(attachments.readImageRequest(attachment, { width: 8, height: 1.5, maxBytes: 100 })) + .rejects.toThrow('Image request height must be a positive integer') + await expect(attachments.readImageRequest(attachment, { width: 8, height: 4, maxBytes: 0 })) .rejects.toThrow('Image request maxBytes must be a positive integer') - await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxDimension: 0, maxBytes: 100 })) - .rejects.toThrow('Image request maxDimension must be a positive integer') - for (const field of ['patchSize', 'downsampleRatio', 'maxTokens'] as const) { - const projection = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024, [field]: 1.5 } - await expect(attachments.readImageRequest(attachment, { projection, maxBytes: 100 })) - .rejects.toThrow(`Image request ${field} must be a positive integer`) - } }) - it('projects onto a token grid, caps the long edge, and keys the cache by projection', async () => { + it('resizes by the long edge to the exact target and keys the cache by target', async () => { const attachments = await store() - const grid = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024 } + const maxBytes = 2 * 1024 * 1024 const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' }) const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' }) const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' }) const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' }) const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' }) - const squareRequest = await attachments.readImageRequest(square, { projection: grid, maxBytes: 2 * 1024 * 1024 }) - const smallRequest = await attachments.readImageRequest(small, { projection: grid, maxBytes: 2 * 1024 * 1024 }) - const thinRequest = await attachments.readImageRequest(thin, { projection: grid, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }) - const budgeted = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 1302 * 1302 }, maxBytes: 2 * 1024 * 1024 }) - const uncapped = await attachments.readImageRequest(thin, { projection: grid, maxBytes: 2 * 1024 * 1024 }) - const wideRequest = await attachments.readImageRequest(wide, { projection: grid, maxBytes: 2 * 1024 * 1024 }) - const tallRequest = await attachments.readImageRequest(tall, { projection: grid, maxBytes: 2 * 1024 * 1024 }) + const squareRequest = await attachments.readImageRequest(square, { width: 1302, height: 1302, maxBytes }) + const smallRequest = await attachments.readImageRequest(small, { width: 800, height: 800, maxBytes }) + const thinRequest = await attachments.readImageRequest(thin, { width: 4096, height: 20, maxBytes }) + const wideRequest = await attachments.readImageRequest(wide, { width: 1708, height: 961, maxBytes }) + const tallRequest = await attachments.readImageRequest(tall, { width: 961, height: 1708, maxBytes }) + const smaller = await attachments.readImageRequest(square, { width: 1024, height: 1024, maxBytes }) + const enlarged = await attachments.readImageRequest(thin, { width: 9000, height: 45, maxBytes }) expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' }) expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' }) expect(smallRequest.data).toEqual((await attachments.readImage(small)).data) expect(thinRequest).toMatchObject({ width: 4096, height: 20 }) - expect(uncapped).toMatchObject({ width: 8000, height: 40 }) expect(wideRequest).toMatchObject({ width: 1708, height: 961 }) expect(tallRequest).toMatchObject({ width: 961, height: 1708 }) - expect(budgeted.variantId).not.toBe(squareRequest.variantId) - expect(uncapped.variantId).not.toBe(thinRequest.variantId) + expect(enlarged).toMatchObject({ width: 8000, height: 40 }) + expect(smaller.variantId).not.toBe(squareRequest.variantId) + expect(enlarged.variantId).not.toBe(thinRequest.variantId) }) it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }) + const request = await attachments.readImageRequest(attachment, { width: 1, height: 1, maxBytes: 1 }) expect(request.mediaType).toBe('image/jpeg') expect(request.bytes).toBeGreaterThan(1) @@ -118,7 +113,7 @@ describe('local request-image cache', () => { it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) - const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 4_096 } + const policy = { width: 22, height: 11, maxBytes: 4_096 } const initial = await attachments.readImageRequest(attachment, policy) const hash = String(initial.variantId).slice('sha256:'.length) const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) @@ -167,10 +162,10 @@ describe('local request-image cache', () => { data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', }) - const squareRequest = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) - const wideRequest = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) - const repeated = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) - const low = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxBytes: 1024 * 1024 }) + const squareRequest = await attachments.readImageRequest(square, { width: 800, height: 800, maxBytes: 1024 * 1024 }) + const wideRequest = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 }) + const repeated = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 }) + const low = await attachments.readImageRequest(wide, { width: 724, height: 362, maxBytes: 1024 * 1024 }) expect(squareRequest).toMatchObject({ width: 800, height: 800 }) expect(wideRequest).toMatchObject({ width: 1130, height: 565 }) @@ -210,8 +205,8 @@ describe('local request-image cache', () => { const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' }) const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' }) - const photoRequest = await attachments.readImageRequest(photo, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 1024 * 1024 }) - const alphaRequest = await attachments.readImageRequest(alpha, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 4_096 }) + const photoRequest = await attachments.readImageRequest(photo, { width: 128, height: 128, maxBytes: 1024 * 1024 }) + const alphaRequest = await attachments.readImageRequest(alpha, { width: 128, height: 128, maxBytes: 4_096 }) expect(photoRequest.mediaType).toBe('image/jpeg') expect(alphaRequest.mediaType).toBe('image/webp') @@ -227,7 +222,7 @@ describe('local request-image cache', () => { }).toColourspace('rgb16').png().toBuffer()) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) @@ -241,7 +236,7 @@ describe('local request-image cache', () => { const source = await complexOpaqueAlphaImage(64, 32) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }) expect(request.mediaType).toBe('image/webp') await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false }) @@ -263,7 +258,7 @@ describe('local request-image cache', () => { }).png().toBuffer()) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { width: 800, height: 800, maxBytes: 1024 * 1024 }) expect(request).toMatchObject({ width: 800, height: 800 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) @@ -276,7 +271,7 @@ describe('local request-image cache', () => { }) const run = vi.spyOn(CompressionLimiter.prototype, 'run') const controller = new AbortController() - const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 } + const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) const completed = attachments.readImageRequest(attachment, policy) @@ -306,7 +301,7 @@ describe('local request-image cache', () => { const controller = new AbortController() const request = attachments.readImageRequest( attachment, - { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }, + { width: 1130, height: 565, maxBytes: 1024 * 1024 }, controller.signal, ) await vi.waitFor(() => { @@ -339,7 +334,7 @@ describe('local request-image cache', () => { return actualRead(ref, signal) }) const controller = new AbortController() - const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 } + const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) await vi.waitFor(() => { expect(calls).toBe(1) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index bab63d6a8a..87d9bd1b78 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: e17d6bea53127d54babad919bdd546388b742d5f -README.zh.md: 5bf061304684cbdd8be00709ba9d1224b6c68ffa +README.md: d2a667e2d12deadc5e82a83cca6565f0b5a6aae0 +README.zh.md: b5598eb8ccca39d8b5be346c585fd84d3dd38468 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index e17d6bea53..d2a667e2d1 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -66,13 +66,13 @@ This section explains the design decisions behind the seam and the service opera - **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization. - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. -- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions under an explicit projection rule, per-side cap, and byte target, while execution filesystems may map the immutable host object to a model-readable process path. +- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path. - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, projection rule, per-side cap, byte target, and encoder settings. The pure `requestImageDimensions` and `tokenGridProjection` exports compute each projection's aspect-preserving dimensions from a total-pixel budget or a provider token grid, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 5bf0613046..b5598eb8cc 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -66,13 +66,13 @@ kind: "package-reference" - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。 -- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按显式的投影规则、单边上限与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 +- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、投影规则、单边上限、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `tokenGridProjection` 按总像素预算或提供方 token 网格计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 312bd2a3e7..d5854336db 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -10,7 +10,7 @@ import type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -22,7 +22,7 @@ export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedFile, admitEncodedImages } from './admission.ts' -export { requestImageDimensions, tokenGridProjection } from './request-projection.ts' +export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts' export type { ProjectedDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, @@ -33,8 +33,7 @@ export type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, - ImageRequestProjection, + ImageRequestTarget, ImageMediaType, PromptContentPart, RequestImageAttachment, @@ -42,7 +41,6 @@ export type { SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, - TokenGridProjection, } from './types.ts' declare module '@deepseek-ai/cordis' { @@ -244,18 +242,18 @@ export abstract class AttachmentStore extends Service { /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output. + * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ readImageRequest( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() void ref - void policy + void target return Promise.reject(new AttachmentError( 'The mounted attachment provider cannot derive model-request images.', 'ATTACHMENT_PROJECTION_UNSUPPORTED', diff --git a/packages/attachment/attachment/src/request-projection.ts b/packages/attachment/attachment/src/request-projection.ts index f1a9d855ed..4e809a3632 100644 --- a/packages/attachment/attachment/src/request-projection.ts +++ b/packages/attachment/attachment/src/request-projection.ts @@ -1,21 +1,26 @@ /** - * Pure request-projection geometry shared by attachment providers and - * provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection + * Pure request-projection geometry shared by model routes and provider-side + * request pricing. @module @deepseek-ai/dsh-attachment/request-projection */ -import type { ImageRequestPolicy, ImageRequestProjection, TokenGridProjection } from './types.ts' - /** Integer width and height of one projected image. */ export interface ProjectedDimensions { width: number height: number } -const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor) -const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor) - -/** Aspect-preserving integer dimensions within a hard total-pixel budget; small images are not enlarged. */ -function pixelBudgetDimensions(width: number, height: number, maxPixels: number): ProjectedDimensions { +/** + * Compute aspect-preserving integer dimensions within a hard total-pixel budget. + * @param width - positive source width. + * @param height - positive source height. + * @param maxPixels - positive width-times-height cap. + * @returns inward-rounded dimensions; small images are not enlarged. + */ +export function requestImageDimensions( + width: number, + height: number, + maxPixels: number, +): ProjectedDimensions { const scale = Math.min(1, Math.sqrt(maxPixels / (width * height))) if (scale === 1) return { width, height } if (width >= height) { @@ -36,103 +41,22 @@ function pixelBudgetDimensions(width: number, height: number, maxPixels: number) return { width: projectedWidth, height: projectedHeight } } -/** Token count of one grid: every row carries a separator, plus two framing tokens. */ -function gridTokens(rows: number, columns: number): number { - return rows * (columns + 1) + 2 -} - /** - * Project one image onto the token grid of a `token-grid` projection. The - * provider pads each edge up to a whole patch, groups patches into token - * cells, and keeps the padded source when its cell grid fits `maxTokens`; - * otherwise it solves the largest aspect-preserving grid inside the cap, whose - * edges are whole patches. The closed-form solve always lands inside the cap. - * @param width - positive integer source width in pixels. - * @param height - positive integer source height in pixels. - * @param grid - patch size, per-axis downsampling ratio, and token cap. - * @returns the dimensions the provider retains and the tokens it charges. + * Compute aspect-preserving integer dimensions with an exact long edge; the + * short edge rounds to the nearest pixel, as an encoder resize by the + * long edge alone. + * @param width - positive source width. + * @param height - positive source height. + * @param longEdge - positive target for the longer source edge. + * @returns the target dimensions; a long edge at or above the source returns the source unchanged. */ -export function tokenGridProjection( +export function longEdgeDimensions( width: number, height: number, - grid: Extract, -): TokenGridProjection { - const { patchSize, downsampleRatio, maxTokens } = grid - const cells = (paddedLength: number): number => ceilDiv(intDiv(paddedLength, patchSize), downsampleRatio) - const paddedWidth = ceilDiv(width, patchSize) * patchSize - const paddedHeight = ceilDiv(height, patchSize) * patchSize - const directTokens = gridTokens(cells(paddedHeight), cells(paddedWidth)) - if (directTokens <= maxTokens) { - return { width: paddedWidth, height: paddedHeight, tokens: directTokens, unscaled: true } - } - const cellSize = patchSize * downsampleRatio - const aspect = height / width - const idealColumns = Math.sqrt((maxTokens - 2) / aspect + 0.25) - 0.5 - const idealRows = idealColumns * aspect - let bestWidth: number - let bestHeight: number - if (idealColumns < 1) { - bestWidth = cellSize - bestHeight = intDiv(maxTokens - 2, 2) * cellSize - } else if (idealRows < 1) { - bestWidth = (maxTokens - 3) * cellSize - bestHeight = cellSize - } else { - const columns = Math.trunc(idealColumns) - const rows = Math.trunc(idealRows) - const scale = Math.min(columns * cellSize / width, rows * cellSize / height) - bestWidth = Math.trunc(width * scale / patchSize) * patchSize - bestHeight = Math.trunc(height * scale / patchSize) * patchSize - } - return { - width: bestWidth, - height: bestHeight, - tokens: gridTokens(cells(bestHeight), cells(bestWidth)), - unscaled: false, - } -} - -/** Aspect-preserving dimensions with an exact long edge; the short edge rounds to the nearest pixel. */ -function fromLongEdge(width: number, height: number, longEdge: number): ProjectedDimensions { + longEdge: number, +): ProjectedDimensions { + if (longEdge >= Math.max(width, height)) return { width, height } return width >= height ? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) } : { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge } } - -function projectDimensions(width: number, height: number, projection: ImageRequestProjection): ProjectedDimensions { - switch (projection.kind) { - case 'pixel-budget': - return pixelBudgetDimensions(width, height, projection.maxPixels) - case 'token-grid': { - const fit = tokenGridProjection(width, height, projection) - if (fit.unscaled) return { width, height } - return fromLongEdge(width, height, width >= height ? fit.width : fit.height) - } - /* v8 ignore next 4 -- ImageRequestProjection is a closed union; this branch is only the static exhaustiveness guard. */ - default: { - const unreachable: never = projection - throw new Error(`unknown image request projection ${JSON.stringify(unreachable)}`) - } - } -} - -/** - * Compute the aspect-preserving integer dimensions of one request image: - * the policy projection first, then the optional per-side cap. Small images - * are never enlarged. A downscaled result keeps the source long edge exact - * and rounds the short edge, matching a long-edge-only encoder resize. - * @param width - positive source width. - * @param height - positive source height. - * @param policy - route projection rule and optional per-side cap. - * @returns integer dimensions. - */ -export function requestImageDimensions( - width: number, - height: number, - policy: Pick, -): ProjectedDimensions { - const projected = projectDimensions(width, height, policy.projection) - const longEdge = Math.max(projected.width, projected.height) - if (policy.maxDimension === undefined || longEdge <= policy.maxDimension) return projected - return fromLongEdge(width, height, policy.maxDimension) -} diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 0c8482a1eb..455d6c9de0 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -132,46 +132,12 @@ export interface StoredImageAttachment { data: Uint8Array } -/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */ -export type ImageRequestProjection = - | { - /** Hard cap on width multiplied by height. */ - kind: 'pixel-budget' - /** Maximum width multiplied by height after projection. */ - maxPixels: number - } - | { - /** - * Largest aspect-preserving patch grid whose token count - * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout. - */ - kind: 'token-grid' - /** Patch edge in pixels; a downscaled edge is a whole number of patches. */ - patchSize: number - /** Patches per token cell along each axis. */ - downsampleRatio: number - /** Token cap for one image. */ - maxTokens: number - } - -/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */ -export interface TokenGridProjection { - /** Retained width: the patch-padded source when it fits, otherwise the solved width. */ +/** Deterministic request-image target selected by one exact model route for one attachment. */ +export interface ImageRequestTarget { + /** Target width in pixels; a target above the source keeps the source width. */ width: number - /** Retained height: the patch-padded source when it fits, otherwise the solved height. */ + /** Target height in pixels; a target above the source keeps the source height. */ height: number - /** Tokens charged for the retained grid. */ - tokens: number - /** Whether the patch-padded source already fits `maxTokens` without downscaling. */ - unscaled: boolean -} - -/** Deterministic request-image policy selected by one exact model route. */ -export interface ImageRequestPolicy { - /** Downscale rule applied before the per-side cap. */ - projection: ImageRequestProjection - /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */ - maxDimension?: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 63c0471f40..9ce59d2d48 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -8,7 +8,7 @@ import AttachmentStore, { isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, - type ImageRequestPolicy, + type ImageRequestTarget, type RequestImageAttachment, type SaveFileAttachment, type SaveImageAttachment, @@ -56,7 +56,7 @@ class RecordingStore extends AttachmentStore { override readImageRequest( ref: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, ): Promise { this.calls.push(`request:${ref.name}`) return Promise.resolve({ @@ -154,12 +154,12 @@ describe('AttachmentStore.readImageRequest', () => { it('reports unsupported request projection while preserving cancellation', async () => { const store = new UnsupportedProjectionStore(new Context()) const ref = await new RecordingStore(new Context()).saveImage(image(1)) - await expect(store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 })) + await expect(store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) const controller = new AbortController() const reason = new Error('cancel unsupported projection') controller.abort(reason) - expect(() => store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }, controller.signal)).toThrow(reason) + expect(() => store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) it('rejects generic-file storage and exposes no provider-owned host path by default', async () => { diff --git a/packages/attachment/attachment/tests/request-projection.spec.ts b/packages/attachment/attachment/tests/request-projection.spec.ts index 0a7d604b5b..2db1578863 100644 --- a/packages/attachment/attachment/tests/request-projection.spec.ts +++ b/packages/attachment/attachment/tests/request-projection.spec.ts @@ -1,18 +1,14 @@ import { describe, expect, it } from 'vitest' -import { requestImageDimensions, tokenGridProjection } from '../src/index.ts' -import type { ImageRequestProjection } from '../src/index.ts' +import { longEdgeDimensions, requestImageDimensions } from '../src/index.ts' -const PIXEL_BUDGET: ImageRequestProjection = { kind: 'pixel-budget', maxPixels: 640_000 } -const TOKEN_GRID = { kind: 'token-grid', patchSize: 14, downsampleRatio: 3, maxTokens: 1024 } as const - -describe('pixel-budget projection', () => { +describe('request image dimensions', () => { it.each([ [4096, 4096, 800, 800], [4096, 2048, 1130, 565], [3840, 2160, 1066, 600], [320, 240, 320, 240], ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => { - const projected = requestImageDimensions(width, height, { projection: PIXEL_BUDGET }) + const projected = requestImageDimensions(width, height, 640_000) expect(projected).toEqual({ width: expectedWidth, height: expectedHeight, @@ -21,65 +17,27 @@ describe('pixel-budget projection', () => { }) it('projects a portrait within the same total-pixel budget', () => { - const projected = requestImageDimensions(2160, 3840, { projection: PIXEL_BUDGET }) + const projected = requestImageDimensions(2160, 3840, 640_000) expect(projected).toEqual({ width: 600, height: 1066 }) expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) }) it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => { - expect(requestImageDimensions(2, 4, { projection: { kind: 'pixel-budget', maxPixels: 5 } })).toEqual({ width: 1, height: 2 }) + expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 }) }) }) -describe('token-grid projection', () => { - it('keeps the patch-padded source when its grid fits the token cap', () => { - expect(tokenGridProjection(800, 800, TOKEN_GRID)).toEqual({ width: 812, height: 812, tokens: 422, unscaled: true }) - expect(tokenGridProjection(1302, 1302, TOKEN_GRID)).toEqual({ width: 1302, height: 1302, tokens: 994, unscaled: true }) +describe('long-edge dimensions', () => { + it('keeps the long edge exact and rounds the short edge', () => { + expect(longEdgeDimensions(8000, 40, 4096)).toEqual({ width: 4096, height: 20 }) + expect(longEdgeDimensions(10_000, 100, 4096)).toEqual({ width: 4096, height: 41 }) + expect(longEdgeDimensions(1080, 2400, 1862)).toEqual({ width: 838, height: 1862 }) + expect(longEdgeDimensions(1, 9000, 4096)).toEqual({ width: 1, height: 4096 }) }) - it.each([ - [1303, 1303, 1302, 1302, 994], - [4096, 4096, 1302, 1302, 994], - [3840, 2160, 1708, 966, 968], - [2160, 3840, 966, 1708, 986], - [4000, 1000, 2520, 630, 917], - ])('solves %sx%s onto the largest in-cap grid %sx%s', (width, height, expectedWidth, expectedHeight, tokens) => { - expect(tokenGridProjection(width, height, TOKEN_GRID)).toEqual({ - width: expectedWidth, - height: expectedHeight, - tokens, - unscaled: false, - }) - expect(expectedWidth % TOKEN_GRID.patchSize).toBe(0) - expect(expectedHeight % TOKEN_GRID.patchSize).toBe(0) - }) - - it('solves one-row and one-column grids for extreme aspect ratios', () => { - expect(tokenGridProjection(100_000, 1, TOKEN_GRID)).toEqual({ width: 42_882, height: 42, tokens: 1024, unscaled: false }) - expect(tokenGridProjection(1, 100_000, TOKEN_GRID)).toEqual({ width: 42, height: 21_462, tokens: 1024, unscaled: false }) - }) - - it('sends a fitting source at its own dimensions and a larger source at the solved grid', () => { - expect(requestImageDimensions(800, 800, { projection: TOKEN_GRID })).toEqual({ width: 800, height: 800 }) - expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID })).toEqual({ width: 8192, height: 78 }) - expect(requestImageDimensions(2048, 1024, { projection: TOKEN_GRID })).toEqual({ width: 1848, height: 924 }) - }) - - it('keeps the solved long edge and rounds the short edge to the source aspect ratio', () => { - expect(requestImageDimensions(3840, 2160, { projection: TOKEN_GRID })).toEqual({ width: 1708, height: 961 }) - expect(requestImageDimensions(1080, 2400, { projection: TOKEN_GRID })).toEqual({ width: 838, height: 1862 }) - }) -}) - -describe('per-side cap', () => { - it('scales the projected image down to the cap on its long edge', () => { - expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 4096, height: 39 }) - expect(requestImageDimensions(1, 8192, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1, height: 4096 }) - expect(requestImageDimensions(10_000, 100, { projection: PIXEL_BUDGET, maxDimension: 4096 })).toEqual({ width: 4096, height: 41 }) - }) - - it('leaves an image within the cap untouched', () => { - expect(requestImageDimensions(2048, 2048, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1302, height: 1302 }) + it('never enlarges a source at or below the long edge', () => { + expect(longEdgeDimensions(320, 240, 320)).toEqual({ width: 320, height: 240 }) + expect(longEdgeDimensions(320, 240, 4096)).toEqual({ width: 320, height: 240 }) }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c7a984b12a..beb0637aaa 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -548,9 +548,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['an AttachmentError when the durable reference is invalid.'], }, { - signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', + signature: 'readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', - parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], + parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'target', description: 'route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, ], @@ -4359,12 +4359,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', }, { - name: 'ImageRequestPolicy', - declaration: 'export interface ImageRequestPolicy {\n projection: ImageRequestProjection;\n maxDimension?: number;\n maxBytes: number;\n}', - }, - { - name: 'ImageRequestProjection', - declaration: 'export type ImageRequestProjection = {\n kind: \'pixel-budget\';\n maxPixels: number;\n} | {\n kind: \'token-grid\';\n patchSize: number;\n downsampleRatio: number;\n maxTokens: number;\n};', + name: 'ImageRequestTarget', + declaration: 'export interface ImageRequestTarget {\n width: number;\n height: number;\n maxBytes: number;\n}', }, { name: 'ImageVariantId', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 734062fae9..95851bc4f2 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: c99431b0d415ef82d4833d7ca38b531b3cd38937 -README.zh.md: 1d515c5ee8a855981e529b7fff03cbf18fa9dde7 +README.md: b3ccd5bc7ed8c66ac4c0a5212d834fa8551d553d +README.zh.md: edfd5e89292f1e9ad478ccedb5601a7e95daea45 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index c99431b0d4..b3ccd5bc7e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -74,7 +74,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Streaming with thinking and images -An image-capable route resolves each durable reference into a deterministic request version under its projection, per-side cap, and byte target. Omitting `imagePixelBudget` projects onto the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and other aspect ratios keep the largest grid inside the cap; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. +An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. The adapter normally uploads those exact request bytes through the DeepSeek Files API and sends file-id blocks. A failed or timed-out file resolution rebuilds the whole chat request with the same request versions as base64 data URLs; one request never mixes file ids and inline images. Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1d515c5ee8..edfd5e8929 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -74,7 +74,7 @@ kind: "package-reference" ### 带 thinking 与图片的流式调用 -支持图片的路由会在自身投影、单边上限与字节目标内把每个持久引用解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格投影,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,其他宽高比保留上限内最大的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 +支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 适配器通常通过 DeepSeek Files API 上传这些确切请求字节,并发送 file-id 块。文件解析失败或超时会用相同请求版本的 base64 data URL 重建整份 chat 请求;一次请求绝不混用 file id 与内联图片。缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 432902ca6b..5622209cea 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -38,7 +38,7 @@ import type { } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { ImageWireLocation, RequestDefaults } from './serialize.ts' -import { deepSeekImageRequestPricing, resolveRequestImagePolicy } from './request-pricing.ts' +import { deepSeekImageRequestPricing, resolveRequestImageMaxBytes, resolveRequestImageTarget } from './request-pricing.ts' import { DeepSeekFileStore } from './file-store.ts' import type { DeepSeekFilePolicy } from './file-store.ts' import type { DeepSeekFileId } from './file-id.ts' @@ -229,10 +229,9 @@ async function prepareRequestImages( ): Promise> { const refs = new Map() for (const message of options.messages) collectImageRefs(message.content, refs) - const policy = resolveRequestImagePolicy(model) const orderedRefs = [...refs.values()] const projected = await Promise.all(orderedRefs.map( - ref => attachments.readImageRequest(ref, policy, signal), + ref => attachments.readImageRequest(ref, resolveRequestImageTarget(model, ref), signal), )) return new Map(orderedRefs.map((ref, index) => ( [ref.attachmentId, projected[index] as RequestImageAttachment] @@ -556,18 +555,18 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) - const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) + const maxBytes = model === undefined ? undefined : resolveRequestImageMaxBytes(model) const resolveImageAccess = attachments === undefined ? undefined : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } - const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { + const requestMessages = maxBytes === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { representation: 'raw', maxBytes: connection.maxRequestFilesBytes, maxImages: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, - byteLength: ref => Math.min(ref.bytes, policy.maxBytes), + byteLength: ref => Math.min(ref.bytes, maxBytes), placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } diff --git a/packages/llm/llm-deepseek/src/image-tokens.ts b/packages/llm/llm-deepseek/src/image-tokens.ts index 318ee1f399..c5618af0f9 100644 --- a/packages/llm/llm-deepseek/src/image-tokens.ts +++ b/packages/llm/llm-deepseek/src/image-tokens.ts @@ -1,39 +1,139 @@ /** * DeepSeek vision-token accounting: the provider's published image-token - * calculator (api-docs.deepseek.com, Token & Token Usage) in its current - * `v41` configuration. The provider scales an image below 544×544 total - * pixels up, then projects it onto the token grid shared with request - * projection, iterating to a fixpoint. The count is exact: this configuration - * has no alignment pad and no aspect-ratio clamp. Actual usage remains - * authoritative. + * calculator (api-docs.deepseek.com, Token & Token Usage) ported verbatim in + * its current `v41` configuration. The provider scales an image below + * 544×544 total pixels up, aligns it to a 14px-patch grid, downsamples 3:1 + * per axis into token cells, and caps one image at 1024 tokens by solving the + * largest aspect-preserving grid inside that budget. The count is exact: this + * configuration has no alignment pad and no aspect-ratio clamp. Actual usage + * remains authoritative. * * @module dsh-llm-deepseek/image-tokens */ -import { tokenGridProjection } from '@deepseek-ai/dsh-attachment' -import type { ImageRequestProjection, TokenGridProjection } from '@deepseek-ai/dsh-attachment' - -/** Published DeepSeek vision grid: 14px patches, 3:1 per-axis downsampling, at most 1024 tokens per image. */ -export const DEEPSEEK_IMAGE_TOKEN_GRID = { - kind: 'token-grid', - patchSize: 14, - downsampleRatio: 3, - maxTokens: 1024, -} as const satisfies ImageRequestProjection - +import { longEdgeDimensions } from '@deepseek-ai/dsh-attachment' +import type { ProjectedDimensions } from '@deepseek-ai/dsh-attachment' +/** Vision patch edge in pixels. */ +const PATCH_SIZE = 14 +/** Per-axis patch-to-token downsampling ratio. */ +const DOWNSAMPLE_RATIO = 3 +/** Provider cap on tokens for one request image. */ +const MAX_IMAGE_TOKENS = 1024 /** Total-pixel floor; smaller images are scaled up before grid projection. */ const MIN_PIXELS = 544 * 544 +/** Pixels covered by one token cell along either axis. */ +const CELL_SIZE = PATCH_SIZE * DOWNSAMPLE_RATIO -/** One scale-up-then-project pass; the caller iterates it to a fixpoint. */ -function resizeOnce(width: number, height: number): TokenGridProjection { - const pixels = width * height - if (pixels >= MIN_PIXELS) return tokenGridProjection(width, height, DEEPSEEK_IMAGE_TOKEN_GRID) - const scale = Math.sqrt(MIN_PIXELS / pixels) - return tokenGridProjection(Math.trunc(width * scale), Math.trunc(height * scale), DEEPSEEK_IMAGE_TOKEN_GRID) +const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor) +const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor) + +interface GridResize { + readonly gridHeight: number + readonly gridWidth: number + readonly bestHeight: number + readonly bestWidth: number + readonly numTokens: number } -function sameResize(a: TokenGridProjection, b: TokenGridProjection): boolean { - return a.width === b.width && a.height === b.height && a.tokens === b.tokens +/** Token count of one grid: every row carries a separator, plus two framing tokens. */ +function gridTokens(gridHeight: number, gridWidth: number): number { + return gridHeight * (gridWidth + 1) + 2 +} + +/** Token-cell count along one padded pixel axis. */ +function gridCells(paddedLength: number): number { + return ceilDiv(intDiv(paddedLength, PATCH_SIZE), DOWNSAMPLE_RATIO) +} + +/** Solve the largest grid within `budget` tokens preserving the aspect ratio. */ +function solveResizeRatio(height: number, width: number, budget: number): GridResize { + const aspect = height / width + const idealGridWidth = Math.sqrt((budget - 2) / aspect + 0.25) - 0.5 + const idealGridHeight = idealGridWidth * aspect + let bestHeight: number + let bestWidth: number + if (idealGridWidth < 1) { + const solvedGridWidth = 1 + const solvedGridHeight = intDiv(budget - 2, solvedGridWidth + 1) + bestWidth = solvedGridWidth * CELL_SIZE + bestHeight = solvedGridHeight * CELL_SIZE + } else if (idealGridHeight < 1) { + const solvedGridHeight = 1 + const solvedGridWidth = intDiv(budget - 2, solvedGridHeight) - 1 + bestWidth = solvedGridWidth * CELL_SIZE + bestHeight = solvedGridHeight * CELL_SIZE + } else { + const solvedGridWidth = Math.trunc(idealGridWidth) + const solvedGridHeight = Math.trunc(idealGridHeight) + const scale = Math.min(solvedGridWidth * CELL_SIZE / width, solvedGridHeight * CELL_SIZE / height) + bestWidth = Math.trunc(width * scale / PATCH_SIZE) * PATCH_SIZE + bestHeight = Math.trunc(height * scale / PATCH_SIZE) * PATCH_SIZE + } + const gridHeight = gridCells(bestHeight) + const gridWidth = gridCells(bestWidth) + return { gridHeight, gridWidth, bestHeight, bestWidth, numTokens: gridTokens(gridHeight, gridWidth) } +} + +/** Project padded pixel dimensions onto the largest in-budget token grid. */ +function safeResize(height: number, width: number, paddedHeight: number, paddedWidth: number): GridResize { + const gridHeight = gridCells(paddedHeight) + const gridWidth = gridCells(paddedWidth) + const direct: GridResize = { + gridHeight, + gridWidth, + bestHeight: paddedHeight, + bestWidth: paddedWidth, + numTokens: gridTokens(gridHeight, gridWidth), + } + if (direct.numTokens <= MAX_IMAGE_TOKENS) return direct + const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS) + /* v8 ignore next 3 -- the published solver's assertion; the closed-form + solve stays within the budget for every positive geometry. */ + if (solved.numTokens > MAX_IMAGE_TOKENS) { + throw new Error(`deepseek image tokens: no grid fits the token budget for ${width}x${height}`) + } + return solved +} + +/** One scale-pad-project pass; the caller iterates it to a fixpoint. */ +function resizeOnce(width: number, height: number): GridResize { + let scaledWidth = width + let scaledHeight = height + const pixels = scaledWidth * scaledHeight + if (pixels < MIN_PIXELS && pixels > 0) { + const scale = Math.sqrt(MIN_PIXELS / pixels) + scaledWidth = Math.trunc(scaledWidth * scale) + scaledHeight = Math.trunc(scaledHeight * scale) + } + const paddedWidth = ceilDiv(scaledWidth, PATCH_SIZE) * PATCH_SIZE + const paddedHeight = ceilDiv(scaledHeight, PATCH_SIZE) * PATCH_SIZE + return safeResize(scaledHeight, scaledWidth, paddedHeight, paddedWidth) +} + +function sameResize(a: GridResize, b: GridResize): boolean { + return a.gridHeight === b.gridHeight + && a.gridWidth === b.gridWidth + && a.bestHeight === b.bestHeight + && a.bestWidth === b.bestWidth + && a.numTokens === b.numTokens +} + +/** + * Dimensions the harness sends so the provider keeps the whole image: the + * source itself when its patch-padded grid fits the token cap, otherwise the + * source aspect ratio at the solved grid's long edge. The provider pads the + * short edge to whole patches on its side, so the token count equals the + * solved grid's. Small images are never enlarged. + * @param width - positive integer source width in pixels. + * @param height - positive integer source height in pixels. + * @returns the request dimensions to encode. + */ +export function deepSeekRequestImageDimensions(width: number, height: number): ProjectedDimensions { + const paddedWidth = ceilDiv(width, PATCH_SIZE) * PATCH_SIZE + const paddedHeight = ceilDiv(height, PATCH_SIZE) * PATCH_SIZE + if (gridTokens(gridCells(paddedHeight), gridCells(paddedWidth)) <= MAX_IMAGE_TOKENS) return { width, height } + const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS) + return longEdgeDimensions(width, height, width >= height ? solved.bestWidth : solved.bestHeight) } /** @@ -46,8 +146,8 @@ function sameResize(a: TokenGridProjection, b: TokenGridProjection): boolean { export function deepSeekImageTokens(width: number, height: number): number { let result = resizeOnce(width, height) for (let iteration = 1; iteration < 10; iteration += 1) { - const next = resizeOnce(result.width, result.height) - if (sameResize(next, result)) return result.tokens + const next = resizeOnce(result.bestWidth, result.bestHeight) + if (sameResize(next, result)) return result.numTokens result = next } /* v8 ignore next 2 -- the published solver's non-convergence guard; every diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 1e41a4f5f7..e3f470d6fa 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -65,9 +65,10 @@ export { DEFAULT_REQUEST_IMAGE_MAX_BYTES, REQUEST_IMAGE_MAX_DIMENSION, deepSeekImageRequestPricing, - resolveRequestImagePolicy, + resolveRequestImageMaxBytes, + resolveRequestImageTarget, } from './request-pricing.ts' -export { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts' +export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts' export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts' export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts' export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts' diff --git a/packages/llm/llm-deepseek/src/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts index 732038da28..7d6091fcc2 100644 --- a/packages/llm/llm-deepseek/src/request-pricing.ts +++ b/packages/llm/llm-deepseek/src/request-pricing.ts @@ -1,6 +1,6 @@ /** * Provider-side request-image pricing for DeepSeek routes: reproduces the - * adapter's deterministic request projection (per-model projection, + * adapter's deterministic request projection (per-model request target, * oldest-first offload under the raw-byte and count budgets) and prices every * retained image with the published vision-token accounting. Consumed * synchronously by the token meter through `LlmAdapter.imageRequestPricing`; @@ -11,9 +11,9 @@ import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' -import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageRequestPolicy, ImageRequestProjection } from '@deepseek-ai/dsh-attachment' -import { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts' +import { longEdgeDimensions, requestImageDimensions } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageRequestTarget } from '@deepseek-ai/dsh-attachment' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' /** Default bound on accumulated file-referenced image bytes per request. */ @@ -31,25 +31,37 @@ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024 export const REQUEST_IMAGE_MAX_DIMENSION = 4096 /** - * Resolve the request-image policy owned by one DeepSeek model route: the - * published token grid unless the model overrides it with a pixel budget, - * the provider per-side limit, and the encoded-byte target. + * Resolve the encoded-byte target one DeepSeek model route applies to every request image. * @param model - Advertised model route and its optional image overrides. - * @returns Complete projection, per-side cap, and encoded-byte target. + * @returns the route's encoded-byte target. * @internal */ -export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { - const projection: ImageRequestProjection = model.imagePixelBudget === undefined - ? DEEPSEEK_IMAGE_TOKEN_GRID - : { - kind: 'pixel-budget', - maxPixels: model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget, - } - return { - projection, - maxDimension: REQUEST_IMAGE_MAX_DIMENSION, - maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, - } +export function resolveRequestImageMaxBytes(model: DeepSeekCatalogModel): number { + return model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES +} + +/** + * Resolve the deterministic request target one DeepSeek model route chooses + * for one source image: the published token grid unless the model overrides + * it with a pixel budget, then the provider per-side limit, then the route's + * encoded-byte target. Small images are never enlarged. + * @param model - Advertised model route and its optional image overrides. + * @param source - intrinsic dimensions of the normalized attachment. + * @returns Complete request dimensions and encoded-byte target. + * @internal + */ +export function resolveRequestImageTarget( + model: DeepSeekCatalogModel, + source: Pick, +): ImageRequestTarget { + const budget = model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget + const projected = budget === undefined + ? deepSeekRequestImageDimensions(source.width, source.height) + : requestImageDimensions(source.width, source.height, budget) + const capped = Math.max(projected.width, projected.height) > REQUEST_IMAGE_MAX_DIMENSION + ? longEdgeDimensions(source.width, source.height, REQUEST_IMAGE_MAX_DIMENSION) + : projected + return { ...capped, maxBytes: resolveRequestImageMaxBytes(model) } } /** @@ -86,11 +98,11 @@ export function deepSeekImageRequestPricing( if (catalogModel?.inputModalities?.includes('image') !== true) { return { priceImages: images => images.map(textOnlyPrice) } } - const policy = resolveRequestImagePolicy(catalogModel) + const maxBytes = resolveRequestImageMaxBytes(catalogModel) return { priceImages: (images) => { const offloaded = offloadedImagePrefixCount( - images.map(ref => Math.min(ref.bytes, policy.maxBytes)), + images.map(ref => Math.min(ref.bytes, maxBytes)), { maxBytes: connection.maxRequestFilesBytes, maxImages: connection.maxImagesPerRequest, @@ -102,10 +114,10 @@ export function deepSeekImageRequestPricing( if (index < offloaded) { return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) } } - const dimensions = requestImageDimensions(ref.width, ref.height, policy) + const target = resolveRequestImageTarget(catalogModel, ref) return { - visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height), - text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)), + visualTokens: deepSeekImageTokens(target.width, target.height), + text: requestImageHandleText(ref, target, resolveAccess?.(ref)), } }) }, diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index fc14b03e55..12251be841 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -14,7 +14,7 @@ import LocalAttachments from '@deepseek-ai/dsh-attachment-local' import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -92,7 +92,7 @@ class E2eAttachmentStore extends AttachmentStore { override readImageRequest( _ref: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, _signal?: AbortSignal, ): Promise { return Promise.resolve(this.version) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 4122023b2c..3f8c261b3a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -20,9 +20,9 @@ import { SessionId } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DEEPSEEK_IMAGE_TOKEN_GRID, DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' -import { resolveRequestImagePolicy } from '../src/request-pricing.ts' +import { resolveRequestImageTarget } from '../src/request-pricing.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' import type { Behavior } from './mock-server.ts' @@ -141,22 +141,29 @@ function successfulSseResponse(): Response { }) } -describe('request image policy', () => { +describe('request image target', () => { it.each([ [ { id: 'default' }, - { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, + { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, ], [ { id: 'low', imagePixelBudget: 'low' as const }, - { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, + { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, ], [ { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, - { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 512_000 }, + { width: 565, height: 565, maxBytes: 512_000 }, ], - ])('resolves route-owned defaults and overrides for %s', (model, expected) => { - expect(resolveRequestImagePolicy(model)).toEqual(expected) + ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { + expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) + }) + + it('caps every request image at the provider per-side limit', () => { + expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) + .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) + expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) + .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) }) it('answers image request pricing from the current connection snapshot', () => { @@ -407,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => { bytes: 3, }]) expect(signalSeen[0]).toBeInstanceOf(AbortSignal) - expect(policies).toEqual([{ projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }]) + expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) }) it('falls back to one all-base64 request when Files API resolution fails', async () => { @@ -621,7 +628,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( recent, - { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, expect.any(AbortSignal), ) const body = server.requests[0] as { messages: unknown[] } @@ -672,13 +679,13 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 1, imageRef, - { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 512_000 }, + { width: 1, height: 1, maxBytes: 512_000 }, expect.any(AbortSignal), ) expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 2, imageRef, - { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, expect.any(AbortSignal), ) }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index ffac6880e5..6f26fd083e 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -8,7 +8,7 @@ import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh- import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -70,7 +70,7 @@ class StaticAttachmentStore extends AttachmentStore { override readImageRequest( ref: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, _signal?: AbortSignal, ): Promise { return Promise.resolve({ diff --git a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts index 0e6b5f5b9b..0f48e22760 100644 --- a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts +++ b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { deepSeekImageTokens } from '../src/image-tokens.ts' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/image-tokens.ts' describe('DeepSeek image tokens', () => { // Reference values from the provider's published image token calculator @@ -47,3 +47,26 @@ describe('DeepSeek image tokens', () => { expect(deepSeekImageTokens(89, 2076)).toBe(254) }) }) + +describe('DeepSeek request image dimensions', () => { + it.each([ + [800, 800, 800, 800], + [1302, 1302, 1302, 1302], + [8192, 78, 8192, 78], + [1, 9000, 1, 9000], + ])('sends %sx%s unchanged because its padded grid fits the cap', (width, height, expectedWidth, expectedHeight) => { + expect(deepSeekRequestImageDimensions(width, height)).toEqual({ width: expectedWidth, height: expectedHeight }) + }) + + it.each([ + [1303, 1303, 1302, 1302], + [2048, 2048, 1302, 1302], + [2048, 1024, 1848, 924], + [3840, 2160, 1708, 961], + [1080, 2400, 838, 1862], + ])('downscales %sx%s to %sx%s at the solved long edge', (width, height, expectedWidth, expectedHeight) => { + const sent = deepSeekRequestImageDimensions(width, height) + expect(sent).toEqual({ width: expectedWidth, height: expectedHeight }) + expect(deepSeekImageTokens(sent.width, sent.height)).toBe(deepSeekImageTokens(width, height)) + }) +}) diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index c1e0d9b586..8dba978c31 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -373,7 +373,7 @@ export class PiAiAdapter extends LlmAdapter { resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), maxRequestImageBytes: profile.maxRequestImageBytes, requestImagePolicy: { - projection: { kind: 'pixel-budget', maxPixels: profile.requestImagePixelBudget }, + maxPixels: profile.requestImagePixelBudget, maxBytes: profile.requestImageMaxBytes, }, }, onReplayDegrade) diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 2c072166e8..8126bfc04d 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -11,11 +11,12 @@ import type { AttachmentId, AttachmentStore, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' +import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts' /** Join the text blocks of a harness message. */ @@ -102,14 +103,14 @@ function collectImageRefs( async function prepareRequestImages( messages: readonly Message[], attachments: AttachmentStore, - policy: ImageRequestPolicy, + budget: PiImageRequestBudget, signal?: AbortSignal, ): Promise> { const refs = new Map() for (const message of messages) collectImageRefs(message.content, refs) const orderedRefs = [...refs.values()] const prepared = await Promise.all(orderedRefs.map( - ref => attachments.readImageRequest(ref, policy, signal), + ref => attachments.readImageRequest(ref, requestImageTarget(ref, budget), signal), )) const versions = new Map() for (const [index, ref] of orderedRefs.entries()) { @@ -222,7 +223,20 @@ export interface PiImageRequestContext { /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */ maxRequestImageBytes?: number /** Route pixel and raw encoded-byte budgets. */ - requestImagePolicy?: ImageRequestPolicy + requestImagePolicy?: PiImageRequestBudget +} + +/** Per-route budgets from which each request image's target is derived. */ +export interface PiImageRequestBudget { + /** Total-pixel budget; larger sources are downscaled proportionally. */ + maxPixels: number + /** Encoded-byte target for one request image. */ + maxBytes: number +} + +/** Deterministic request target for one source under the route budgets. */ +function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageRequestBudget): ImageRequestTarget { + return { ...requestImageDimensions(ref.width, ref.height, budget.maxPixels), maxBytes: budget.maxBytes } } /** @@ -272,7 +286,7 @@ async function toPiContextWithImages( ): Promise { const { attachments, resolveImageAccess, maxRequestImageBytes } = images const requestImagePolicy = images.requestImagePolicy ?? { - projection: { kind: 'pixel-budget', maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET }, + maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, } assertSupportedImageRoles(options.messages) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 4746ee4be1..a247b20e42 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -4,7 +4,7 @@ import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh- import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -254,7 +254,7 @@ describe('PiAiAdapter provider routing', () => { Promise.resolve({ ref, data: Uint8Array.of(1) })) const readImageRequest = vi.fn(( value: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, _signal?: AbortSignal, ): Promise => ( Promise.resolve({ @@ -299,7 +299,7 @@ describe('PiAiAdapter provider routing', () => { override readImageRequest( value: ImageAttachmentRef, - policy: ImageRequestPolicy, + policy: ImageRequestTarget, signal?: AbortSignal, ): Promise { return readImageRequest(value, policy, signal) @@ -325,7 +325,8 @@ describe('PiAiAdapter provider routing', () => { expect(result.finish.kind).toBe('error') expect(readImageRequest).toHaveBeenCalledWith(ref, { - projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 }, + width: 1, + height: 1, maxBytes: 1024 * 1024, }, expect.any(AbortSignal)) expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 576b84a235..59f952ad86 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -3,7 +3,7 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' import { ToolCallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' @@ -38,7 +38,7 @@ function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImage function projectionStore( readImageRequest: ( value: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ) => Promise = vi.fn((value: ImageAttachmentRef) => ( Promise.resolve(requestImage(value, Uint8Array.of(1))) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 650c8222ae..4347d74858 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageRequestTarget, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' @@ -62,7 +62,7 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { function attachmentStore(readImageRequest: ( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + policy: ImageRequestTarget, signal?: AbortSignal, ) => Promise): AttachmentStore { return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore @@ -105,7 +105,7 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => ( + const readImageRequest = vi.fn((value: ImageAttachmentRef, _target: ImageRequestTarget) => ( Promise.resolve(requestVersion(value)) )) const context = await toPiContext({ @@ -119,7 +119,7 @@ describe('toPiContext', () => { expect(readImageRequest).toHaveBeenCalledWith( attachment, - { projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 }, maxBytes: 1024 * 1024 }, + { width: 1, height: 1, maxBytes: 1024 * 1024 }, undefined, ) expect(context.messages[0]).toEqual({ @@ -141,7 +141,7 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => ( + const readImageRequest = vi.fn((value: ImageAttachmentRef, _target: ImageRequestTarget) => ( Promise.resolve(requestVersion(value)) )) const context = await toPiContext({ diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index c6fd5d6db3..fd917c7a66 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -5,7 +5,7 @@ import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh- import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -91,7 +91,7 @@ async function harness(image?: StoredImageAttachment): Promise { return Promise.resolve(fixture) } - override readImageRequest(ref: ImageAttachmentRef, _policy: ImageRequestPolicy): Promise { + override readImageRequest(ref: ImageAttachmentRef, _target: ImageRequestTarget): Promise { if (ref.attachmentId !== fixture.ref.attachmentId) { return Promise.reject(new Error('unknown e2e attachment fixture')) } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ed81fccc1f..9bc3374bd0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -378,7 +378,8 @@ export const LINK_MAP: Readonly> = { SaveFileStreamAttachment: 'attachment.md', ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', - ImageRequestPolicy: 'attachment.md', + ImageRequestTarget: 'attachment.md', + ProjectedDimensions: 'attachment.md', PromptContentPart: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index cd6f18083a..86aba302e1 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1003,18 +1003,13 @@ }, { "doc": "docs/subsystems/attachment.md", - "symbol": "ImageRequestProjection", + "symbol": "ImageRequestTarget", "source": "packages/attachment/attachment/src/types.ts" }, { "doc": "docs/subsystems/attachment.md", - "symbol": "TokenGridProjection", - "source": "packages/attachment/attachment/src/types.ts" - }, - { - "doc": "docs/subsystems/attachment.md", - "symbol": "ImageRequestPolicy", - "source": "packages/attachment/attachment/src/types.ts" + "symbol": "ProjectedDimensions", + "source": "packages/attachment/attachment/src/request-projection.ts" }, { "doc": "docs/subsystems/attachment.md", From 5a88e5c4b9a809eb18ecb6d5acde99b760599bcd Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 16:50:43 +0800 Subject: [PATCH 26/60] docs(ci): state the full admitted failure class for the dockkit exemption The package README and the Agent Note named an extension or error code but omitted the unrelated-message case the classifier also rejects, and the note loosened "Node's unknown-`.css`-extension refusal" to any non-`.css` failure. --- .../bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml | 4 ++-- .../bug-fix/2026-09-10-built-bundle-css-exemption.md | 2 +- .../bug-fix/2026-09-10-built-bundle-css-exemption.zh.md | 2 +- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 2 +- packages/experimental/webworker-runtime/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml index 4fdb59676b..4f985d74f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.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-09-10-built-bundle-css-exemption.md -2026-09-10-built-bundle-css-exemption.md: 81ff2770c675675cc43b34ecbad2b03fb1b2e34b -2026-09-10-built-bundle-css-exemption.zh.md: 77de7138017ca053eda0ed0d6b16f2e32c5a5857 +2026-09-10-built-bundle-css-exemption.md: e5a55689696f885b8ea9411a5b3a7598295fff35 +2026-09-10-built-bundle-css-exemption.zh.md: 1c1aab35df72497946b02bf2a7f67a2271c804e5 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md index 81ff2770c6..e5a5568969 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md @@ -22,6 +22,6 @@ The Dockkit exemption admits Node's unknown-`.css`-extension refusal for any sty ## Consequences -The sweep reports the Dockkit bundle when it stops importing for any reason other than a `.css` import, and the entry no longer asserts which stylesheet fails. [Scoped resolve/load hooks](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts) exercise an admitted Dockkit stylesheet, the dependency's source stylesheet, another extension, an arbitrary message, another error code, and a stale exemption without modifying shared build artifacts. +The sweep reports the Dockkit bundle when it stops importing for any reason other than Node's unknown-`.css`-extension refusal, and the entry no longer asserts which stylesheet fails. [Scoped resolve/load hooks](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts) exercise an admitted Dockkit stylesheet, the dependency's source stylesheet, another extension, an arbitrary message, another error code, and a stale exemption without modifying shared build artifacts. The [CI observation decision](../testing/2026-09-08-ci-completion-observations.md) keeps the fixture completion and isolation decisions it owns; its built-client classification paragraph keeps the sweep summary and links here for the admitted evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md index 77de713801..1c1aab35df 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md @@ -22,6 +22,6 @@ Dockkit 豁免接受 Node 对任意样式表因未知 `.css` 扩展名而拒绝 ## 后果 -只要 Dockkit bundle 因 `.css` 导入之外的任何原因停止导入,sweep 就会报告它,该条目也不再断言失败的是哪个样式表。[限定范围的 resolve/load hook](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts)覆盖被接受的 Dockkit 样式表、依赖的源样式表、其他扩展名、任意消息、其他错误码和陈旧豁免,不修改共享构建产物。 +只要 Dockkit bundle 因 Node 未知 `.css` 扩展名拒绝之外的任何原因停止导入,sweep 就会报告它,该条目也不再断言失败的是哪个样式表。[限定范围的 resolve/load hook](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts)覆盖被接受的 Dockkit 样式表、依赖的源样式表、其他扩展名、任意消息、其他错误码和陈旧豁免,不修改共享构建产物。 [CI 观察决策](../testing/2026-09-08-ci-completion-observations.zh.md)保留其拥有的 fixture 完成与隔离决策;其已构建 Client 导入分类段落保留 sweep 摘要,并就被接受的证据链接到本文。 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 79bc2c7587..7abcc672c9 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: 072194713d3038fd714855c13a05dc213d20f4b1 -README.zh.md: 79f17edb958516dab94b42869a3a6d6cf11cb8b9 +README.md: d6291063678c9b9f6cc233ce91d737ffe187c37c +README.zh.md: 5c3a54a054284ecd2ba227913099533a41fd2605 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 072194713d..d629106367 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -32,7 +32,7 @@ Three build artifacts and one source-owned process layer: Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The fixture generator owns current-generation logs and the projection cache; committed predecessor logs remain byte-identical alongside them. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. -The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts Node’s unknown-`.css`-extension refusal for any stylesheet rather than one pinned path; another extension or error code, and an unexpectedly successful exempt import, remain errors. See the [stylesheet exemption decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md). +The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts Node’s unknown-`.css`-extension refusal for any stylesheet rather than one pinned path; another extension, error code, or message, and an unexpectedly successful exempt import, remain errors. See the [stylesheet exemption decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md). ----- diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 79f17edb95..5c3a54a054 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -32,7 +32,7 @@ kind: "package-library" 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。fixture 生成器负责当前代日志与投影缓存;已提交的前代日志逐字节保持不变,与它们并存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 -[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外接受 Node 针对任意样式表报告的未知 `.css` 扩展名错误,而不限定某一固定路径;其他扩展名或错误码,以及意外成功的豁免导入,仍然报错。参见 [样式表豁免决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)。 +[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外接受 Node 针对任意样式表报告的未知 `.css` 扩展名错误,而不限定某一固定路径;其他扩展名、错误码或消息,以及意外成功的豁免导入,仍然报错。参见 [样式表豁免决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)。 ----- From 42123ef019785e90a6e8acc8853d5f592e24a0ae Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 17:12:45 +0800 Subject: [PATCH 27/60] fix(desktop): tighten parallel notarization checks --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/package-macos.ts | 3 +- apps/desktop/scripts/package-target.ts | 1 + .../tests/macos-notarized-application.spec.ts | 45 +++++++++++++++++++ apps/desktop/tests/package-macos.spec.ts | 6 ++- 7 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/tests/macos-notarized-application.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e83f63e217..b7ab466b8d 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: cd5425fab72ae14eaf58ad2ff4f39472cc510ab1 -README.zh.md: 32e15d8f779282e230240610578085a900343790 +README.md: c053e4894714d10cba2a9d9c1ebed71b53457c37 +README.zh.md: e27554fe8a91ee8a918380ef5a860b0838351aa5 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cd5425fab7..c053e48947 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -129,7 +129,7 @@ pnpm run upload:mac:arm64 Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename. -The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands still notarize and staple the App. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 32e15d8f77..e27554fe8a 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -129,7 +129,7 @@ pnpm run upload:mac:arm64 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。 -macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令仍会公证 App 并钉票。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts index 382e52b150..f1378d55ef 100644 --- a/apps/desktop/scripts/package-macos.ts +++ b/apps/desktop/scripts/package-macos.ts @@ -59,7 +59,8 @@ async function timed(label: string, action: () => Promise): Promise * Both lanes settle before cleanup or rejection. The ZIP contains a stapled App; the DMG * carries its own ticket and encloses the signed App without an individually stapled ticket. * @param request - Signed directory build, release version, architecture, and credentials. - * @param build - Runs electron-builder to completion with publishing disabled. + * @param build - Runs electron-builder with publishing disabled; resolves only after its DMG + * notarization and verification hook succeeds, and rejects on build or hook failure. * @param apple - Apple signing, copying, and notarization operations. * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory. */ diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index e0fc9acf76..22c303d2bc 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -238,6 +238,7 @@ export function desktopElectronBuilderArguments( 'never', ...(directory ? ['--dir'] : []), ...(artifact === undefined ? [] : [ + ...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []), '--prepackaged', artifact.appPath, '--config.directories.output', artifact.output, ]), diff --git a/apps/desktop/tests/macos-notarized-application.spec.ts b/apps/desktop/tests/macos-notarized-application.spec.ts new file mode 100644 index 0000000000..38fc57054c --- /dev/null +++ b/apps/desktop/tests/macos-notarized-application.spec.ts @@ -0,0 +1,45 @@ +/** Verify application qualification commands without invoking Apple tools. */ + +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { verifyMacOSNotarizedApplication } from '../scripts/verify-macos-signature.mjs' + +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawnSync: vi.fn(), +})) + +const expected = { signingIdentity: 'Example Company (TEAMID1234)', teamId: 'TEAMID1234' } +const appPath = '/private build/DeepSeek Harness.app' +const commands = [ + ['/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]], + ['/usr/bin/codesign', ['--display', '--verbose=4', appPath]], + ['/usr/bin/xcrun', ['stapler', 'validate', appPath]], + ['/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]], +] as const + +afterEach(() => { vi.resetAllMocks() }) + +describe('notarized application qualification', () => { + it.each([undefined, 0, 1, 2, 3])('stops at failed command %s or verifies every qualification', (failedCommand) => { + let index = 0 + vi.mocked(spawnSync).mockImplementation(() => ({ + pid: 1, + output: [], + stdout: '', + stderr: `Authority=Developer ID Application: ${expected.signingIdentity}\nTeamIdentifier=${expected.teamId}\n`, + status: index++ === failedCommand ? 1 : 0, + signal: null, + })) + if (failedCommand === undefined) { + expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).not.toThrow() + } else { + expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).toThrow('exited with 1') + } + const calledCommands = commands.slice(0, failedCommand === undefined ? commands.length : failedCommand + 1) + expect(spawnSync).toHaveBeenCalledTimes(calledCommands.length) + for (const [index, [command, args]] of calledCommands.entries()) { + expect(spawnSync).toHaveBeenNthCalledWith(index + 1, command, args, { encoding: 'utf8' }) + } + }) +}) diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts index a8f961c3de..fcbc743bbd 100644 --- a/apps/desktop/tests/package-macos.spec.ts +++ b/apps/desktop/tests/package-macos.spec.ts @@ -87,7 +87,7 @@ describe('parallel macOS artifacts', () => { }) try { await appStarted.promise - expect([...starts]).toEqual(['app', 'dmg']) + await vi.waitFor(() => { expect([...starts]).toEqual(expect.arrayContaining(['app', 'dmg'])) }) expect(new Set(copies).size).toBe(2) expect(copies.every(path => path !== f.appPath)).toBe(true) appAccepted.release() @@ -107,7 +107,8 @@ describe('parallel macOS artifacts', () => { } finally { appAccepted.release() dmgCompleted.release() - try { await operation } finally { await rm(f.root, { recursive: true, force: true }) } + await Promise.allSettled([operation]) + await rm(f.root, { recursive: true, force: true }) } }) @@ -181,6 +182,7 @@ describe('parallel macOS artifacts', () => { expect(desktopElectronBuilderArguments(target, false, { format, appPath, output })).toEqual([ 'exec', 'electron-builder', '--config', 'electron-builder.config.mjs', '--mac', format, '--arm64', '--publish', 'never', + '--config.mac.notarize=false', '--prepackaged', appPath, '--config.directories.output', output, ]) } From aaa02a39709893ea45ac220aa87194d8522325fe Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 17:27:52 +0800 Subject: [PATCH 28/60] fix(subprocess): settle a Linux scope left active with no processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Range observation waited for an active transient scope to leave `active` before it would prove the managed range empty. systemd ends a scope only when it observes the populated-to-empty transition, so a payload killed before the manager placed it in the scope's cgroup leaves the unit active with an empty cgroup forever. `waitForExit()` then polled a unit that could not change, and `LocalSubprocessRuntime.disposeManagedProcesses()` awaited it without bound — teardown hung for any command cancelled immediately after spawn. Each state query now also reads the unit's process count. Once termination was requested and the launcher has exited, a still-active unit reporting no processes is the empty range: the launcher forked every process it will ever fork, so nothing later joins that cgroup. The owner stops the leftover unit so transient units cannot accumulate, and an unreported or `[not set]` count stays unknown and keeps waiting. The race needs a live user-systemd manager, so it stayed dormant wherever the native scope path was unavailable and surfaced once CI routed lanes onto the Blacksmith hosted image, whose runners accept that path. Ten repetitions of `local.spec.ts > disposal kills still-running processes and awaits their exit` in a Linux container with a user manager failed seven times before the change and passed ten times after it. --- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 4 +- ...-08-28-subprocess-native-containment.zh.md | 4 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/linux-scope.ts | 51 +++++++++++- .../tests/linux-scope.spec.ts | 77 +++++++++++++++++++ 8 files changed, 134 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index f20124e9f9..2f925cf952 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84 -2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d +2026-08-28-subprocess-native-containment.md: 7523f9d66e7a302f6ce9c77d93671a5b1303a5aa +2026-08-28-subprocess-native-containment.zh.md: 252a8e9fd058cf37a1a7a70f09394a6811d58580 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 8e1e12a853..7523f9d66e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor. -Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion. +Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads `LoadState`, `ActiveState`, and `TasksCurrent`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal, except that once termination was requested and the launcher has exited, a still-active unit reporting no processes is an empty range: the manager ends a scope only on the populated-to-empty transition, so a payload killed before it entered the cgroup never triggers one. That conclusion stops the leftover unit so transient units cannot accumulate, while an unreported or `[not set]` process count stays unknown and keeps waiting. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion. The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants. @@ -54,7 +54,7 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification -- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. +- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement. - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index 83bc20a29d..252a8e9fd0 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。 -request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 +request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询读取 `LoadState`、`ActiveState` 与 `TasksCurrent`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态;例外是:一旦请求过终止且 launcher 已经退出,报告没有任何进程却仍 active 的 unit 就是空 range——manager 只在观测到 populated→empty 转变时才结束 scope,因此进入 cgroup 前就被杀死的 payload 永远不会触发该转变。该判定会 stop 掉这个遗留 unit,使 transient unit 不会累积;未上报或为 `[not set]` 的进程数仍视为未知并继续等待。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。 @@ -54,7 +54,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification -- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 +- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 5cc4ae4c49..f4573b93cc 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: 03856c32e6308a1d075ff41b879da144cc772843 -README.zh.md: d1fb5881396963bf5a8d7cb4f44cabae3d1e5a24 +README.md: 3222d7bf9b9b498baf939212ebbf7da9bf599d76 +README.zh.md: e8d2ddc9398ffe186260a1b4ed3a0cd116ff8afb diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 03856c32e6..3222d7bf9b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -52,7 +52,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. -Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty. +Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. ### What can go wrong diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index d1fb588139..e8d2ddc939 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。 -Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空。 +Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。 ### 可能出错的地方 diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index d5f28b5fb2..c018dc01a3 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -178,6 +178,7 @@ class LinuxScopeStartup { class SystemdScopeOwner implements BoundProcessOwner { private establishment: 'pending' | 'established' = 'pending' private stopped = false + private terminationRequested = false private observation: Promise | undefined private killFailure: Error | undefined private wakeGeneration = 0 @@ -195,6 +196,7 @@ class SystemdScopeOwner implements BoundProcessOwner { signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return + this.terminationRequested = true if (this.direct.running()) this.startup.terminationSignals.add(signal) this.observeRequestConsumption() const directFallbackRequired = this.establishment === 'pending' @@ -256,7 +258,32 @@ class SystemdScopeOwner implements BoundProcessOwner { return true } - private parseUnitState(stdout: string): { loadState: string; activeState: string } { + /** + * Prove an active unit with no processes is the empty managed range rather + * than a launch still placing its payload. systemd ends a scope only on the + * populated-to-empty transition, so a payload killed before it entered the + * cgroup leaves the unit active forever. Requested termination plus a + * departed client makes that leftover conclusive: the client forked every + * process it will ever fork. + */ + private emptyRange(tasksCurrent: number | undefined): boolean { + return this.terminationRequested && tasksCurrent === 0 && !this.direct.running() + } + + /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */ + private releaseEmptyRange(): void { + try { + this.runSync(this.systemctl, ['--user', 'stop', this.unit], { + env: managerEnvironment(), + stdio: 'ignore', + timeout: SYSTEMCTL_TIMEOUT_MS, + }) + } catch { + // The range is already empty; a failed cleanup leaves only the transient unit. + } + } + + private parseUnitState(stdout: string): { loadState: string; activeState: string; tasksCurrent: number | undefined } { const values = new Map() for (const line of stdout.split(/\r?\n/u)) { if (line === '') continue @@ -272,10 +299,21 @@ class SystemdScopeOwner implements BoundProcessOwner { } const loadState = values.get('LoadState') const activeState = values.get('ActiveState') - if (values.size !== 2 || loadState === undefined || activeState === undefined) { + // The manager prints this sentinel for a property the unit does not carry. + const reportedTasks = values.get('TasksCurrent') + const tasksCurrent = reportedTasks === '[not set]' ? undefined : reportedTasks + if (values.size !== (reportedTasks === undefined ? 2 : 3) + || loadState === undefined || activeState === undefined) { throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`) } - return { loadState, activeState } + if (tasksCurrent !== undefined && !/^\d+$/u.test(tasksCurrent)) { + throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`) + } + return { + loadState, + activeState, + tasksCurrent: tasksCurrent === undefined ? undefined : Number(tasksCurrent), + } } private async rangeActive(): Promise { @@ -286,10 +324,11 @@ class SystemdScopeOwner implements BoundProcessOwner { this.unit, '--property=LoadState', '--property=ActiveState', + '--property=TasksCurrent', ]) const output = `${result.stdout}\n${result.stderr}` if (result.status === 0) { - const { loadState, activeState } = this.parseUnitState(result.stdout) + const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout) if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit() if (loadState !== 'loaded') { throw new Error( @@ -302,6 +341,10 @@ class SystemdScopeOwner implements BoundProcessOwner { throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`) } if (this.killFailure !== undefined) throw this.killFailure + if (this.emptyRange(tasksCurrent)) { + this.releaseEmptyRange() + return false + } return true } if (!MISSING_UNIT.test(output)) { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ab69f4483b..8233e60a2f 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -84,6 +84,20 @@ function unitState(loadState: string, activeState: string) { return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' } } +function activeUnitWithTasks(tasks: string) { + return { status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n`, stderr: '' } +} + +/** Deny every real process-group signal so a fake child never reaches a live host group. */ +function denyProcessGroups(): void { + vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') }) +} + +/** Record the systemctl invocations a scope owner makes, succeeding unless a case overrides it. */ +function recordingSystemctl() { + return vi.fn((_command: string, _args: readonly string[]) => ({ status: 0, stdout: '', stderr: '' })) +} + function spec() { return { argv: ['tool', 'literal arg'], @@ -446,6 +460,7 @@ describe('Linux scope establishment and quiescence', () => { ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'], ['LoadState=loaded\n', 'incomplete state'], ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'], + ['LoadState=loaded\nActiveState=active\nTasksCurrent=0\nOther=value\n', 'incomplete state'], ] as const) { const launched = launch(async () => ({ status: 0, stdout, stderr: '' })) await expect(launched.result.owner.waitForExit()).rejects.toThrow(message) @@ -453,6 +468,68 @@ describe('Linux scope establishment and quiescence', () => { } }) + it('rejects a manager process count that is neither numeric nor the unset sentinel', async () => { + const launched = launch(async () => activeUnitWithTasks('many')) + await expect(launched.result.owner.waitForExit()).rejects.toThrow('non-numeric TasksCurrent') + launched.result.owner.cleanup?.() + }) + + it('releases an active scope left with no processes once its client has gone', async () => { + // Regression: the manager's empty cgroup never ends this unit on its own. + denyProcessGroups() + const spawnSync = recordingSystemctl() + const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never }) + launched.result.owner.signal('SIGKILL') + launched.child.exit(null, 'SIGKILL') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(spawnSync.mock.calls.map(call => call[1])).toEqual([ + ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', expect.stringMatching(/\.scope$/u)], + ['--user', 'stop', expect.stringMatching(/\.scope$/u)], + ]) + launched.result.owner.cleanup?.() + }) + + it('concludes the empty range even when releasing the leftover scope fails', async () => { + denyProcessGroups() + const spawnSync = recordingSystemctl() + .mockImplementationOnce(() => ({ status: 0, stdout: '', stderr: '' })) + .mockImplementationOnce(() => { throw new Error('systemctl is gone') }) + const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never }) + launched.result.owner.signal('SIGKILL') + launched.child.exit(null, 'SIGKILL') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + launched.result.owner.cleanup?.() + }) + + it('keeps waiting while the client still owns an active scope with no processes', async () => { + denyProcessGroups() + const spawnSync = recordingSystemctl() + const states = [activeUnitWithTasks('0'), unloadedUnit()] + const launched = launch(async () => states.shift() ?? unloadedUnit(), { + spawnSync: spawnSync as never, + }) + launched.result.owner.signal('SIGTERM') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill']) + launched.result.owner.cleanup?.() + }) + + it('keeps waiting for an active empty scope no termination has requested', async () => { + const states = [activeUnitWithTasks('0'), unloadedUnit()] + const launched = launch(async () => states.shift() ?? unloadedUnit()) + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(launched.spawnSync).not.toHaveBeenCalled() + launched.result.owner.cleanup?.() + }) + + it('treats an unset process count as unknown and keeps waiting', async () => { + const states = [activeUnitWithTasks('[not set]'), unloadedUnit()] + const launched = launch(async () => states.shift() ?? unloadedUnit()) + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(launched.spawnSync).not.toHaveBeenCalled() + launched.result.owner.cleanup?.() + }) + it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => { const spawnSync = vi.fn() .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) From ac5f851095819b90247c25ee7b0209c1245116f5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:13 +0800 Subject: [PATCH 29/60] test(directory-picker): probe the folder dialog class instead of the platform A Windows Server image can answer CoCreateInstance(CLSID_FileOpenDialog) with CLASS_E_CLASSNOTAVAILABLE, so the win32 COM smoke has no dialog to open and the POSIX rejection expectation is the only proof that image can produce. Gate the pair on a PowerShell activation probe taken outside the code under test, so a GUID or vtable regression in src still fails the smoke instead of skipping it. --- .../tests/win32-dialog.spec.ts | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 8e7d6951b8..bab9f4c222 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -1,11 +1,12 @@ /** * Driver tests: the child-process message protocol mapped onto the promise, * the WM_CLOSE abort service (including the show-race retry and the kill - * last resort) against fakes, plus the real spawn plumbing — POSIX hosts - * prove the default path rejects cleanly (koffi cannot load ole32 there), - * and win32 hosts briefly open and auto-abort a real dialog. + * last resort) against fakes, plus the real spawn plumbing: hosts that cannot + * open the folder dialog prove the default path rejects cleanly and hosts + * that can briefly open and auto-abort a real one. */ +import { spawnSync } from 'node:child_process' import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts' @@ -41,6 +42,28 @@ function harness(overrides: Partial = {}): Harness { const live = (): AbortSignal => new AbortController().signal +/** CLSID_FileOpenDialog, spelled out here so the probe below owns its own copy. */ +const CLSID_FILE_OPEN_DIALOG = 'dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7' + +/** + * Whether this host can open the shell's folder dialog. Some Windows Server + * images answer `CoCreateInstance(CLSID_FileOpenDialog)` with + * CLASS_E_CLASSNOTAVAILABLE (0x80040111) instead of an object, so no dialog + * can ever appear there. PowerShell activates the class outside the code + * under test, which keeps a GUID or vtable regression in `src` failing the + * smoke below instead of silently skipping it. Every exception from that + * activation reads as refusal, so a host that fails the probe for another + * reason only loses the real-dialog case; a probe that cannot run at all — + * no `powershell.exe` — keeps the win32 assumption. + */ +const opensRealDialog = process.platform === 'win32' && spawnSync('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `try { [void][Activator]::CreateInstance([Type]::GetTypeFromCLSID([Guid]'${CLSID_FILE_OPEN_DIALOG}')) } catch { exit 1 }`, +], { encoding: 'utf8' }).status !== 1 + describe('pickWin32Directory', () => { it('resolves the selected path and the cancellation null', async () => { const first = harness() @@ -145,15 +168,18 @@ describe('pickWin32Directory', () => { expect(close.mock.calls.length).toBeGreaterThan(10) }) - // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped - // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. - it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { + // Hosts without the folder dialog exercise the REAL default plumbing end to + // end: the tsx-bootstrapped worker spawns, loads koffi, and reports the + // refusal — ole32.dll fails to load on POSIX, and a Windows Server image + // that refuses the dialog class reports the same rejection. + it.skipIf(opensRealDialog)('rejects through the real worker where the Win32 surface is unavailable', async () => { await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') }, 30_000) - // win32 hosts run the true COM smoke instead: a real dialog opens briefly - // and the abort service closes it (the same lever a disconnecting client pulls). - it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { + // Hosts with the dialog run the true COM smoke instead: a real dialog opens + // briefly and the abort service closes it (the same lever a disconnecting + // client pulls). + it.skipIf(!opensRealDialog)('opens and abort-closes a real dialog', async () => { const controller = new AbortController() setTimeout(() => { controller.abort() From df709f442c4ec05640f928ccd81ded6aaa28fc59 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:14 +0800 Subject: [PATCH 30/60] test(subprocess): align the teardown race with the settlement contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disposal contains a spawn-failure rejection that races teardown` asserted one winner of the race. A bootstrap that publishes its pre-exec failure rejects with that failure, and a teardown that stops the bootstrap first settles as the requested termination — the recorded failure only outranks the stop when it was published before the stop landed. Assert the contract instead: a rejection carries the failure, a stop carries SIGTERM, which only the Linux scope records because the win32 job owner and the fallback launcher both reject the start. The two terminal cases that drive a mocked PTY exit state why they pin `internals.platform`: under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. --- .../subprocess-local/tests/local.spec.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 135638f54a..a13f589de1 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -393,6 +393,8 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType + // Pins the containment choice: with the host's native scope a mocked PTY + // exit races the scope bootstrap. service.internals = { platform: 'darwin' } const handle = await ctx.subprocess.spawnTerminal({ argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, @@ -600,6 +602,8 @@ describe('LocalSubprocessRuntime', () => { ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const alive = new Set([124]) + // Pins the containment choice: with the host's native scope a mocked PTY + // exit races the scope bootstrap. ;(ctx.subprocess as InstanceType).internals = { platform: 'darwin' } ;(ctx.subprocess as InstanceType).terminalInspector = { foregroundPgid: () => 123, @@ -878,10 +882,26 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) // Dispose before the rejection continuation removes the handle from the - // live set, so teardown itself must swallow the rejected done. + // live set, so teardown itself must swallow the rejected done. Two + // settlements are valid and the winner is a race: a bootstrap that + // publishes its pre-exec failure rejects with that failure, and a teardown + // that stops the bootstrap first settles as the requested termination — + // the recorded failure only outranks the stop when it was published before + // the stop landed. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await fiber.dispose() - await expect(handle.done).rejects.toThrow() + const settlement = await handle.done.then( + outcome => ({ kind: 'stopped' as const, outcome }), + (error: unknown) => ({ kind: 'failed' as const, error }), + ) + if (settlement.kind === 'failed') { + expect(settlement.error).toBeInstanceOf(Error) + } else { + // Only the Linux scope records a stop this way: the win32 job owner + // rejects a cancelled start and the fallback launcher rejects the ENOENT, + // so neither can produce the stopped branch. + expect(settlement.outcome.signal).toBe('SIGTERM') + } }) it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { From f45ca2d9acdbb295e13b6e7eac7daa717ef50470 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:15 +0800 Subject: [PATCH 31/60] test: drop two host-paced assumptions the hosted image exposes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACP plugin-config case drove a real dispose with 150ms graces. On the hosted image the SIGKILL escalation landed while the managed scope could not take the signal — systemctl failed the kill ("Failed to send signal SIGKILL to auxiliary processes: Invalid argument") — so the run reported a teardown failure the configuration never asked for. The case asserts that config graces reach the real run, so the values only need to clear the host's scope handling. Its mock refuses stdin EOF and SIGTERM by design, so the case waits out both graces and carries its own budget for that fixed cost. Both illegal-UTF-8 residual cases relied on a plain sched_yield leaving each write as its own read chunk. A starved reader coalesces hundreds of bytes into one chunk, which inflates the measured peak for correct accounting too: the hosted image measured 2563 against the 2048 bound. Pace with a real sleep so each byte is its own chunk everywhere, and keep each payload above the bound (3200 bytes; 1100 CESU-8 sequences) so a raw-byte undercount still flushes past it instead of only at EOF. --- .../code-runtime-python/tests/runtime.spec.ts | 29 ++++++++++++++----- .../subagent-acp/tests/subagent-acp.spec.ts | 20 +++++++++---- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index 4fa4f225d4..b89ec692b0 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -1517,10 +1517,14 @@ describe('PythonCodeRuntime — programs and bindings', () => { const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) result = await runtime.run({ program: [ - 'import os', - 'for _ in range(6000):', + 'import os, time', + // One byte per chunk on every host: a plain yield lets a loaded + // reader coalesce, and the coalesced chunk is what the bound below + // measures. The payload stays above the 2048 discriminator, so a + // raw-byte undercount still flushes the whole residual at EOF. + 'for _ in range(3200):', ' os.write(1, b"\\xff")', - ' os.sched_yield()', + ' time.sleep(0.001)', 'return None', ].join('\n'), bindings: [], @@ -1534,7 +1538,10 @@ describe('PythonCodeRuntime — programs and bindings', () => { // merged buffer stays well under 2048. A raw-byte undercount would let it // reach ~3072 before flushing, so 2048 discriminates. expect(maxConcat).toBeLessThan(2048) - }) + // The paced payload costs ~3.2s deterministically, which is above the + // 5000ms default the local unit entry grants, so the case carries its own + // bound instead of relying on the lane to widen it. + }, 20_000) it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => { // A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte @@ -1559,12 +1566,15 @@ describe('PythonCodeRuntime — programs and bindings', () => { const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) result = await runtime.run({ program: [ - 'import os', + 'import os, time', 'seq = (0xed, 0xa0, 0x80)', - 'for _ in range(2000):', + // 1100 sequences are 3300 raw bytes, past the 3072-byte budget a + // raw-byte undercount reaches, so the undercount flushes above the + // 2048 discriminator instead of only at EOF. + 'for _ in range(1100):', ' for b in seq:', ' os.write(1, bytes((b,)))', - ' os.sched_yield()', + ' time.sleep(0.001)', 'return None', ].join('\n'), bindings: [], @@ -1579,7 +1589,10 @@ describe('PythonCodeRuntime — programs and bindings', () => { // largest merged buffer stays well under 2048. Charging the structural width // 3 would need ~1024 raw bytes, tripling the peak past 2048. expect(maxConcat).toBeLessThan(2048) - }) + // The paced payload costs ~3.3s deterministically, which is above the + // 5000ms default the local unit entry grants, so the case carries its own + // bound instead of relying on the lane to widen it. + }, 20_000) it('charges a lone surrogate its full six escaped bytes, not three', async () => { // A forged `log` frame carrying `\ud800` escapes materializes lone diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 1de31c00ae..cea3e4536f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1412,17 +1412,27 @@ describe('dsh-subagent-acp', () => { args: [mockServer], permission: 'reject', env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, - disposeEofGraceMs: 150, - disposeGraceMs: 150, + // The graces are wall-clock budgets for the managed scope's teardown on a + // shared host. At 150ms the hosted image escalated while the scope could + // not take the signal and systemctl failed the kill ("Failed to send + // signal SIGKILL to auxiliary processes: Invalid argument"), surfacing as + // a teardown failure the configuration never asked for. The case asserts + // that config graces reach the real run, so the value only has to clear + // the host's scope handling. + disposeEofGraceMs: 5_000, + disposeGraceMs: 5_000, }) const run = await ctx.subagents.start('acp', request()) expect(start).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({ - disposeEofGraceMs: 150, - disposeGraceMs: 150, + disposeEofGraceMs: 5_000, + disposeGraceMs: 5_000, })) await waitForFile(ready, task.timeout) await expect(run.dispose()).resolves.toBeUndefined() - }) + // The trapped child refuses stdin EOF, so the ladder waits out the EOF + // grace and then the SIGTERM grace (10s) before the SIGKILL settles it. + // That fixed cost is above the 5000ms default the local unit entry grants. + }, 30_000) it('rejects a dispose grace outside the Node timer range at load', async () => { for (const bad of [ From 45c10ed2ce022d32ee52f057a3d9afe80051b57f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:16 +0800 Subject: [PATCH 32/60] ci: give the Linux coverage lane the hosted image's teardown budget The managed-scope disposal cases in subprocess-local and bash-sandbox run past the 5000ms default when this lane's four partitions, six workers per gate, and sibling gates share one host. The same commit stays inside the budget on the in-house pool: the hosted image exceeded it (run 34449848541) and the lane is green with 90000 (run 34457655892). The Windows coverage lane already grants the same budget for the same class of teardown. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c06a52d15..57ed55c36d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,14 @@ jobs: DSH_COVERAGE_MAX_WORKERS: '6' DSH_COVERAGE_PARTITIONS: '4' DSH_GATE_CONCURRENCY: '3' + # Managed-scope teardown cases run past the default per-test budget when + # this lane's partitions, workers, and sibling gates share one host: the + # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on + # the hosted image (run 34449848541) while the same commit stayed inside + # the budget on the in-house pool; the lane is green with this value + # (run 34457655892). The Windows coverage lane grants the same budget for + # the same reason. + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' # A gate failure aborts the sibling gate instead of waiting out its # multi-minute instrumented run. DSH_GATE_FAIL_FAST: '1' From 87f32dc8ca8e0e7e297a6bba517612f9a8c0b16a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:16 +0800 Subject: [PATCH 33/60] docs(notes): record the hosted-image assumptions the coverage suite names The failover leg runs this suite on pools this repository does not own, and the coverage lanes failed there on host properties their cases never named: the containment a mocked PTY exit raced, the grace a managed scope needs before it can take a SIGKILL, reader coalescing in the illegal-UTF-8 residual cases, and a Windows image that refuses the folder dialog class. Record what each case pins now, what was rejected, and the runs that measured it, so the next pool change starts from those host dependencies instead of re-diagnosing them. --- ...10-hosted-image-test-assumptions.i18n.yaml | 6 +++ ...026-09-10-hosted-image-test-assumptions.md | 39 +++++++++++++++++++ ...-09-10-hosted-image-test-assumptions.zh.md | 39 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md create mode 100644 .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml new file mode 100644 index 0000000000..52a4c5dd7f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md +2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076 +2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87 diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md new file mode 100644 index 0000000000..e6cd1ef37d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md @@ -0,0 +1,39 @@ +# Agent Note: Hosted-image assumptions in the coverage suite + +Status: implemented + +English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md) + +## Problem + +The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright. + +## Decision + +Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image. + +Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path. + +`disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory. + +`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. At 150ms the hosted image escalated while the scope could not take the signal — `systemctl` failed the kill (`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`) and the teardown reported a failure the configuration never asked for. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants. + +Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start. + +The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host. + +The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption. + +## Alternatives considered + +**Excluding the coverage lanes from the hosted leg.** Rejected: the leg exists to run the same suite on another pool, and the failures named real host dependencies rather than a suite the pool cannot support. + +**Raising only the lane's per-test budget.** Rejected: a wider budget does not change the cases whose cost or behaviour is deterministic — a trapped ACP child still waits out both graces, and a coalesced reader still inflates the measured peak. + +**Keeping the `linux-scope.ts` probe mocks.** Rejected as inert: the platform pin selects the fallback path before either probe is called, so the mock changed no execution path. + +**Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded. + +## Consequences + +The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`). diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md new file mode 100644 index 0000000000..e60910aebd --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md @@ -0,0 +1,39 @@ +# Agent Note: coverage 套件里的托管镜像假设 + +Status: implemented + +[English](2026-09-10-hosted-image-test-assumptions.md) | 中文 + +## 问题 + +[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。 + +## 决策 + +每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。 + +驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径。 + +`disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。 + +`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。在 150ms 时,托管镜像在 scope 还无法接受信号时就升级了信号——`systemctl` 的 kill 失败(`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`),teardown 上报了一个配置从未要求的失败。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。 + +`packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。 + +Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。 + +Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。 + +## 备选方案 + +**把 coverage 通道排除出托管支路。** 否决:该支路的意义就是把同一套测试跑在另一个池上,而这些失败点出的是真实的宿主依赖,不是一个该池无法支撑的套件。 + +**只抬高通道的每用例预算。** 否决:更宽的预算改变不了那些成本或行为确定的用例——被 trap 的 ACP 子进程仍然会等满两个宽限,被合并的读端仍然会抬高测得的峰值。 + +**保留 `linux-scope.ts` 的探针 mock。** 因无效而否决:平台钉死会在任一探针被调用前就选中 fallback 路径,因此该 mock 没有改变任何执行路径。 + +**削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。 + +## 后果 + +套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。 From c4c2d163083c701e8584b4f0cb1d955b68ab2bef Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:14 +0800 Subject: [PATCH 34/60] test(subprocess): align the teardown race with the settlement contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disposal contains a spawn-failure rejection that races teardown` asserted one winner of the race. A bootstrap that publishes its pre-exec failure rejects with that failure, and a teardown that stops the bootstrap first settles as the requested termination — the recorded failure only outranks the stop when it was published before the stop landed. Assert the contract instead: a rejection carries the failure, a stop carries SIGTERM, which only the Linux scope records because the win32 job owner and the fallback launcher both reject the start. The two terminal cases that drive a mocked PTY exit state why they pin `internals.platform`: under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. (cherry picked from commit fee113eb9a5632555304858d2bf6355588a3aaf0) --- .../subprocess-local/tests/local.spec.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 135638f54a..a13f589de1 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -393,6 +393,8 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType + // Pins the containment choice: with the host's native scope a mocked PTY + // exit races the scope bootstrap. service.internals = { platform: 'darwin' } const handle = await ctx.subprocess.spawnTerminal({ argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, @@ -600,6 +602,8 @@ describe('LocalSubprocessRuntime', () => { ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const alive = new Set([124]) + // Pins the containment choice: with the host's native scope a mocked PTY + // exit races the scope bootstrap. ;(ctx.subprocess as InstanceType).internals = { platform: 'darwin' } ;(ctx.subprocess as InstanceType).terminalInspector = { foregroundPgid: () => 123, @@ -878,10 +882,26 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) // Dispose before the rejection continuation removes the handle from the - // live set, so teardown itself must swallow the rejected done. + // live set, so teardown itself must swallow the rejected done. Two + // settlements are valid and the winner is a race: a bootstrap that + // publishes its pre-exec failure rejects with that failure, and a teardown + // that stops the bootstrap first settles as the requested termination — + // the recorded failure only outranks the stop when it was published before + // the stop landed. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await fiber.dispose() - await expect(handle.done).rejects.toThrow() + const settlement = await handle.done.then( + outcome => ({ kind: 'stopped' as const, outcome }), + (error: unknown) => ({ kind: 'failed' as const, error }), + ) + if (settlement.kind === 'failed') { + expect(settlement.error).toBeInstanceOf(Error) + } else { + // Only the Linux scope records a stop this way: the win32 job owner + // rejects a cancelled start and the fallback launcher rejects the ENOENT, + // so neither can produce the stopped branch. + expect(settlement.outcome.signal).toBe('SIGTERM') + } }) it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { From 114e8c9b0950ba545915ea9d2e057521f3dfccc8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:15 +0800 Subject: [PATCH 35/60] test: drop two host-paced assumptions the hosted image exposes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACP plugin-config case drove a real dispose with 150ms graces. On the hosted image the SIGKILL escalation landed while the managed scope could not take the signal — systemctl failed the kill ("Failed to send signal SIGKILL to auxiliary processes: Invalid argument") — so the run reported a teardown failure the configuration never asked for. The case asserts that config graces reach the real run, so the values only need to clear the host's scope handling. Its mock refuses stdin EOF and SIGTERM by design, so the case waits out both graces and carries its own budget for that fixed cost. Both illegal-UTF-8 residual cases relied on a plain sched_yield leaving each write as its own read chunk. A starved reader coalesces hundreds of bytes into one chunk, which inflates the measured peak for correct accounting too: the hosted image measured 2563 against the 2048 bound. Pace with a real sleep so each byte is its own chunk everywhere, and keep each payload above the bound (3200 bytes; 1100 CESU-8 sequences) so a raw-byte undercount still flushes past it instead of only at EOF. (cherry picked from commit 30e0c3ff0415befe3652a55611df8a18d82c18bb) --- .../code-runtime-python/tests/runtime.spec.ts | 29 ++++++++++++++----- .../subagent-acp/tests/subagent-acp.spec.ts | 20 +++++++++---- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index 4fa4f225d4..b89ec692b0 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -1517,10 +1517,14 @@ describe('PythonCodeRuntime — programs and bindings', () => { const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) result = await runtime.run({ program: [ - 'import os', - 'for _ in range(6000):', + 'import os, time', + // One byte per chunk on every host: a plain yield lets a loaded + // reader coalesce, and the coalesced chunk is what the bound below + // measures. The payload stays above the 2048 discriminator, so a + // raw-byte undercount still flushes the whole residual at EOF. + 'for _ in range(3200):', ' os.write(1, b"\\xff")', - ' os.sched_yield()', + ' time.sleep(0.001)', 'return None', ].join('\n'), bindings: [], @@ -1534,7 +1538,10 @@ describe('PythonCodeRuntime — programs and bindings', () => { // merged buffer stays well under 2048. A raw-byte undercount would let it // reach ~3072 before flushing, so 2048 discriminates. expect(maxConcat).toBeLessThan(2048) - }) + // The paced payload costs ~3.2s deterministically, which is above the + // 5000ms default the local unit entry grants, so the case carries its own + // bound instead of relying on the lane to widen it. + }, 20_000) it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => { // A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte @@ -1559,12 +1566,15 @@ describe('PythonCodeRuntime — programs and bindings', () => { const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) result = await runtime.run({ program: [ - 'import os', + 'import os, time', 'seq = (0xed, 0xa0, 0x80)', - 'for _ in range(2000):', + // 1100 sequences are 3300 raw bytes, past the 3072-byte budget a + // raw-byte undercount reaches, so the undercount flushes above the + // 2048 discriminator instead of only at EOF. + 'for _ in range(1100):', ' for b in seq:', ' os.write(1, bytes((b,)))', - ' os.sched_yield()', + ' time.sleep(0.001)', 'return None', ].join('\n'), bindings: [], @@ -1579,7 +1589,10 @@ describe('PythonCodeRuntime — programs and bindings', () => { // largest merged buffer stays well under 2048. Charging the structural width // 3 would need ~1024 raw bytes, tripling the peak past 2048. expect(maxConcat).toBeLessThan(2048) - }) + // The paced payload costs ~3.3s deterministically, which is above the + // 5000ms default the local unit entry grants, so the case carries its own + // bound instead of relying on the lane to widen it. + }, 20_000) it('charges a lone surrogate its full six escaped bytes, not three', async () => { // A forged `log` frame carrying `\ud800` escapes materializes lone diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 1de31c00ae..cea3e4536f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1412,17 +1412,27 @@ describe('dsh-subagent-acp', () => { args: [mockServer], permission: 'reject', env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, - disposeEofGraceMs: 150, - disposeGraceMs: 150, + // The graces are wall-clock budgets for the managed scope's teardown on a + // shared host. At 150ms the hosted image escalated while the scope could + // not take the signal and systemctl failed the kill ("Failed to send + // signal SIGKILL to auxiliary processes: Invalid argument"), surfacing as + // a teardown failure the configuration never asked for. The case asserts + // that config graces reach the real run, so the value only has to clear + // the host's scope handling. + disposeEofGraceMs: 5_000, + disposeGraceMs: 5_000, }) const run = await ctx.subagents.start('acp', request()) expect(start).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({ - disposeEofGraceMs: 150, - disposeGraceMs: 150, + disposeEofGraceMs: 5_000, + disposeGraceMs: 5_000, })) await waitForFile(ready, task.timeout) await expect(run.dispose()).resolves.toBeUndefined() - }) + // The trapped child refuses stdin EOF, so the ladder waits out the EOF + // grace and then the SIGTERM grace (10s) before the SIGKILL settles it. + // That fixed cost is above the 5000ms default the local unit entry grants. + }, 30_000) it('rejects a dispose grace outside the Node timer range at load', async () => { for (const bad of [ From 03770494656e5162cea496826486f9ad23a4ac21 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 10 Sep 2026 17:36:16 +0800 Subject: [PATCH 36/60] ci: give the Linux coverage lane the hosted image's teardown budget The managed-scope disposal cases in subprocess-local and bash-sandbox run past the 5000ms default when this lane's four partitions, six workers per gate, and sibling gates share one host. The same commit stays inside the budget on the in-house pool: the hosted image exceeded it (run 34449848541) and the lane is green with 90000 (run 34457655892). The Windows coverage lane already grants the same budget for the same class of teardown. (cherry picked from commit bf3468adf0947a51f09d4a1aaae2865f3e50a2f2) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c06a52d15..57ed55c36d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,14 @@ jobs: DSH_COVERAGE_MAX_WORKERS: '6' DSH_COVERAGE_PARTITIONS: '4' DSH_GATE_CONCURRENCY: '3' + # Managed-scope teardown cases run past the default per-test budget when + # this lane's partitions, workers, and sibling gates share one host: the + # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on + # the hosted image (run 34449848541) while the same commit stayed inside + # the budget on the in-house pool; the lane is green with this value + # (run 34457655892). The Windows coverage lane grants the same budget for + # the same reason. + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' # A gate failure aborts the sibling gate instead of waiting out its # multi-minute instrumented run. DSH_GATE_FAIL_FAST: '1' From 3b5daf10be073d5cbe84437e86ccb9188072cd50 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 18:16:47 +0800 Subject: [PATCH 37/60] test(ci): verify current master on PR runners From 0e6505454e868c08fa3418d0a773da3655bb1d73 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 18:00:01 +0800 Subject: [PATCH 38/60] Align client boot import failure test with reverted Loader --- packages/client/web/README.i18n.yaml | 4 ++-- packages/client/web/README.md | 2 +- packages/client/web/README.zh.md | 2 +- packages/client/web/src/boot-client.ts | 4 ++-- .../client/web/tests/boot-client.client.spec.ts | 14 +++++++++----- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 313c9860c2..4ff4a942cd 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/web/README.md -README.md: 6bc0c4f241d1afb28825cadc19f89b7f54d4b637 -README.zh.md: e2d7ab81e82f1b5b1b93b95e4ed117a80f897a8e +README.md: 7bdcec9e2a964b746cbd808497f9222e2b4150fa +README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 6bc0c4f241..7bdcec9e2a 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -35,7 +35,7 @@ Boot runs in two stages: the module stage adopts the parser-loaded bootstrap bat ### The boot page -The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import error, or state) instead of a blank page. +The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import failure, or state) instead of a blank page. The console contains the original import error. ### The shared module table diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index e2d7ab81e8..a7243e52a7 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -35,7 +35,7 @@ kind: "package-library" ### 启动页 -启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入错误或状态),而不是白屏。 +启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入失败或状态),而不是白屏。控制台包含原始导入错误。 ### 共享模块表 diff --git a/packages/client/web/src/boot-client.ts b/packages/client/web/src/boot-client.ts index 1275c47c55..110d8530cb 100644 --- a/packages/client/web/src/boot-client.ts +++ b/packages/client/web/src/boot-client.ts @@ -27,8 +27,8 @@ export interface ClientBootOptions { /** * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one * `loader.create({ name })` per manifest row, `loader.await()`, then - * {@link assertEntriesActive}. A row whose module cannot be imported rejects - * `loader.create`, so that import error propagates from here as-is. + * {@link assertEntriesActive}. A row whose module cannot be imported is marked + * failed; the Loader logs its import error and the audit rejects startup. * @param options - context, module system, manifest, optional progress sink. * @returns resolves after every entry is active; rejects with the audit report otherwise. */ diff --git a/packages/client/web/tests/boot-client.client.spec.ts b/packages/client/web/tests/boot-client.client.spec.ts index 478819ca59..43fe974d80 100644 --- a/packages/client/web/tests/boot-client.client.spec.ts +++ b/packages/client/web/tests/boot-client.client.spec.ts @@ -4,7 +4,7 @@ import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph, } from '@deepseek-ai/dsh-client-modules/client' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts' import { FIBER_STATE } from '../src/loader-status.ts' @@ -77,17 +77,21 @@ describe('bootClient', () => { await ctx.fiber.dispose() }) - it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => { + it('reports and logs an import failure for a row that is neither seeded nor a graph row', async () => { const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } }) const manifest = parseBootManifest(graphOf(['ghost'])) const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) const sink = stateSink() await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow( - /failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/, + 'web boot: 1 entry did not activate\nghost: import failed (see console for the import error)', ) - expect(sink.states.get('ghost')).toEqual(['loading']) - await ctx.fiber.dispose() + expect(sink.states.get('ghost')).toEqual(['loading', 'failed']) + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('client-modules: cannot resolve')) }) }) From 39e6e6fb891dc9e83b73cb4a67f72187cc5d93fb Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 18:21:19 +0800 Subject: [PATCH 39/60] Retain awaited client cleanup after rebase --- .../client-runtime/README.i18n.yaml | 4 ++-- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../src/assembly/test-client.ts | 7 +++++-- .../tests/assembly-test-client.client.spec.ts | 19 ++++++++++++++++++- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index addb502c8a..534cc2596a 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/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/test-support/client-runtime/README.md -README.md: e32f7858b32e15e5200f9a986cb88e29309c44cb -README.zh.md: 1b7da6f38723607dedd5eb16a15ade0b974ed135 +README.md: 20f6454012d0d8e4c78d9a0ea0df960d22a71f5a +README.zh.md: d9c6888f17df3f37e699bb6be0f7ee44ce82d373 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index e32f7858b3..20f6454012 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -87,7 +87,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 1b7da6f387..d9c6888f17 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -87,7 +87,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它并等待插件清理完成;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 7e26d9bbc8..fd99de38fd 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -303,11 +303,14 @@ export class TestClient { } /** - * Remove one Loader entry. + * Remove one Loader entry and wait for its plugin cleanup. * @param name - package name of the row. */ async unload(name: string): Promise { - await this.ctx.loader.remove(this.entryOf(name).id) + const entry = this.entryOf(name) + const disposal = entry.fiber?.dispose() + this.ctx.loader.remove(entry.id) + await disposal } /** diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts index 788c640b7f..b4d28d9fc6 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -136,7 +136,24 @@ describe('TestClient (jsdom)', () => { await client.reload(SIDEBAR) await client.flush() expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) - await client.unload(SIDEBAR) + const cleanupStarted = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + const sidebar = [...client.ctx.loader.entries()].find(entry => entry.options.name === SIDEBAR)! + sidebar.fiber!.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await releaseCleanup.promise + }) + let unloaded = false + const unloading = client.unload(SIDEBAR).then(() => { unloaded = true }) + try { + await cleanupStarted.promise + await client.flush() + expect(unloaded).toBe(false) + } finally { + releaseCleanup.resolve(undefined) + await unloading + } + expect(unloaded).toBe(true) await client.flush() expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0) await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`) From 0963bdd7d3cf77f3c3542afd1facd18f51f01163 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 10 Sep 2026 18:29:04 +0800 Subject: [PATCH 40/60] test(python): control stray-output fragments for sealing coverage --- ...10-hosted-image-test-assumptions.i18n.yaml | 4 +- ...026-09-10-hosted-image-test-assumptions.md | 2 + ...-09-10-hosted-image-test-assumptions.zh.md | 2 + .../code-runtime-python/tests/runtime.spec.ts | 52 ----------------- .../tests/stray-fragments.spec.ts | 56 +++++++++++++++++++ 5 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml index 52a4c5dd7f..241a9e9d5f 100644 --- a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md -2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076 -2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87 +2026-09-10-hosted-image-test-assumptions.md: 5cf3b14503162d67d0fbb27743228cb1a8d7ffe2 +2026-09-10-hosted-image-test-assumptions.zh.md: db82566c11b50b9203d30f7be6aec1378512f94b diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md index e6cd1ef37d..5cf3b14503 100644 --- a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md @@ -20,6 +20,8 @@ Terminal cases that drive a mocked PTY exit pin the containment they need (`inte Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start. +The stray-output sealing test in `packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: [run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies. + The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host. The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption. diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md index e60910aebd..db82566c11 100644 --- a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md @@ -20,6 +20,8 @@ Status: implemented `packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。 +`packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:[run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。 + Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。 Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。 diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index b89ec692b0..1f3fd6cb1e 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -4759,58 +4759,6 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(copied).toBeLessThan(256 * 1024) }, 40_000) - it('seals trickled stray fragments into blocks without recopying the sealed prefix', async () => { - // The stray-capture buffer has the same object-overhead exposure as the fd-3 - // reader above: each newline-free `data` chunk is its own Buffer, so a - // program pacing single-byte `os.write(1, ...)` accumulates one object per - // write, which the serialized-cost counter cannot see. Past MAX_PENDING_CHUNKS - // the fragments seal into a finished block; re-merging the whole residual at - // each threshold instead would copy the sealed prefix again and again, making - // the cumulative copy volume quadratic. `Buffer.concat` is wrapped to measure - // that volume — both shapes admit the same final log entry, so the copy total - // is the discriminator. maxLogBytes is raised so the trickle is retained, - // not truncated, which is what forces the fragments to accumulate and seal. - const realConcat = Buffer.concat.bind(Buffer) - let copied = 0 - Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { - for (const part of list) copied += part.length - return realConcat(list, total) - } - let result: CodeRunResult - try { - const { runtime } = await setup({ maxLogBytes: 200_000, maxWallMs: 30_000 }) - result = await runtime.run({ - program: [ - 'import os', - 'for _ in range(60000):', - ' os.write(1, b"x")', - ' os.sched_yield()', - 'os.write(1, b"\\n")', - 'return "done"', - ].join('\n'), - bindings: [], - }) - } finally { - Buffer.concat = realConcat - } - expect(result.error).toBeUndefined() - expect(result.value).toBe('done') - // The trickle coalesces into one log line (no interior newlines). Its exact - // length depends on pipe coalescing, but it is one entry and non-empty. - expect(result.logs.length).toBe(1) - expect((result.logs[0] as string).length).toBeGreaterThan(0) - // Sealing appends a finished block rather than re-merging everything held, so - // each byte is copied a bounded number of times. Re-merging the whole - // residual at every seal threshold instead makes the cumulative copy volume - // quadratic. Measured like the fd-3 sibling above rather than reasoned about: - // this sealed shape copies about 120 KB for 60000 trickled bytes, the - // re-merging shape about 538 KB (the stray path adds one whole-residual - // concat at the terminating newline over the fd-3 sibling's 119/540, landing - // at the same order). 256 KiB sits between them with margin on both sides, so - // reverting the seal to a re-merge turns this assertion red. - expect(copied).toBeLessThan(256 * 1024) - }, 40_000) - it('caps a huge exception diagnostic child-side before it crosses the wire', async () => { // A program can raise with a multi-megabyte message; the child must cap // it at maxValueBytes before formatting/sending, not ship the whole diff --git a/packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts b/packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts new file mode 100644 index 0000000000..9ed70c558b --- /dev/null +++ b/packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts @@ -0,0 +1,56 @@ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' + +// Keep the interpreter and pipe lifecycle real; only OS-dependent read sizes +// change. Each byte reaches the runtime as its own data event. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawn: vi.fn(actual.spawn).mockImplementation((...args) => { + const child = actual.spawn(...args) + const stdout = child.stdout! + const emit = stdout.emit.bind(stdout) + stdout.emit = (event: string | symbol, ...values: unknown[]) => { + if (event !== 'data') return emit(event, ...values) + const chunk = values[0] as Buffer + for (let offset = 0; offset < chunk.length; offset++) { + emit('data', chunk.subarray(offset, offset + 1)) + } + return true + } + return child + }), + } +}) + +const { PythonCodeRuntime } = await import('../src/index.ts') + +it('seals stray fragments without recopying the sealed prefix', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(PythonCodeRuntime, { maxLogBytes: 200_000, maxWallMs: 30_000 }) + const realConcat = Buffer.concat.bind(Buffer) + let copied = 0 + let maxFragments = 0 + const concat = vi.spyOn(Buffer, 'concat').mockImplementation((list, total) => { + maxFragments = Math.max(maxFragments, list.length) + for (const part of list) copied += part.length + return realConcat(list, total) + }) + try { + const result = await ctx.codeRuntime.run({ + program: 'import os\nos.write(1, b"x" * 60000 + b"\\n")\nreturn "done"', + bindings: [], + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe('done') + expect(result.logs).toEqual(['x'.repeat(60_000)]) + // Sealing copies each byte at most twice; merging every accumulated prefix + // instead copies over a megabyte for these 60,001 controlled fragments. + expect(maxFragments).toBeLessThanOrEqual(1024) + expect(copied).toBeLessThan(256 * 1024) + } finally { + concat.mockRestore() + await fiber.dispose() + } +}, 40_000) From 2eed93628c0f447cbc2c38e4068fe13165d7ec2a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:00:38 -0700 Subject: [PATCH 41/60] fix(web): shorten Chinese question skip label --- packages/client/ui-user-questions/src/client/locales.ts | 2 +- .../tests/user-questions-composer.client.spec.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index b3c4232c25..7d8f0c2531 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -11,7 +11,7 @@ export const zh = { 'nav.cancel': '放弃整组问题', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', - 'action.skip': '跳过本题', + 'action.skip': '跳过', 'action.next': '下一题', 'plan.header': '计划待审', 'plan.approve': '确认执行', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 90848b3e9e..5bf5fb483c 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -247,9 +247,9 @@ describe('QuestionComposer', () => { expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) expect(screen.getByText('2 / 3')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) + fireEvent.click(screen.getByRole('button', { name: '跳过' })) expect(screen.getByText('3 / 3')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) + fireEvent.click(screen.getByRole('button', { name: '跳过' })) expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['研究潜力型'] }, @@ -346,7 +346,7 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第一次取消失败')).toBeTruthy() - expect(screen.getByRole('button', { name: '跳过本题' }).disabled).toBe(false) + expect(screen.getByRole('button', { name: '跳过' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第二次取消失败')).toBeTruthy() From 69db89f84899f3a6bcf36affa48a8179ebe5d5de Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:06:14 -0700 Subject: [PATCH 42/60] fix(web): shorten English question skip label --- apps/web/tests/built-boot.expected.e2e.ts | 2 +- apps/web/tests/question-composer.e2e.ts | 2 +- packages/client/ui-user-questions/README.i18n.yaml | 4 ++-- packages/client/ui-user-questions/README.md | 2 +- packages/client/ui-user-questions/README.zh.md | 2 +- packages/client/ui-user-questions/src/client/locales.ts | 2 +- .../tests/user-questions-composer.client.spec.tsx | 2 +- snapshots/web/question-composer/composed.expected.md | 2 +- snapshots/web/question-composer/ui.expected.md | 2 +- snapshots/web/steering/mid-steer.expected.md | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index d7292382df..e30ac22630 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -101,7 +101,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // Skip the resident fixture's three questions, then resolve its approval so // the ordinary composer bar (which owns ContextMeter) resumes. for (let index = 0; index < 3; index += 1) { - fireEvent.click(await screen.findByRole('button', { name: 'Skip this question' })) + fireEvent.click(await screen.findByRole('button', { name: 'Skip' })) } fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 45ba98f7ac..bd39a95bbd 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -345,7 +345,7 @@ describe('web e2e: resident question composer round trip', () => { expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true }) // Settle the wait so teardown is not racing a pending question. - await composer.getByRole('button', { name: 'Skip this question' }).click() + await composer.getByRole('button', { name: 'Skip' }).click() expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] }) await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 63bf522d57..753e95f5e6 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md -README.md: 9e5997eb2ebe69b4b2d49b57a61b0bd36ec41053 -README.zh.md: 44a73607f95e7859b344034b5abaa2c386619083 +README.md: 5a31c688d7f7ea9a9ad10877628a333d797ac06d +README.zh.md: 4db6cfa9fdead65c8e1c567981d0c16d92e9cb1e diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9e5997eb2e..5a31c688d7 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the composer becomes the question surface: answe ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`. ### The plan-review card diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 44a73607f9..4db6cfa9fd 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 ### plan-review 卡片 diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 7d8f0c2531..e54243883b 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -33,7 +33,7 @@ export const en = { 'nav.cancel': 'Dismiss all questions', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', - 'action.skip': 'Skip this question', + 'action.skip': 'Skip', 'action.next': 'Next', 'plan.header': 'Plan review', 'plan.approve': 'Approve', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 5bf5fb483c..41682b0c6c 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -387,7 +387,7 @@ describe('QuestionComposer', () => { const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }]) render() expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Skip' })).toBeTruthy() expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy() }) diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index 66f3706af5..a3e655e8b9 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -15,5 +15,5 @@ - button "Next question" [disabled]: - img - status - - button "Skip this question" + - button "Skip" - button "Submit" diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 1c7109702d..b86e6c0223 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -15,5 +15,5 @@ - button "Next question" [disabled]: - img - status - - button "Skip this question" + - button "Skip" - button "Submit" [disabled] diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 5d3e0929a4..16fec2b6ba 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -47,5 +47,5 @@ - button "Next question" [disabled]: - img - status - - button "Skip this question" + - button "Skip" - button "Submit" [disabled] From 7bce0c26f5a2c752c70cfa56a8625f74f63b6264 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:35:32 -0700 Subject: [PATCH 43/60] test(python): make stray fragment coverage deterministic --- ...10-hosted-image-test-assumptions.i18n.yaml | 4 +- ...026-09-10-hosted-image-test-assumptions.md | 6 ++ ...-09-10-hosted-image-test-assumptions.zh.md | 6 ++ .../code-runtime-python/tests/runtime.spec.ts | 71 ++++++++++--------- 4 files changed, 52 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml index 52a4c5dd7f..8020a2be1a 100644 --- a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md -2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076 -2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87 +2026-09-10-hosted-image-test-assumptions.md: 0c894e89f910db9b952f79b985ef1c04e4963ef0 +2026-09-10-hosted-image-test-assumptions.zh.md: 3acd759b405109c82ef890f41ee90780b3d3080b diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md index e6cd1ef37d..0c894e89f9 100644 --- a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md @@ -8,6 +8,8 @@ English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md) The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright. +The stray-fragment sealing case also assumed `os.sched_yield()` would produce at least 1024 stdout data events. In [run 34474886667](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34474886667), its assertions passed without reaching the seal branch, so Linux coverage failed. + ## Decision Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image. @@ -20,6 +22,8 @@ Terminal cases that drive a mocked PTY exit pin the containment they need (`inte Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start. +The stray-fragment case keeps the real CPython child but splits that child's stdout data events into single-byte buffers. This fixture controls the fragment count regardless of kernel coalescing and restores its opt-in flag and `Buffer.concat` wrapper in `finally`. It asserts the complete 60000-byte log, a maximum concat input count of 1024, and the existing 256 KiB copy budget. Removing the seal fails the input-count assertion; repeatedly merging the sealed prefix fails the copy budget. + The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host. The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption. @@ -34,6 +38,8 @@ The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` **Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded. +**Pacing the fragment-count case with sleeps.** Rejected: the operating system still owns pipe-read boundaries, so sleeps cannot guarantee the 1024 fragments this branch requires. Controlling the data-event boundary preserves the real subprocess and gives the branch a deterministic stimulus. + ## Consequences The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`). diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md index e60910aebd..3acd759b40 100644 --- a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md @@ -8,6 +8,8 @@ Status: implemented [故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。 +杂散输出的分片封块用例还假定 `os.sched_yield()` 会产生至少 1024 次 stdout data 事件。在 [run 34474886667](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34474886667) 中,该用例的断言全部通过,却未进入封块分支,导致 Linux coverage 失败。 + ## 决策 每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。 @@ -20,6 +22,8 @@ Status: implemented `packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。 +分片封块用例保留真实的 CPython 子进程,但将该子进程的 stdout data 事件拆成单字节缓冲区。这个 fixture 控制分片数量,不再取决于内核是否合并写入,并在 `finally` 中恢复启用标志与 `Buffer.concat` 包装。它断言完整的 60000 字节日志、最大 concat 输入数量为 1024,以及原有的 256 KiB 复制预算。移除封块会触发输入数量断言;反复合并已封块的前缀会超出复制预算。 + Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。 Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。 @@ -34,6 +38,8 @@ Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateIn **削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。 +**用 sleep 控制分片数量用例的写入节奏。** 否决:管道读取的边界仍由操作系统决定,因此 sleep 无法保证该分支需要的 1024 个分片。控制 data 事件边界可以保留真实子进程,并确定性地触发该分支。 + ## 后果 套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。 diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index b89ec692b0..826524adc5 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -28,7 +28,7 @@ import type { CodeBindingFunction, CodeJsonValue, CodeRunResult } from '@deepsee * records the same race and solves it with argv-based identity; recording the * mkdtempSync results is the fs-mock equivalent. */ -const { failNextCopyOf, stagedDirs, tempDirs, tempFiles } = vi.hoisted(() => ({ +const { failNextCopyOf, stagedDirs, tempDirs, tempFiles, splitStdout } = vi.hoisted(() => ({ failNextCopyOf: { value: undefined as string | undefined }, stagedDirs: [] as string[], // Test-created temp dirs/files, registered by the helpers below and removed @@ -38,7 +38,28 @@ const { failNextCopyOf, stagedDirs, tempDirs, tempFiles } = vi.hoisted(() => ({ // tests themselves build). tempDirs: [] as string[], tempFiles: [] as string[], + splitStdout: { value: false }, })) +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawn(...args: Parameters) { + const child = actual.spawn(...args) + if (splitStdout.value && child.stdout !== null) { + // The kernel may coalesce writes; this case owns the data-event boundaries. + const emit = child.stdout.emit.bind(child.stdout) + child.stdout.emit = (event: string | symbol, ...values: unknown[]): boolean => { + const chunk = values[0] + if (event !== 'data' || !Buffer.isBuffer(chunk)) return emit(event, ...values) + for (let index = 0; index < chunk.length; index += 1) emit('data', chunk.subarray(index, index + 1)) + return true + } + } + return child + }, + } +}) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { @@ -59,10 +80,9 @@ vi.mock('node:fs', async (importOriginal) => { }) /** - * Integration suite over REAL python3 subprocesses (no subprocess mocks — it is - * cheap and local, per docs/testing.md's real-over-mock policy; the only mock is - * `node:fs.copyFileSync` for the staging-failure cases). Each test builds a fresh - * runtime so budgets can be tuned per case. + * Integration suite over real python3 subprocesses. The staging-failure cases + * mock `copyFileSync`; the fragment-count case controls stdout data-event boundaries. + * Each test builds a fresh runtime so budgets can be tuned per case. */ async function setup(config: Config = {}) { const ctx = new Context() @@ -93,6 +113,7 @@ function makeTempDirSync(prefix: string): string { // Remove every fixture this file created, so repeated runs do not accumulate // `dsh-*` directories and wrappers in the shared tmpdir. afterEach(() => { + splitStdout.value = false for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) for (const file of tempFiles.splice(0)) rmSync(file, { force: true }) }) @@ -4760,54 +4781,38 @@ describe('PythonCodeRuntime — hostile peer', () => { }, 40_000) it('seals trickled stray fragments into blocks without recopying the sealed prefix', async () => { - // The stray-capture buffer has the same object-overhead exposure as the fd-3 - // reader above: each newline-free `data` chunk is its own Buffer, so a - // program pacing single-byte `os.write(1, ...)` accumulates one object per - // write, which the serialized-cost counter cannot see. Past MAX_PENDING_CHUNKS - // the fragments seal into a finished block; re-merging the whole residual at - // each threshold instead would copy the sealed prefix again and again, making - // the cumulative copy volume quadratic. `Buffer.concat` is wrapped to measure - // that volume — both shapes admit the same final log entry, so the copy total - // is the discriminator. maxLogBytes is raised so the trickle is retained, - // not truncated, which is what forces the fragments to accumulate and seal. - const realConcat = Buffer.concat.bind(Buffer) + // One-byte data events force the fragment-count limit independently of pipe + // coalescing. The input-list bound rejects missing seals; copied bytes reject + // repeatedly merging the sealed prefix. Both regressions preserve log text. + const realConcat = Buffer.concat + const previousSplitStdout = splitStdout.value let copied = 0 + let maxParts = 0 Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { for (const part of list) copied += part.length + maxParts = Math.max(maxParts, list.length) return realConcat(list, total) } let result: CodeRunResult try { + splitStdout.value = true const { runtime } = await setup({ maxLogBytes: 200_000, maxWallMs: 30_000 }) result = await runtime.run({ program: [ 'import os', - 'for _ in range(60000):', - ' os.write(1, b"x")', - ' os.sched_yield()', - 'os.write(1, b"\\n")', + 'os.write(1, b"x" * 60000 + b"\\n")', 'return "done"', ].join('\n'), bindings: [], }) } finally { Buffer.concat = realConcat + splitStdout.value = previousSplitStdout } expect(result.error).toBeUndefined() expect(result.value).toBe('done') - // The trickle coalesces into one log line (no interior newlines). Its exact - // length depends on pipe coalescing, but it is one entry and non-empty. - expect(result.logs.length).toBe(1) - expect((result.logs[0] as string).length).toBeGreaterThan(0) - // Sealing appends a finished block rather than re-merging everything held, so - // each byte is copied a bounded number of times. Re-merging the whole - // residual at every seal threshold instead makes the cumulative copy volume - // quadratic. Measured like the fd-3 sibling above rather than reasoned about: - // this sealed shape copies about 120 KB for 60000 trickled bytes, the - // re-merging shape about 538 KB (the stray path adds one whole-residual - // concat at the terminating newline over the fd-3 sibling's 119/540, landing - // at the same order). 256 KiB sits between them with margin on both sides, so - // reverting the seal to a re-merge turns this assertion red. + expect(result.logs).toEqual(['x'.repeat(60000)]) + expect(maxParts).toBe(1024) expect(copied).toBeLessThan(256 * 1024) }, 40_000) From 884485e7e6edec1a3633bef0a2ce2abd8cedfa09 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:40:50 -0700 Subject: [PATCH 44/60] test(python): restore concat through its spy --- .../code-runtime-python/tests/runtime.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index 826524adc5..6c028cdeb5 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -4784,15 +4784,15 @@ describe('PythonCodeRuntime — hostile peer', () => { // One-byte data events force the fragment-count limit independently of pipe // coalescing. The input-list bound rejects missing seals; copied bytes reject // repeatedly merging the sealed prefix. Both regressions preserve log text. - const realConcat = Buffer.concat + const realConcat = Buffer.concat.bind(Buffer) const previousSplitStdout = splitStdout.value let copied = 0 let maxParts = 0 - Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { + const concatSpy = vi.spyOn(Buffer, 'concat').mockImplementation((list: readonly Uint8Array[], total?: number): Buffer => { for (const part of list) copied += part.length maxParts = Math.max(maxParts, list.length) return realConcat(list, total) - } + }) let result: CodeRunResult try { splitStdout.value = true @@ -4806,7 +4806,7 @@ describe('PythonCodeRuntime — hostile peer', () => { bindings: [], }) } finally { - Buffer.concat = realConcat + concatSpy.mockRestore() splitStdout.value = previousSplitStdout } expect(result.error).toBeUndefined() From 060323d8e2f355022cfa9dbdb9226a421a7b7638 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Thu, 10 Sep 2026 20:32:14 +0800 Subject: [PATCH 45/60] feat(web): backport feedback and file refinements to 0.1.5 Backport the selected changes from PR #3928 and PR #3902 onto dsh-v0.1.5-rc.1, retaining baseline session recordings and excluding newer master commits. Refs #3927 Refs #3903 Co-authored-by: LegGasai --- ...8-feedback-dialog-and-categories.i18n.yaml | 4 +- ...26-09-08-feedback-dialog-and-categories.md | 8 +- ...09-08-feedback-dialog-and-categories.zh.md | 8 +- ...026-09-08-shared-file-type-icons.i18n.yaml | 4 +- .../2026-09-08-shared-file-type-icons.md | 7 +- .../2026-09-08-shared-file-type-icons.zh.md | 7 +- ...tric-message-feedback-submission.i18n.yaml | 6 + ...0-symmetric-message-feedback-submission.md | 25 + ...ymmetric-message-feedback-submission.zh.md | 25 + apps/web/tests/feedback-release.e2e.ts | 13 +- apps/web/tests/message-feedback.e2e.ts | 39 +- apps/web/tests/present.e2e.ts | 67 +++ apps/web/tests/produced-files.e2e.ts | 31 ++ docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 6 +- docs/subsystems/feedback.zh.md | 6 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 6 + packages/client/ui-chat/README.zh.md | 6 + .../client/chat/TurnTailNodeView.module.css | 3 + .../tests/turn-tail-spacing.client.spec.ts | 16 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../src/client/Deliverables.module.css | 21 +- .../src/client/Deliverables.tsx | 5 +- .../src/client/PresentedFileCard.tsx | 2 +- .../src/client/ProducedFiles.module.css | 2 +- .../tests/produced-files.client.spec.tsx | 13 +- .../tests/turn-tail-spacing.client.spec.ts | 24 + .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 11 +- .../client/ui-message-feedback/README.zh.md | 11 +- .../client/ui-message-feedback/package.json | 2 +- .../src/client/FeedbackDialog.module.css | 8 - .../src/client/FeedbackDialog.tsx | 29 +- .../src/client/MessageFeedbackActions.tsx | 46 +- .../src/client/controller.ts | 26 +- .../ui-message-feedback/src/client/dialog.ts | 25 +- .../ui-message-feedback/src/client/index.ts | 10 +- .../ui-message-feedback/src/client/locales.ts | 4 +- .../ui-message-feedback/src/client/slots.ts | 22 +- .../ui-message-feedback/src/client/surface.ts | 6 +- .../tests/browser-plugin.client.spec.tsx | 38 +- .../tests/controller.client.spec.ts | 44 +- .../tests/dialog.client.spec.ts | 40 +- .../tests/feedback-dialog.client.spec.tsx | 34 +- .../message-feedback-actions.client.spec.tsx | 109 ++-- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../client/ui-primitives/src/CodeFileIcon.tsx | 491 +----------------- .../src/code-file-icon-artwork.manifest.json | 71 +++ .../src/code-file-icon-artwork.ts | 60 +++ .../tests/code-file-icon.client.spec.tsx | 57 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 2 +- .../feedback/command-feedback/README.zh.md | 2 +- .../feedback/message-feedback/src/types.ts | 2 +- .../feedback-release.expected.json | 4 +- 63 files changed, 778 insertions(+), 778 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md create mode 100644 .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md create mode 100644 packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts create mode 100644 packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts create mode 100644 packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json create mode 100644 packages/client/ui-primitives/src/code-file-icon-artwork.ts diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml index 3d3ce530c3..16c17f3405 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.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-09-08-feedback-dialog-and-categories.md -2026-09-08-feedback-dialog-and-categories.md: 02a41f08541cca85dad7cd3c2c984b5e7cd8fd6d -2026-09-08-feedback-dialog-and-categories.zh.md: 40d83360e52bff569120820b78959a7cc3f4db4f +2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31 +2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md index 02a41f0854..ee656946a9 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -12,9 +12,9 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe `command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit. -`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it. +`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor. -The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code. +The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure. ## Alternatives considered @@ -24,9 +24,9 @@ The dialog is the shared Modal card at the design's width; the design's checkbox **Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs. -**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike. +**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves both message-rating paths and the Session dialog. -**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition. +**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; the File row uses the same `action` kind, so one definition serves both entries. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md index 40d83360e5..ef2b2d4670 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -12,9 +12,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` `command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。 -`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回。 +`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。 -弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开。 +弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。 ## 考虑过的替代方案 @@ -24,9 +24,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` **在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。 -**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径。 +**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务两种消息评分路径与 Session 弹窗。 -**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可。 +**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;「文件」行使用同一个 `action` 种类,一份定义即可服务两个条目。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf23295de5..bf3876efca 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488 -2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef +2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f +2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index f8c87cb325..1e3fe72679 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -14,11 +14,11 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh `FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. ## Alternatives considered @@ -32,12 +32,13 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences - Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. - A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. - Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The fixed 48-entry artwork table adds about 35 kB uncompressed and 17 kB gzip to the shared browser bundle; adding categories must justify that static baseline cost. - The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. - The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index ea115cd63b..fd1f38ebb3 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -14,11 +14,11 @@ Status: implemented `FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 ## 备选方案 @@ -32,12 +32,13 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 - 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 - 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 - 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- 固定的 48 项图稿表为共享浏览器 bundle 增加约 35 kB 未压缩体积和 17 kB gzip 体积;新增类别必须证明这份静态基线成本是必要的。 - primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 - 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml new file mode 100644 index 0000000000..bc63e89dd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.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/feature/2026-09-10-symmetric-message-feedback-submission.md +2026-09-10-symmetric-message-feedback-submission.md: 51c754dbd3ecc566e15ec1b7a276fc2a3cf7c551 +2026-09-10-symmetric-message-feedback-submission.zh.md: 0ba5d1a1c53f08b76c5bbc32b84687c3dcd38ec2 diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md new file mode 100644 index 0000000000..51c754dbd3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md @@ -0,0 +1,25 @@ +# Agent Note: Symmetric message feedback submission + +Status: implemented + +English | [中文](2026-09-10-symmetric-message-feedback-submission.zh.md) + +## Problem + +The assistant-message rating controls used different commit points. Like recorded a positive rating immediately, while Dislike opened the feedback dialog and recorded only after Submit. The asymmetry made an accidental Like durable before confirmation and prevented positive feedback from carrying the same optional category and description as negative feedback. + +## Decision + +The [feedback dialog and categories](2026-09-08-feedback-dialog-and-categories.md) decision owns the shared form and durable taxonomy. Both unrecorded ratings open the shared feedback dialog and record only after Submit. A message `FeedbackDialogTarget` carries the selected `positive` or `negative` rating, and `FeedbackSurface` passes that rating with the dialog entry to `MessageFeedbackController.rate`. The action row reads the committed item before either action: clicking its current rating calls the injected `retract` operation, while clicking an absent or opposite rating opens the dialog. `retract` rechecks the committed rating inside the controller's serialized mutation queue and becomes a no-op after a concurrent change, so it cannot turn stale UI intent into a bare rating put. The dialog remains optional-input: submitting without a category or description records the selected rating and raises the acknowledgement toast; dismissing it records nothing. + +## Alternatives considered + +**Keep Like as an immediate action.** This preserves one fewer click for positive feedback, but keeps two submission models beside each other and prevents positive reports from carrying context. + +**Require the dialog to retract a recorded rating.** Retraction has no category or description to collect, and an extra confirmation would make the existing undo action less direct. + +**Create separate positive and negative forms.** The fields, validation, failures, and acknowledgement are identical; carrying the rating in the existing target keeps one draft and submission lifecycle. + +## Consequences + +Neither rating creates a `feedback/message-put` event until the user submits the dialog. Positive and negative records can both include a category and note, while clicking the active rating continues to create `feedback/message-delete` without opening the dialog. Unit coverage pins the shared action path and target routing, and the keyless Web scenarios submit both ratings through the real dialog before checking durable events and telemetry release. diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md new file mode 100644 index 0000000000..0ba5d1a1c5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 对称消息反馈提交 + +Status: implemented + +[English](2026-09-10-symmetric-message-feedback-submission.md) | 中文 + +## 问题 + +助手消息的两个评分控件使用不同的提交时点。点赞会立即记录正面评分,点踩则先打开反馈弹窗,仅在用户提交后记录。这种不对称会让误触点赞在确认前就持久化,也让正面反馈无法携带与负面反馈相同的可选分类和描述。 + +## 决策 + +[反馈弹窗与分类](2026-09-08-feedback-dialog-and-categories.zh.md)决策负责共用表单和持久化分类表。两种未记录的评分都打开共用反馈弹窗,并且只在提交后记录。消息 `FeedbackDialogTarget` 携带所选的 `positive` 或 `negative` 评分,`FeedbackSurface` 将该评分与弹窗条目一起传给 `MessageFeedbackController.rate`。动作行会在任一操作前读取已提交条目:点击当前评分会调用注入的 `retract` 操作,点击未记录或相反评分则打开弹窗。`retract` 会在控制器的串行变更队列内重新检查已提交评分,并在并发变更后变为无操作,因此不会把陈旧的 UI 意图转成裸评分 put。弹窗的输入仍可全部留空:不选分类也不填描述时提交会记录所选评分并弹出确认 toast;关闭弹窗不会记录任何内容。 + +## 考虑过的替代方案 + +**保留点赞立即提交。** 这能让正面反馈少一次点击,但会让并列的两个评分继续使用不同提交模式,也无法让正面反馈携带上下文。 + +**撤回已记录评分也必须经过弹窗。** 撤回没有需要收集的分类或描述,多一次确认会让现有撤销操作变得不够直接。 + +**分别创建正面和负面表单。** 两者的字段、校验、失败处理与确认完全相同;在现有目标中携带评分即可共用一套草稿与提交生命周期。 + +## 后果 + +用户提交弹窗前,两种评分都不会创建 `feedback/message-put` 事件。正面与负面记录都可以包含分类和备注,而点击当前评分仍会直接创建 `feedback/message-delete`,无需打开弹窗。单元测试固定共用动作路径与目标路由,无密钥 Web 场景则通过真实弹窗提交两种评分,再检查持久事件与遥测投递。 diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index fdb1330697..4643da9cf8 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -218,12 +218,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const like = page.getByRole('button', { name: 'Good response' }) await like.hover() await like.click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.getByRole('button', { name: 'Instruction understanding and following', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Clear and complete.') + expect(captured()).toHaveLength(releasedCount) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) const rated = page.getByRole('button', { name: 'Remove rating' }) await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true') await expectFeedbackRelease('feedback/message-put', 1) - // Dislike collects the category and note in the dialog; typing releases nothing. + // The second rating uses the same dialog; typing releases nothing. await page.getByRole('button', { name: 'Bad response' }).click() - const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.getByRole('button', { name: 'Task result', exact: true }).click() await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.') expect(captured()).toHaveLength(releasedCount) @@ -242,7 +247,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', { data: { text: 'the second remark' } }, ]) expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([ - { data: { sessionId, item: { rating: 'positive' } } }, + { data: { sessionId, item: { rating: 'positive', note: 'Clear and complete.', category: 'instruction-following' } } }, { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } }, ]) expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }]) @@ -251,7 +256,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', expect(captured()).toHaveLength(releasedCount) const wire = uploads.join('\n') for (const text of ['the diff view is unreadable', 'the second remark', - 'Read both files before answering.']) expect(wire).toContain(text) + 'Clear and complete.', 'Read both files before answering.']) expect(wire).toContain(text) const feedback = events.flatMap>((event) => { switch (event.type) { case 'feedback/record': return [{ type: event.type, text: event.data.text }] diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 8677beb6d9..df89180c18 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -1,8 +1,8 @@ // Keyless browser regression for durable per-message feedback. Cold-seeds a -// settled two-turn transcript (zero model calls), likes one assistant message -// and sees the acknowledgement, replaces the Like through the Dislike dialog -// with a category and a note, proves the judgment survives a full page reload -// from the Host's canonical log, then retracts it. +// settled two-turn transcript (zero model calls), records a Like through the +// feedback dialog, replaces it through the same dialog with a Dislike, proves +// the judgment survives a full page reload from the Host's canonical log, then +// retracts it. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -20,6 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'message-feedback-web-e2e' +const POSITIVE_NOTE = 'Clear and complete.' const NOTE = 'Read both files before answering.' describe('web e2e: durable per-message feedback', () => { @@ -58,7 +59,7 @@ describe('web e2e: durable per-message feedback', () => { await sessionRow.click() } - it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => { + it.skipIf(MODE === 'record')('submits both ratings through the dialog, persists the Dislike, then retracts it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) await openSeededSession() @@ -71,21 +72,29 @@ describe('web e2e: durable per-message feedback', () => { await like.scrollIntoViewIfNeeded() await like.hover() await like.click() - // A Like records at once and is acknowledged; a recorded rating relabels - // the button to what the next click would do. + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Stability and speed', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(POSITIVE_NOTE) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 }) const rated = page.getByRole('button', { name: 'Remove rating' }).first() await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') - // Dislike opens the Session's feedback dialog; its submission replaces - // the Like with a negative judgment carrying the category and note. + // The same dialog records a negative judgment with its own category and + // note, replacing the positive judgment only after submission. await page.getByRole('button', { name: 'Bad response' }).first().click() - const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder')) .toBe('Add details to help us improve. Your submission will include the current conversation log.') await dialog.getByRole('button', { name: 'Task result', exact: true }).click() - await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE) + const details = dialog.getByRole('textbox', { name: 'Feedback details' }) + await details.fill('x'.repeat(8193)) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await page.getByRole('alert').filter({ hasText: 'The description is too long' }).waitFor({ timeout: 10_000 }) + expect(await dialog.count()).toBe(1) + await details.fill(NOTE) await dialog.getByRole('button', { name: 'Submit', exact: true }).click() await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating') @@ -116,9 +125,11 @@ describe('web e2e: durable per-message feedback', () => { await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1) - expect(put?.type === 'feedback/message-put' ? put.data.item : undefined) - .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' }) + const puts = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put') + expect(puts.map(event => event.type === 'feedback/message-put' ? event.data.item : undefined)).toMatchObject([ + { rating: 'positive', note: POSITIVE_NOTE, category: 'service-stability' }, + { rating: 'negative', note: NOTE, category: 'task-result' }, + ]) // Re-clicking the active rating retracts it, and the note goes with it. await restored.click() diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index d1b4bc713c..38e8f16cc9 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -176,6 +176,73 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(await failed.innerText()).toContain('Delivery failed') expect(await delivered.innerText()).toContain('Delivered') await page.locator('[data-turn-process]').click() + const geometry = await page.evaluate(() => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`present layout is missing ${name}`) + return value + } + const answer = requiredElement( + [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .find(element => element.textContent?.includes('PRESENT_DONE')), + 'final answer', + ) + const presentedGrid = requiredElement( + document.querySelector('[data-presented-files-row]'), + 'presented grid', + ) + const presentedRoot = requiredElement(presentedGrid.parentElement, 'presented root') + const turnTail = requiredElement(presentedRoot.closest('[data-turn-tail]'), 'turn tail') + const actions = requiredElement( + turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, + 'action row', + ) + const cards = [...presentedGrid.querySelectorAll('[data-presented-file]')] + const report = requiredElement( + cards.find(card => card.textContent?.includes('report.txt')), + 'report card', + ) + const title = requiredElement( + report.querySelector('span[title="report.txt"]') + ?? [...report.querySelectorAll('span')] + .find(element => element.textContent === 'report.txt'), + 'report title', + ) + const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const open = requiredElement( + report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + 'report open action', + ) + const icon = requiredElement(report.querySelector('svg'), 'report icon') + const secondCard = requiredElement(cards[1], 'second card') + const answerRect = answer.getBoundingClientRect() + const presentedRect = presentedRoot.getBoundingClientRect() + const actionsRect = actions.getBoundingClientRect() + const firstCard = report.getBoundingClientRect() + const secondCardRect = secondCard.getBoundingClientRect() + const gridStyle = getComputedStyle(presentedGrid) + return { + answerToPresented: presentedRect.top - answerRect.bottom, + presentedToActions: actionsRect.top - presentedRect.bottom, + cardHeight: firstCard.height, + cardColumnGap: secondCardRect.left - firstCard.right, + gridColumnGap: gridStyle.columnGap, + gridRowGap: gridStyle.rowGap, + iconWidth: icon.getAttribute('width'), + titleFontSize: getComputedStyle(title).fontSize, + descriptionFontSize: getComputedStyle(description).fontSize, + openFontSize: getComputedStyle(open).fontSize, + } + }) + expect(geometry.answerToPresented).toBeCloseTo(20, 1) + expect(geometry.presentedToActions).toBeCloseTo(20, 1) + expect(geometry.cardHeight).toBeCloseTo(60, 1) + expect(geometry.cardColumnGap).toBeCloseTo(10, 1) + expect(geometry.gridColumnGap).toBe('10px') + expect(geometry.gridRowGap).toBe('10px') + expect(geometry.iconWidth).toBe('20') + expect(geometry.titleFontSize).toBe('13px') + expect(geometry.descriptionFontSize).toBe('10px') + expect(geometry.openFontSize).toBe('12px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 38628b0e8c..8ee9644030 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -171,6 +171,37 @@ describe('web e2e: a finished turn ends with the files it produced', () => { expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) + const turnSpacing = await page.evaluate((done) => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`) + return value + } + const answer = requiredElement( + [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .find(element => element.textContent?.includes(done)), + 'final answer', + ) + const producedRow = requiredElement( + document.querySelector('[data-produced-files-row]'), + 'produced row', + ) + const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root') + const turnTail = requiredElement(producedRoot.closest('[data-turn-tail]'), 'turn tail') + const actions = requiredElement( + turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, + 'action row', + ) + const answerRect = answer.getBoundingClientRect() + const producedRect = producedRoot.getBoundingClientRect() + const actionsRect = actions.getBoundingClientRect() + return { + answerToProduced: producedRect.top - answerRect.bottom, + producedToActions: actionsRect.top - producedRect.bottom, + } + }, DONE) + expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1) + expect(turnSpacing.producedToActions).toBeCloseTo(20, 1) + const tops = await row.locator(':scope > *:visible').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().top)) expect(new Set(tops.map(top => Math.round(top))).size).toBe(1) diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index ba31e1ea1d..621fcf80ff 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.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/feedback.md -feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5 -feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99 +feedback.md: e153047bcc260bcd06847b42eb6b983ac9247d03 +feedback.zh.md: 60263ea4509c0b10ed9f57d13f1564ced1e066c7 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index 2c6728d97a..e153047bcc 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -27,7 +27,7 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion @@ -298,9 +298,9 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows. -One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read. +One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. The injected `retract` operation rechecks the committed rating inside that queue and becomes a no-op after a concurrent change, so stale UI cannot bypass the dialog by recording a bare rating. A `connection/reset` refreshes only Sessions already read. -Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it. +Either unrecorded rating opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts the selected judgment carrying the chosen category and the trimmed description, or neither; success closes the dialog and shows the acknowledgement toast, while failure keeps the dialog and draft open and shows a warning toast. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it without opening the dialog. ## Boundaries and limitations diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index db9f03cb82..60263ea450 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -27,7 +27,7 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion @@ -298,9 +298,9 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。 -每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 +每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。注入的 `retract` 操作会在该队列内重新检查已提交评分,并在并发变更后变为无操作,因此陈旧 UI 无法绕过弹窗记录裸评分。`connection/reset` 只刷新已读取过的 Session。 -点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会撤回它。 +任一未记录的评分都会打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 所选评分,带上所选分类与去除首尾空白的描述,两者也可都不带;成功会关闭弹窗并显示确认 toast,失败则保留弹窗与草稿并显示警告 toast。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会直接撤回,不打开弹窗。 ## 边界与限制 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 2a62fbbd35..ed24447f76 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969 -README.zh.md: 1f895de009e82688ede9b3a83807bae9689ccb80 +README.md: 0894d4e0ef5e45876a58abea40cdad840e7dfacd +README.zh.md: 5604f95acbcac88a157981b936791c9284a858bb diff --git a/packages/client/README.md b/packages/client/README.md index cf4b6aceb3..0894d4e0ef 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | -| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 1f895de009..5604f95acb 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -71,7 +71,7 @@ kind: "package-group" | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | -| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — | diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4db27677d0..525de1dcc3 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 -README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43 +README.md: cbf8fb88670913fac83154f5450e452674b2ed65 +README.zh.md: 9ee73474b2848ef19252bb26c5f0a215e323b413 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 860dcb9eeb..cbf8fb8867 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -16,6 +16,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [System prompt row](#system-prompt-row) - [Turn token usage](#turn-token-usage) +- [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) - [Scroll ownership](#scroll-ownership) - [Model Experience](#model-experience) @@ -34,6 +35,11 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. + +## Completed-turn footer + +The completed-turn action footer starts 20px below the preceding prose or extension content. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4dd06a16b1..9ee73474b2 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -16,6 +16,7 @@ kind: "package-reference" - [系统提示词行](#system-prompt-row) - [轮次 token 用量](#turn-token-usage) +- [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) - [滚动归属](#scroll-ownership) - [模型体验](#model-experience) @@ -36,6 +37,11 @@ kind: "package-reference" 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成 Turn 才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 + +## 已完成轮次的页脚 + +已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。 + ----- diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css index 831e6e212b..bfa386d31c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css @@ -5,5 +5,8 @@ } .actions { + /* With tail content, this adds to the root gap; without it, the Chat flow's + preceding-item offset supplies the other 16px. */ + margin-top: 4px; margin-left: -6px; } diff --git a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts new file mode 100644 index 0000000000..1955fd41cd --- /dev/null +++ b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts @@ -0,0 +1,16 @@ +/** CSS checks for the completed-turn footer's 20px content spacing. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const read = (name: string): string => + readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8') + +describe('completed-turn spacing', () => { + it('combines the flow and footer offsets around turn-tail content', () => { + expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 16px\)/) + const tail = read('TurnTailNodeView.module.css') + expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s) + expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s) + }) +}) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 74d4c89ee7..d044cf5fea 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4 -README.zh.md: 8e801a164c8a60de4aea8ff6a224e4823e2d3ccc +README.md: aaf18126e2ebbc0beabe7781b0c5ad30049769ee +README.zh.md: 445401890639c936d4300f85ff9a44b4fd8236d1 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 857b71c5e0..aaf18126e2 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,13 +30,13 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row -The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. +The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the row, and the action footer starts 20px below the last file section. The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. ### Inline-code links diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8e801a164c..4454018906 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,13 +30,13 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 -“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 +“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 ### 行内代码链接 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 5125edd381..c78725bdcb 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,27 +1,30 @@ /** Delivery cards use one full-width row or a two-column, four-card summary. */ -.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 16px; } +.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; } +/* The turn-tail gap alone separates adjacent file sections. The default top + margin remains when deliveries are the first file section after prose. */ +.root[data-after-produced-files='true'] { margin-top: 0; } :global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } -.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; min-width: 0; } +.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; } .presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); } -.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 72px; padding: 12px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } +.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 60px; padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } .file:hover { background: var(--deliverable-hover); } .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } -.fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } +.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } -.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } -.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .previewHint { display: none; } .file:hover .secondaryText { display: none; } .file:hover .previewHint { display: inline; } -.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 32px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-button-floating-fill); } +.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); } .menuAnchor { align-self: stretch; } .open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); } -.open { padding: 5px 10px; font-size: 14px; line-height: 22px; } -.chevron { padding: 5px 6px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } +.open { padding: 4px 8px; font-size: 12px; line-height: 18px; } +.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } .open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } .chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; } .menuActionIcon { display: block; width: 16px; height: 16px; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index dac421a577..6099cfdf72 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -58,7 +58,10 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope }, [matched.presented.length, host, reloadPresentedHost]) return <> {matched.produced.length > 0 && } - {matched.presented.length > 0 &&
+ {matched.presented.length > 0 &&
0 || undefined} + > {host === 'error' &&
{t('presented.hostError')} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index ef848827e8..5466473999 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -51,7 +51,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, return