Merge pull request #6625 from deepseek-harness/xtr/session-projection-runtime-compat

fix(session-projection): 保留旧插件的运行时兼容调用
This commit is contained in:
imccyu authored and GitHub committed 2026-10-20 01:47:15 +08:00
commit d07ee15204
26 files changed
+656 -81

No files matched your search

@@ -9,11 +9,11 @@
en: 47ba548b869be190
zh: c2e5038a56fe741c
/agent-note-asynchronous-projection-registration-and-retained-readers/decision:
en: 9e56d4bcbdc3a291
zh: bebaaab338a15e40
en: be0d17bfbd1b59d4
zh: 7a23bccdf95c21ff
/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered:
en: ee5b0cd715ed1fef
zh: 6b1478dcde21916d
en: e7c66403fab991d2
zh: 1580ec29116c7d42
/agent-note-asynchronous-projection-registration-and-retained-readers/consequences:
en: 6f0e04ef7924e2c6
zh: e3730273e323440d
en: 31ce6f02dd3bf39d
zh: bcd29b2146f689b5
@@ -10,12 +10,14 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a
## Decision
`SessionProjectionRegistry.register` returns a promise for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
`SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state.
Plugins compiled against the synchronous API keep running during migration. The awaitable registration is also the original Cordis effect disposer, so calling it releases the registration and a composing effect adopts its ownership; that call is deprecated. Registry-level `stateOf(session, key)` remains a deprecated read returning `unknown`; it loses access after the final release and logs one `ctx.logger.warn` per key per registry. Registration itself does not warn, because an ignored result is indistinguishable from one awaited later. A legacy plugin may never observe the registration, so the registry attaches its own rejection handler: once the current macrotask and its microtasks finish, a rejection nobody observed through `then`, `catch`, or `finally` is logged as an error while the plugin stays active, and one whose owner is no longer loading or active is dropped. TypeScript consumers migrate their declarations; the host state map is not restored.
## Alternatives considered
**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order.
@@ -26,6 +28,12 @@ AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. Ag
**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break.
**Require every external plugin to migrate immediately.** Compiled plugins fail at runtime on the missing disposer and keyed read, although the projection keys and state fields they read are unchanged.
**Return a new wrapper around the disposer.** Cordis composition adopts effects by their returned identity. A wrapper leaves the original registration owned by the fiber, so owner disposal can release it before the composed cleanup finishes.
**Dispose the plugin after an ignored rejection.** This approximates the old failed activation, but it needs a startup observer, runs only after the plugin's services became visible, and ends in `DISPOSED` rather than a retryable `FAILED`. Logging keeps the failure visible without a second lifecycle path.
## Consequences
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown.
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls.
@@ -10,12 +10,14 @@ Status: implemented
## Decision
`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
`SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。
按同步 API 编译的插件在迁移期间继续运行。可等待的注册结果同时就是原有的 Cordis effect 释放函数:调用它会释放注册,组合 effect 也会接管其所有权;该调用已弃用。注册表级 `stateOf(session, key)` 作为已弃用的读取保留,返回 `unknown`;最后一次释放后它失去访问,并且每个 registry 对每个键输出一次 `ctx.logger.warn`。注册本身不产生警告,因为被忽略的结果与稍后才等待的结果无法区分。旧插件可能从不观察注册结果,因此 registry 挂上自己的拒绝处理函数:当前宏任务及其微任务结束时,没有任何调用方通过 `then`、`catch` 或 `finally` 观察的拒绝会被记录为错误,插件保持激活;owner 已不在加载或激活状态的拒绝会被丢弃。TypeScript 消费方仍需迁移声明;宿主状态映射不再恢复。
## Alternatives considered
**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。
@@ -26,6 +28,12 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`
**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。
**要求所有外部插件立即迁移。** 已编译的插件会因缺失的释放函数和按键读取在运行时失败,尽管它们读取的投影键与状态字段都没有变化。
**返回包装释放函数的新函数。** Cordis 组合按返回值身份接管 effect。包装函数会让原注册继续归 fiber 所有,因此 owner 卸载可能在组合清理结束前释放它。
**被忽略的拒绝出现后卸载插件。** 这近似于旧 API 的激活失败,但需要启动观察器,只能在插件服务已经可见后执行,并以 `DISPOSED` 而非可重试的 `FAILED` 结束。记录日志能让失败可见,而无需第二条生命周期路径。
## Consequences
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
/session-projections:
en: e9330151d3eb579a
zh: 53e47acd1d810da9
en: 45e5f8cb8ca18d6b
zh: 4ecfad40ec9e221c
/session-projections/the-unit:
en: d48814d3ab0e997f
zh: d0337d8eba097a63
+4 -2
View File
@@ -26,6 +26,8 @@ interface ProjectionHandle<S> {
}
```
The [compatibility rules](../../packages/session/session-projection/README.md#deprecated-plugin-compatibility) define legacy disposal, keyed reads, and their once-per-key deprecation warnings.
## The unit
`ProjectionDefinition<K, S>` carries each unit's host state type locally; registration infers `S` from `stateSchema` and returns `ProjectionHandle<S>`. Only client-visible keys merge into `SessionProjectionMap`, which types their whole values. A `wire` block makes a unit client-visible, and rendering belongs to the slot system, never this layer:
@@ -314,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
* Retained handles remain readable after release.
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns a ready handle with synchronous state reads and the effect disposer.
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
*/
register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): Promise<ProjectionHandle<S>>
@@ -323,7 +325,7 @@ register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDe
* and always checkpointed like every other unit.
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns a ready handle with synchronous state reads and the effect disposer.
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
*/
register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> }, ): Promise<ProjectionHandle<S>>
+4 -2
View File
@@ -26,6 +26,8 @@ interface ProjectionHandle<S> {
}
```
[兼容规则](../../packages/session/session-projection/README.zh.md#deprecated-plugin-compatibility)规定旧释放操作、按键读取及其每个键一次的弃用警告。
## 投影单元
`ProjectionDefinition<K, S>` 在各单元内部携带宿主状态类型;注册从 `stateSchema` 推导 `S`,并返回 `ProjectionHandle<S>`。只有客户端可见的 key 合并到 `SessionProjectionMap`,由它约束对应的全量值。`wire` 块使单元对客户端可见,渲染归 slot 体系管,永远不归本层:
@@ -314,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
* Retained handles remain readable after release.
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns a ready handle with synchronous state reads and the effect disposer.
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
*/
register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): Promise<ProjectionHandle<S>>
@@ -323,7 +325,7 @@ register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDe
* and always checkpointed like every other unit.
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns a ready handle with synchronous state reads and the effect disposer.
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
*/
register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> }, ): Promise<ProjectionHandle<S>>
@@ -1,21 +0,0 @@
---
kind: upgrade-guide
description: "Session projection registration is asynchronous and returns a retained reader instead of a disposer."
---
# Session projection registration handles
English | [中文](guide.zh.md)
## Change
`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise<ProjectionHandle<S>>`. Registry-level `stateOf(session, key)` and `SessionProjectionStateMap` are removed. Host state types stay local to `ProjectionDefinition<K, S>` and are inferred from `stateSchema` during registration. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand.
`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state; consumers use `ctx.agents.turnBoundaryOf(session)` instead of importing `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` also removes `installModelSelectionProjection`.
## Migration
1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`. Consumers of domain data inject the owning service.
2. Remove host-state map augmentations and imports; pass the local state type as the second `ProjectionDefinition<K, S>` argument. Keep client-value declarations in `SessionProjectionMap`. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle privately before publishing dependent operations. Keep existing function-plugin namespace exports.
3. Replace `registry.stateOf(session, key)` with a business service method for another domain, or `handle.stateOf(session)` inside the owner. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise<void>`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures.
4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`.
5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone.
@@ -1,21 +0,0 @@
---
kind: upgrade-guide
description: "Session 投影注册改为异步,并返回保留读取能力的句柄而不是释放函数。"
---
# Session 投影注册句柄
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise<ProjectionHandle<S>>`。注册表级 `stateOf(session, key)` 与 `SessionProjectionStateMap` 已移除。宿主状态类型保留在各自的 `ProjectionDefinition<K, S>` 中,注册时从 `stateSchema` 推导。注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。
`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态;消费方使用 `ctx.agents.turnBoundaryOf(session)`,不再导入 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 同时移除了 `installModelSelectionProjection`。
## 迁移
1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。领域数据的消费方注入所属服务。
2. 删除宿主状态映射的声明合并与导入,将局部状态类型作为 `ProjectionDefinition<K, S>` 的第二个类型参数。保留 `SessionProjectionMap` 中的客户端值声明。对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前于内部保存句柄。保留现有函数插件命名空间导出。
3. 将 `registry.stateOf(session, key)` 替换为其他领域的业务服务方法,或所有者内部的 `handle.stateOf(session)`。将保存的取消注册回调替换为始终返回 `Promise<void>` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动释放注册。处理初始化失败时须等待注册。
4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。
5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。
@@ -1,16 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.3/session-projection-handles/guide.md
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/session-projection-handles/guide.md
/:
en: b3f54a7eadb3398a
zh: 6815b996b2cebc6f
en: a7c158942ce2a575
zh: 7103da2fa7ca494a
/session-projection-registration-handles:
en: 5cf8cb0051860df5
zh: 2578c6c3e77bc1c7
en: 08af78abac6d3bcd
zh: 85ede5596cac9d5e
/session-projection-registration-handles/change:
en: 64c2492c43c7a263
zh: ba5a15b959df496c
en: d5e6ea606de638a0
zh: a041eee1ffd48ac3
/session-projection-registration-handles/migration:
en: bd4310bb946b6f08
zh: 2086e6e9c11bc4ed
en: 9c7d73f9f50c4bdd
zh: b1c597c63d52b444
@@ -0,0 +1,24 @@
---
kind: upgrade-guide
description: "Session projection registration resolves asynchronously to a retained reader; the callable result and registry-level state reads are deprecated."
---
# Session projection registration handles
English | [中文](guide.zh.md)
## Change
`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise<ProjectionHandle<S>>`. The definition installs synchronously; awaiting the result yields a handle with `stateOf(session)` and `dispose()`, and registration errors reject it. The runtime value is a callable thenable, not a native Promise: calling it still releases the registration, but that call is deprecated. `typeof registration` is `"function"` and `registration instanceof Promise` is false, so code that branches on those checks or treats functions as callbacks is affected; Vitest's `.rejects` calls it instead of observing the rejection.
Registry-level `stateOf(session, key): unknown` is deprecated. It returns `undefined` whenever no plugin currently registers the key: its owner is not composed, has not reached registration, or has released its last registration. `SessionProjectionStateMap` is removed; host state types stay local to `ProjectionDefinition<K, S>` and are inferred from `stateSchema`. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles still fold Session events on demand. [Runtime compatibility](../../../../packages/session/session-projection/README.md#deprecated-plugin-compatibility) details the deprecated calls.
`@deepseek-ai/dsh-agent` and `@deepseek-ai/dsh-subagent` require `sessionProjections`. AgentRegistry owns turn and step state and provides `ctx.agents.turnBoundaryOf(session)`; `@deepseek-ai/dsh-agent-loop` no longer exports `turnBoundaryProjectionDefinition`. Turn-state consumers, including the Claude Code mods bridge, require `agents`. Preset and context-pressure reads use `ctx.agentPresets.presetForSession(session)` and `ctx.tokenMeter.contextPressure(session)`. The API controller and mods bridge no longer register those definitions independently; their optional-service behavior applies when the owning service is absent. `@deepseek-ai/dsh-api-session-controller` removes `installModelSelectionProjection`.
## Migration
1. Mount `SessionProjectionRegistry` before `AgentRegistry`, `SubagentRuntime`, and other projection owners in custom compositions. Include `@deepseek-ai/dsh-agent` wherever a plugin requires `agents`.
2. For a plugin's own definitions, await `ctx.sessionProjections.register(definition)` in asynchronous `apply()` or `[Service.init]()` and retain the handle before publishing dependent operations. Remove `SessionProjectionStateMap` augmentations and pass the state type as the second `ProjectionDefinition<K, S>` argument. Keep client-value declarations in `SessionProjectionMap`.
3. Replace `registry.stateOf(session, key)` with the owning service's method, such as `ctx.agents.turnBoundaryOf(session)`, or `handle.stateOf(session)` inside the owner; until then, handle its `undefined` result. Replace calls to the registration result with an awaited `handle.dispose()`; fiber disposal still releases registrations automatically.
4. Where a native Promise is required, pass `Promise.resolve(registration)`, including `await expect(Promise.resolve(registration)).rejects.toThrow(...)` in Vitest. Replace `typeof` or `instanceof Promise` checks on the result with awaiting.
5. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`.
6. Typecheck custom plugins, then verify activation, a Session state read, a rejected registration, and owner disposal.
@@ -0,0 +1,24 @@
---
kind: upgrade-guide
description: "Session 投影注册异步解析为保留读取能力的句柄;可调用的注册结果与注册表级状态读取已弃用。"
---
# Session 投影注册句柄
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为 `Promise<ProjectionHandle<S>>`。定义同步安装;等待结果得到带 `stateOf(session)` 与 `dispose()` 的句柄,注册错误使结果拒绝。运行时返回值是可调用的 thenable,而非原生 Promise:调用它仍会释放注册,但该调用已弃用。`typeof registration` 为 `"function"`,`registration instanceof Promise` 为 false,因此按这些检查分支或把函数视为回调的代码会受影响;Vitest 的 `.rejects` 会调用它,而不是观察拒绝。
注册表级 `stateOf(session, key): unknown` 已弃用。当前没有任何插件注册该 key 时它返回 `undefined`:所属插件未组合、尚未执行到注册,或已释放最后一次注册。`SessionProjectionStateMap` 已移除;宿主状态类型保留在各自的 `ProjectionDefinition<K, S>` 中,并从 `stateSchema` 推导。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。[运行时兼容说明](../../../../packages/session/session-projection/README.zh.md#deprecated-plugin-compatibility)详述这些已弃用调用。
`@deepseek-ai/dsh-agent` 与 `@deepseek-ai/dsh-subagent` 依赖 `sessionProjections`。AgentRegistry 持有轮次和步骤状态,并提供 `ctx.agents.turnBoundaryOf(session)`;`@deepseek-ai/dsh-agent-loop` 不再导出 `turnBoundaryProjectionDefinition`。包括 Claude Code mods 桥接在内的轮次状态消费方依赖 `agents`。预设与上下文压力分别通过 `ctx.agentPresets.presetForSession(session)` 和 `ctx.tokenMeter.contextPressure(session)` 读取。API 控制器与 mods 桥接不再独立注册这些定义;所属服务缺席时采用各自的可选服务行为。`@deepseek-ai/dsh-api-session-controller` 移除了 `installModelSelectionProjection`。
## 迁移
1. 在自定义组合中,先挂载 `SessionProjectionRegistry`,再挂载 `AgentRegistry`、`SubagentRuntime` 及其他投影所有者。组合中的插件依赖 `agents` 时,加入 `@deepseek-ai/dsh-agent`。
2. 对插件自己的定义,在异步 `apply()` 或 `[Service.init]()` 中等待 `ctx.sessionProjections.register(definition)`,并在提供依赖操作前保存句柄。删除 `SessionProjectionStateMap` 的声明合并,将状态类型作为 `ProjectionDefinition<K, S>` 的第二个类型参数。保留 `SessionProjectionMap` 中的客户端值声明。
3. 将 `registry.stateOf(session, key)` 替换为所属服务的方法(例如 `ctx.agents.turnBoundaryOf(session)`),或所有者内部的 `handle.stateOf(session)`;替换之前须处理其 `undefined` 结果。将对注册结果的调用替换为等待 `handle.dispose()`;fiber 释放仍会自动释放注册。
4. 需要原生 Promise 时传入 `Promise.resolve(registration)`,包括在 Vitest 中写 `await expect(Promise.resolve(registration)).rejects.toThrow(...)`。将对结果的 `typeof` 或 `instanceof Promise` 检查改为直接等待。
5. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。
6. 对自定义插件执行类型检查,再验证激活、Session 状态读取、注册被拒绝以及 owner 释放。
@@ -2452,13 +2452,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, \'wire\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> }, ): Promise<ProjectionHandle<S>>',
description: 'Register one domain\'s unit on the calling context\'s fiber. Fiber or handle disposal releases one reference; the final release removes live participation. Retained handles remain readable after release. Callers must await registration before publishing consumers; registration may perform asynchronous work.',
parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
returns: 'a ready handle with synchronous state reads and the effect disposer.',
returns: 'an awaitable yielding a ready handle; the callable runtime value is deprecated.',
},
{
signature: 'register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> }, ): Promise<ProjectionHandle<S>>',
description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit. Callers must await registration before publishing consumers; registration may perform asynchronous work.',
parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
returns: 'a ready handle with synchronous state reads and the effect disposer.',
returns: 'an awaitable yielding a ready handle; the callable runtime value is deprecated.',
},
{
signature: 'onChanged(listener: ProjectionChangeListener): () => void',
@@ -24,8 +24,11 @@
en: 09758d1475fad86e
zh: 473637cb34aebe72
/deepseek-ai-dsh-session-projection/use-this-package/register-and-read:
en: b75f8f00df2933e1
zh: c53e64518953110b
en: 976fb019501a941b
zh: e15038a7205337c3
/deepseek-ai-dsh-session-projection/use-this-package/deprecated-plugin-compatibility:
en: eed1ad9d62755251
zh: 88a07c787528009a
/deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints:
en: 3bbc61a438aeabd0
zh: bd1824f4da278764
@@ -65,6 +65,19 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
A domain that owns projected state declares `sessionProjections` as a Cordis service dependency and keeps its handle private. Consumers inject that domain service and use its methods, such as `ctx.agents.turnBoundaryOf(session)`, without importing definitions or retaining projection readers. Optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent.
<a id="deprecated-plugin-compatibility"></a>
### Deprecated plugin compatibility
`register(definition)` keeps its public return type `Promise<ProjectionHandle<S>>`, but the runtime value is a callable thenable, not a native `Promise` (`typeof` is `"function"`; `instanceof Promise` is false). Awaiting it delivers the retained handle; calling it releases the registration through the original Cordis effect disposer. Calling the return value is deprecated; use `handle.dispose()` in new code. Use `Promise.resolve(registration)` when an API requires a native Promise or treats functions as callbacks, including Vitest Promise assertions. See the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-rc.2/session-projection-handles/guide.md).
Legacy synchronous `return register(...)`, `ctx.effect(() => register(...))`, and synchronous generator yields retain cleanup ownership. Returning the registration from an async function assimilates the thenable into a handle, which Cordis rejects as `Invalid effect`. Yielding it from an async generator also yields the handle: that effect fails with `Invalid effect` when disposed and never adopts the registration, while the plugin stays active and its fiber releases the registration on unload. Async plugins and effects await registration and return `void`; the owning fiber releases the registration automatically.
Legacy `stateOf(session, key)` is deprecated and returns `unknown`. Reading a registered key materializes every registered unit at the Session cursor without producing unrelated wire views. An unregistered or fully released key returns `undefined`, while retained handles remain readable. Compatibility covers runtime calls; TypeScript consumers migrate their type declarations.
The first legacy `stateOf(session, key)` read of each key logs one `ctx.logger.warn` deprecation warning per registry instance. Registration, calls to its result, handle methods, and owner cleanup do not warn.
Definitions install synchronously, and handle delivery remains asynchronous. Registration errors reject the returned value. When no caller observes a rejection before the current macrotask and its microtasks finish, the registry logs it as an error instead of leaving an unhandled rejection, and the plugin stays active; after a key conflict, its keyed reads return the other registrant's state; a rejection whose owner is no longer loading or active is dropped. New plugins await registration before exposing readers. Synchronous `try/catch` cannot catch asynchronous registration errors.
### Persisted checkpoints
Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap.
@@ -65,6 +65,19 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
持有投影状态的领域将 `sessionProjections` 声明为 Cordis 服务依赖,并在内部保留句柄。消费方注入该领域服务并使用其方法,例如 `ctx.agents.turnBoundaryOf(session)`,无需导入定义或保留投影读取句柄。可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。
<a id="deprecated-plugin-compatibility"></a>
### 已弃用的插件兼容接口
`register(definition)` 的公开返回类型保持为 `Promise<ProjectionHandle<S>>`,但运行时值是可调用的 thenable,并非原生 `Promise`(`typeof` 为 `"function"`,`instanceof Promise` 为 false)。等待它会得到保留的句柄,调用它则通过原有 Cordis effect disposer 释放注册。调用返回值的用法已弃用;新代码使用 `handle.dispose()`。API 要求原生 Promise 或把函数视为回调时,包括 Vitest 的 Promise 断言,使用 `Promise.resolve(registration)`。参见[升级指南](../../../docs/upgrade-guide/v0.2.1-rc.2/session-projection-handles/guide.zh.md)。
旧的同步 `return register(...)`、`ctx.effect(() => register(...))` 和同步生成器 yield 保留原有清理所有权。通过异步函数返回注册值时,thenable 会被吸收为句柄,Cordis 会以 `Invalid effect` 拒绝它。在异步生成器中 yield 注册值同样只会得到句柄:该 effect 在释放时以 `Invalid effect` 失败,且从不接管该注册;插件保持激活,其 fiber 在卸载时释放该注册。异步插件和 effect 等待注册完成后返回 `void`;所属 fiber 会自动释放注册。
旧的 `stateOf(session, key)` 已弃用,返回类型为 `unknown`。读取已注册的键时,它会将所有已注册单元的状态计算到 Session 当前游标,不生成无关的客户端视图。未注册或已完全释放的键返回 `undefined`,保留的句柄则仍可读取。兼容范围限于运行时调用;TypeScript 消费方仍需迁移类型声明。
每个 registry 实例对每个键的首次旧式 `stateOf(session, key)` 读取输出一次 `ctx.logger.warn` 弃用警告。注册、调用其返回值、句柄方法和 owner 清理不产生警告。
定义同步安装,句柄交付保持异步。注册错误使返回值拒绝。当前宏任务及其微任务结束前没有调用方观察该拒绝时,registry 将其记录为错误,而不留下未处理的拒绝,插件保持激活;发生键冲突后,其按键读取返回另一注册方的状态;owner 已不在加载或激活状态的拒绝会被丢弃。新插件在提供读取操作前等待注册完成。同步 `try/catch` 无法捕获异步注册错误。
### 持久检查点
系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。
@@ -41,6 +41,8 @@
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-loader-smoke": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
}
@@ -17,7 +17,7 @@
* @module @deepseek-ai/dsh-session-projection
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { Context, FiberState, Service } from '@deepseek-ai/cordis'
import type { ZodType } from 'zod'
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type {
@@ -254,6 +254,8 @@ export class SessionProjectionRegistry extends Service {
/** Stable summary key set; dropped whenever the key table changes. */
private summaryKeySet: ReadonlySet<Extract<keyof SessionProjectionMap, string>> | undefined
private readonly listeners = new Set<ProjectionChangeListener>()
/** Keys whose deprecated registry-level read has already been reported. */
private readonly deprecatedStateOfKeys = new Set<string>()
/**
* Create and install the registry as `ctx.sessionProjections`.
@@ -283,7 +285,7 @@ export class SessionProjectionRegistry extends Service {
* Retained handles remain readable after release.
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns a ready handle with synchronous state reads and the effect disposer.
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
*/
register<K extends keyof SessionProjectionMap, S>(
definition: Omit<ProjectionDefinition<K, S>, 'wire'> & {
@@ -295,13 +297,56 @@ export class SessionProjectionRegistry extends Service {
* and always checkpointed like every other unit.
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns a ready handle with synchronous state reads and the effect disposer.
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
*/
register<K extends string, S>(
definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> },
): Promise<ProjectionHandle<S>>
async register<K extends string, S>(
/** Legacy runtime behavior: [plugin compatibility](../README.md#deprecated-plugin-compatibility). */
register<K extends string, S>(
definition: ProjectionDefinition<K, S>,
): Promise<ProjectionHandle<S>> {
const owner = this.ctx
// Replaced by the effect disposer unless registration fails before its effect exists.
let dispose: () => Promise<void> = () => Promise.resolve()
const ready = this.acquire(definition, (release) => { dispose = release })
let observed = false
// A legacy caller may never observe the result. Report its rejection instead of leaving an
// unhandled rejection once the current macrotask and its microtasks have had a chance to
// observe it; an owner that is no longer loading or active cannot use the handle, so its
// rejection is dropped.
void ready.catch((error: unknown) => {
setImmediate(() => {
if (observed) return
if (owner.fiber.state !== FiberState.LOADING && owner.fiber.state !== FiberState.ACTIVE) return
owner.logger.error(error)
})
})
const promise: Promise<ProjectionHandle<S>> = {
then(onFulfilled, onRejected) {
observed = true
return ready.then(onFulfilled, onRejected)
},
catch(onRejected) {
observed = true
return ready.catch(onRejected)
},
finally(onFinally) {
observed = true
return ready.finally(onFinally)
},
[Symbol.toStringTag]: 'Promise',
}
// Return the effect disposer itself, as the synchronous API did, so a composing effect adopts it.
// The assigned then() replaces the disposer's own awaitable disposal: awaiting yields the handle.
// The toStringTag completes the Promise members, so Object.prototype.toString reports a Promise.
return Object.assign(dispose, promise)
}
/** Install one unit; `onEffect` receives its release before the asynchronous handoff. */
private async acquire<K extends string, S>(
definition: ProjectionDefinition<K, S>,
onEffect: (release: () => Promise<void>) => void,
): Promise<ProjectionHandle<S>> {
const wire = definition.wire as {
viewSchema: ZodType
@@ -351,6 +396,7 @@ export class SessionProjectionRegistry extends Service {
}
}
}, 'sessionProjections.register()')
onEffect(dispose)
// Observe disposal during the asynchronous registration handoff before exposing a reader.
await Promise.resolve()
this.ctx.fiber.assertActive()
@@ -364,6 +410,27 @@ export class SessionProjectionRegistry extends Service {
}
}
/**
* Read a currently registered unit after materializing every registered
* unit at the Session cursor. Unrelated wire views are not produced.
* The returned state is live; do not mutate it. A released key has no value.
* The first read of each key logs one deprecation warning per registry.
* @param session - Session whose committed events are folded.
* @param key - key of the registered projection.
* @returns the current state, or `undefined` when the unit is not registered.
* @deprecated Use an awaited registration's `handle.stateOf(session)` or the owning domain service.
*/
stateOf(session: Session, key: string): unknown {
if (!this.deprecatedStateOfKeys.has(key)) {
this.deprecatedStateOfKeys.add(key)
this.ctx.logger.warn(`sessionProjections.stateOf(session, ${JSON.stringify(key)}) is deprecated; read handle.stateOf(session) from an awaited registration, or use the owning domain service`)
}
const registration = this.registrations.get(key)
if (registration === undefined) return undefined
this.materializeCells(session)
return this.cellFor(registration, session).state
}
/**
* Subscribe to the change feed. The registration is an effect on the
* calling context's fiber.
@@ -0,0 +1,341 @@
/** Legacy projection calls keep their effect ownership while new callers await retained readers. */
/* oxlint-disable typescript/no-deprecated -- exercise the public compatibility API. */
import { setImmediate } from 'node:timers/promises'
import { Context, FiberState } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { z } from 'zod'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'test/legacy': number
'test/legacy-unread-wire': number
}
}
const definition = {
key: 'test/legacy',
stateSchema: z.number(),
stateVersion: 1,
init: () => 0,
apply: (state: number) => state + 1,
wire: { viewSchema: z.number(), view: (state: number) => state },
} satisfies ProjectionDefinition<'test/legacy', number>
async function setup() {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warning.mockRestore() })
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
return { ctx, warning, session: ctx.sessions.create() }
}
/** Exercise calls emitted by plugins compiled against the old disposer return type. */
function legacyRegistration<T>(registration: Promise<T>): Promise<T> & (() => Promise<void>) {
expect(registration).toBeTypeOf('function')
return registration as Promise<T> & (() => Promise<void>)
}
describe('projection registration compatibility', () => {
it('awaits modern registration, keeps Promise chaining, and never warns for handle cleanup', async () => {
const { ctx, warning, session } = await setup()
const settled = vi.fn()
const owner = await ctx.plugin({
inject: ['sessionProjections'],
async apply(inner) {
const registration = inner.sessionProjections.register(definition)
expect(registration).not.toBeInstanceOf(Promise)
expect(Promise.resolve(registration)).toBeInstanceOf(Promise)
const handle = await registration.then(value => value).catch(() => { throw new Error('unexpected rejection') })
expect(await registration.finally(settled)).toBe(handle)
expect(handle.stateOf(session)).toBe(0)
await handle.dispose()
session.append('turn/start', { turn: 1 })
expect(handle.stateOf(session)).toBe(1)
},
})
expect(settled).toHaveBeenCalledOnce()
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
await owner.dispose()
expect(warning).not.toHaveBeenCalled()
})
it('lets an asynchronous plugin await registration later in its initialization', async () => {
const { ctx, warning } = await setup()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
onTestFinished(() => { release.resolve(undefined) })
const owner = ctx.plugin({
inject: ['sessionProjections'],
async apply(inner) {
const registration = inner.sessionProjections.register(definition)
entered.resolve(undefined)
await release.promise
await registration
},
})
await entered.promise
release.resolve(undefined)
await owner
expect(warning).not.toHaveBeenCalled()
})
it('loads an unawaited legacy registration and warns once per key for keyed reads', async () => {
const { ctx, warning, session } = await setup()
const mount = () => ctx.plugin({
inject: ['sessionProjections'],
apply(inner) {
void inner.sessionProjections.register(definition)
},
})
const first = await mount()
const second = await mount()
session.append('turn/start', { turn: 1 })
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(1)
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(1)
expect(ctx.sessionProjections.stateOf(session, 'test/missing-legacy')).toBeUndefined()
expect(warning.mock.calls).toEqual([
['sessionProjections.stateOf(session, "test/legacy") is deprecated; read handle.stateOf(session) from an awaited registration, or use the owning domain service'],
['sessionProjections.stateOf(session, "test/missing-legacy") is deprecated; read handle.stateOf(session) from an awaited registration, or use the owning domain service'],
])
await first.dispose()
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(1)
await second.dispose()
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBeUndefined()
expect(warning).toHaveBeenCalledTimes(2)
})
it('materializes every registered unit on a legacy keyed read without producing unrelated wire views', async () => {
const { ctx, session } = await setup()
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const unrelatedView = vi.fn((state: number) => state)
const hostInit = vi.fn(() => 0)
await ctx.sessionProjections.register(definition)
await ctx.sessionProjections.register({
...definition,
key: 'test/legacy-unread-wire',
wire: { viewSchema: z.number(), view: unrelatedView },
})
await ctx.sessionProjections.register({
key: 'test/legacy-unread-host',
stateSchema: definition.stateSchema,
stateVersion: definition.stateVersion,
init: hostInit,
apply: definition.apply,
})
expect(ctx.sessionProjections.stateOf(session, 'test/missing-legacy')).toBeUndefined()
expect(ctx.sessionProjections.cachedSnapshot(session)).toBeUndefined()
expect(hostInit).not.toHaveBeenCalled()
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(2)
expect(unrelatedView).not.toHaveBeenCalled()
expect(ctx.sessionProjections.cachedSnapshot(session)).toEqual({
asOfSeq: 1,
values: { 'test/legacy': 2, 'test/legacy-unread-wire': 2 },
})
expect(hostInit).toHaveBeenCalledOnce()
})
it('keeps callable early disposal idempotent without invalidating an awaited reader', async () => {
const { ctx, warning, session } = await setup()
const registration = legacyRegistration(ctx.sessionProjections.register(definition))
const handle = await registration
await registration()
await registration()
await handle.dispose()
session.append('turn/start', { turn: 1 })
expect(handle.stateOf(session)).toBe(1)
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(warning).not.toHaveBeenCalled()
})
it('releases a registration called before the asynchronous handle handoff', async () => {
const { ctx, warning, session } = await setup()
const registration = legacyRegistration(ctx.sessionProjections.register(definition))
await registration()
const handle = await registration
expect(handle.stateOf(session)).toBe(0)
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(warning).not.toHaveBeenCalled()
})
it.each(['return', 'effect', 'generator'] as const)('unloads a legacy %s registration', async (style) => {
const { ctx, warning, session } = await setup()
const owner = await ctx.plugin({
inject: ['sessionProjections'],
apply(inner) {
if (style === 'return') return legacyRegistration(inner.sessionProjections.register(definition))
if (style === 'effect') return void inner.effect(() => legacyRegistration(inner.sessionProjections.register(definition)))
inner.effect(function* () { yield legacyRegistration(inner.sessionProjections.register(definition)) })
},
})
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/legacy': 0 })
await owner.dispose()
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(warning).not.toHaveBeenCalled()
})
it('rejects an async plugin that returns the legacy registration as a disposer', async () => {
const { ctx, warning, session } = await setup()
const errorLog = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
onTestFinished(() => { errorLog.mockRestore() })
const owner = ctx.plugin({
inject: ['sessionProjections'],
async apply(inner) {
// Compiled legacy callers see a disposer type; async return still assimilates its runtime then().
return legacyRegistration(inner.sessionProjections.register(definition)) as () => Promise<void>
},
})
await expect(owner.await()).rejects.toThrow('Invalid effect')
expect(owner.state).toBe(FiberState.FAILED)
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(errorLog).toHaveBeenCalledOnce()
expect(warning).not.toHaveBeenCalled()
})
it('leaves an async generator yield owned by the plugin instead of the composed effect', async () => {
const { ctx, session } = await setup()
let disposeEffect!: () => Promise<void>
const owner = await ctx.plugin({
inject: ['sessionProjections'],
apply(inner) {
disposeEffect = inner.effect(async function* () {
// Compiled legacy callers see a disposer type; the async yield still assimilates its runtime then().
yield legacyRegistration(inner.sessionProjections.register(definition)) as () => Promise<void>
})
},
})
expect(owner.state).toBe(FiberState.ACTIVE)
await expect(disposeEffect()).rejects.toThrow('Invalid effect')
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/legacy': 0 })
await owner.dispose()
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
})
it('transfers the original disposer into a composed effect and retains it until earlier cleanup settles', async () => {
const { ctx, session } = await setup()
const release = Promise.withResolvers<undefined>()
const closing = Promise.withResolvers<undefined>()
onTestFinished(() => { release.resolve(undefined) })
const owner = await ctx.plugin({
inject: ['sessionProjections'],
apply(inner) {
inner.effect(function* () {
yield legacyRegistration(inner.sessionProjections.register(definition))
yield async () => {
closing.resolve(undefined)
await release.promise
}
})
},
})
const disposed = owner.dispose()
await closing.promise
session.append('turn/start', { turn: 1 })
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/legacy': 1 })
release.resolve(undefined)
await disposed
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
})
it('preserves explicit rejection handling and returns a harmless legacy disposer for failed acquisition', async () => {
const { ctx, warning, session } = await setup()
const errorLog = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
onTestFinished(() => { errorLog.mockRestore() })
const registration = legacyRegistration(ctx.sessionProjections.register({ ...definition, stateVersion: -1 }))
await expect(registration.catch((error: unknown) => error)).resolves.toBeInstanceOf(Error)
await registration()
await setImmediate()
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(errorLog).not.toHaveBeenCalled()
expect(warning).not.toHaveBeenCalled()
})
it('logs an ignored legacy registration rejection and keeps the plugin active', async () => {
const { ctx, session } = await setup()
const reported = Promise.withResolvers<unknown>()
const errorLog = vi.spyOn(ctx.logger, 'error').mockImplementation((error: unknown) => { reported.resolve(error) })
onTestFinished(() => { errorLog.mockRestore() })
const owner = await ctx.plugin({
inject: ['sessionProjections'],
apply(inner) {
void inner.sessionProjections.register(definition)
void inner.sessionProjections.register({ ...definition, stateVersion: 2 })
inner.provide('legacy-failed-provider', {})
},
})
const error = await reported.promise
expect(String(error)).toContain('already registered at stateVersion 1')
expect(errorLog).toHaveBeenCalledOnce()
expect(owner.state).toBe(FiberState.ACTIVE)
expect(ctx.get('legacy-failed-provider')).toBeDefined()
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/legacy': 0 })
})
it('lets a modern plugin handle rejected registration and finish activation', async () => {
const { ctx, warning, session } = await setup()
const owner = await ctx.plugin({
inject: ['sessionProjections'],
async apply(inner) {
const invalid = inner.sessionProjections.register({ ...definition, stateVersion: -1 })
await expect(invalid.catch((error: unknown) => error)).resolves.toBeInstanceOf(Error)
await inner.sessionProjections.register(definition)
},
})
expect(owner.state).toBe(FiberState.ACTIVE)
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/legacy': 0 })
expect(warning).not.toHaveBeenCalled()
})
it('warns again for a new registry instance without sharing warning state between applications', async () => {
const { ctx, warning, session } = await setup()
await ctx.sessionProjections.register(definition)
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(0)
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(0)
expect(warning).toHaveBeenCalledOnce()
const second = new Context()
onTestFinished(async () => { await second.fiber.dispose() })
const secondWarning = vi.spyOn(second.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { secondWarning.mockRestore() })
await second.plugin(SessionProjectionRegistry)
expect(second.sessionProjections.stateOf(session, 'test/legacy')).toBeUndefined()
expect(secondWarning).toHaveBeenCalledOnce()
expect(warning).toHaveBeenCalledOnce()
})
it('drops an ignored rejection when its plugin unloads before handoff', async () => {
const { ctx, warning, session } = await setup()
const errorLog = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
onTestFinished(() => { errorLog.mockRestore() })
const owner = ctx.plugin({
inject: ['sessionProjections'],
apply(inner) {
void inner.sessionProjections.register(definition)
void inner.fiber.dispose()
},
})
await owner.await()
await setImmediate()
expect(owner.state).toBe(FiberState.DISPOSED)
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(errorLog).not.toHaveBeenCalled()
expect(warning).not.toHaveBeenCalled()
})
it('keeps an observed registration owned when its plugin unloads before handoff', async () => {
const { ctx, warning, session } = await setup()
const owner = await ctx.plugin({ inject: ['sessionProjections'], apply() {} })
const registration = owner.ctx.sessionProjections.register(definition)
const rejection = expect(Promise.resolve(registration)).rejects.toThrow('inactive context')
await owner.dispose()
await rejection
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
expect(warning).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,3 @@
- name: '@deepseek-ai/dsh-session'
- name: '@deepseek-ai/dsh-session-projection'
- name: './legacy-plugin.ts'
@@ -0,0 +1,29 @@
/** Test-only app booting the legacy plugin through a real Cordis Loader file. */
import { boot } from '@deepseek-ai/dsh-app-boot'
import type {} from './legacy-plugin.ts'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('projection compatibility fixture requires a config path')
const ctx = await boot('projection-compatibility', configPath)
const warnings: string[] = []
ctx.logger.exporter({
levels: { default: 2 },
export: ({ type, args }) => { if (type === 'warn') warnings.push(String(args[0])) },
})
try {
const session = ctx.sessions.create()
const legacy = ctx.get('legacyProjectionFixture')
if (legacy === undefined) throw new Error('legacy projection fixture did not activate')
session.append('turn/start', { turn: 1 })
const first = legacy.read(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const second = legacy.read(session)
const values = ctx.sessionProjections.snapshot(session).values
await legacy.release()
await legacy.release()
const released = legacy.read(session) ?? null
console.log(JSON.stringify({ first, second, values, released, warnings }))
} finally {
await ctx.fiber.dispose()
}
@@ -0,0 +1,49 @@
/** Third-party-style projection plugin using synchronous registration and keyed reads. */
/* oxlint-disable typescript/no-deprecated -- this fixture intentionally keeps the legacy plugin API. */
import type { Context } from '@deepseek-ai/cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'fixture/legacy-projection': number
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
legacyProjectionFixture: {
read(session: Session): unknown
release(): void | Promise<void>
}
}
}
/** Fixture plugin identity. */
export const name = 'legacy-projection-fixture'
/** The original plugin declares its registry dependency. */
export const inject = ['sessionProjections']
const definition = {
key: 'fixture/legacy-projection',
stateSchema: z.number(),
stateVersion: 1,
init: () => 0,
apply: state => state + 1,
wire: { viewSchema: z.number(), view: state => state },
} satisfies ProjectionDefinition<'fixture/legacy-projection', number>
/**
* Publish the old plugin's read and early-release operations.
* @param ctx - Loader-owned plugin context.
*/
export function apply(ctx: Context): void {
const registration = ctx.sessionProjections.register(definition)
// Old compiled plugins call the runtime disposer without the current Promise-only type.
const dispose = registration as typeof registration & (() => void | Promise<void>)
ctx.provide('legacyProjectionFixture', {
read: session => ctx.sessionProjections.stateOf(session, 'fixture/legacy-projection'),
release: dispose,
})
}
@@ -0,0 +1,23 @@
/** Published/source Loader composition preserves old projection plugins and bounded diagnostics. */
import { fileURLToPath } from 'node:url'
import { expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
it('loads a legacy projection plugin, reads Session state, and warns once for its deprecated keyed read', async () => {
const driver = fileURLToPath(new URL('./fixtures/driver.ts', import.meta.url))
const { stdout } = await runLoaderSmoke({
label: 'legacy projection registration',
tempDirPrefix: 'dsh-projection-compatibility-',
binScript: driver,
libBinScript: driver,
configPath: fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)),
tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
})
expect(stdout.trim()).toBe(JSON.stringify({
first: 1,
second: 2,
values: { 'fixture/legacy-projection': 2 },
released: null,
warnings: ['sessionProjections.stateOf(session, "fixture/legacy-projection") is deprecated; read handle.stateOf(session) from an awaited registration, or use the owning domain service'],
}))
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
@@ -83,7 +83,7 @@ describe('retained projection readers', () => {
it('rejects initialization when the owner unloads before the handle is returned', async () => {
const { ctx, owner, session } = await setup()
const registration = owner.ctx.sessionProjections.register(definition)
const rejected = expect(registration).rejects.toThrow()
const rejected = expect(Promise.resolve(registration)).rejects.toThrow()
await owner.dispose()
await rejected
expect(ctx.sessionProjections.checkpoint(session)).toEqual({})
@@ -481,14 +481,14 @@ describe('SessionProjectionRegistry drive', () => {
// The one incompatibility a runtime comparison can name: the versioned
// contract says the cached state shape differs, so the two cannot share
// cells. Everything else about a definition is functions.
await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 }))
await expect(Promise.resolve(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })))
.rejects.toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/)
})
it('rejects a non-integer or negative stateVersion at register time', async () => {
const { ctx } = await harness()
await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).rejects.toThrow(/stateVersion/)
await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).rejects.toThrow(/stateVersion/)
await expect(Promise.resolve(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 }))).rejects.toThrow(/stateVersion/)
await expect(Promise.resolve(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 }))).rejects.toThrow(/stateVersion/)
})
it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => {
@@ -87,10 +87,10 @@ describe('session projection summary keys', () => {
const { ctx, registry } = await mount()
try {
await registry.register(keptUnit())
await expect(registry.register({
await expect(Promise.resolve(registry.register({
...keptUnit(),
wire: { ...keptUnit().wire, summary: false },
})).rejects.toThrow(/test\/summary-kept.*already registered with summary=true/)
}))).rejects.toThrow(/test\/summary-kept.*already registered with summary=true/)
} finally {
await ctx.fiber.dispose()
}
+6
View File
@@ -12810,6 +12810,12 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:~
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-app-boot':
specifier: workspace:*
version: link:../../boot/app-boot
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:*
version: link:../../test-support/loader-smoke
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session