mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge pull request #7701 from deepseek-harness/fix/empty-session-sidebar-0.2.2
[0.2.2] fix(session-projection): compute exactly the requested projections for hot session summaries
This commit is contained in:
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 9f84c9bd85b6aace
|
||||
zh: 8ebb10962c219624
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/decision:
|
||||
en: 6e8f43d7a53c35fd
|
||||
zh: a6e2bccc20474515
|
||||
en: aada3ca481120bff
|
||||
zh: 1fa45a276a47e921
|
||||
/agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered:
|
||||
en: ce8320f1032018ed
|
||||
zh: 4c5fad5e605bf524
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ A Session-list row carries every registered client view. Four of those views are
|
||||
The Session summary is every registered client view except the keys whose `SessionProjectionMap` entry carries the summary-exclusion mark.
|
||||
|
||||
- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) declares the type-only mark `SessionProjectionSummaryExcluded<T>` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue<T>` strips it distributively, so a nullable entry keeps its `null`; `SessionProjectionSummaryExcludedKey` tests the non-nullish part; `SessionProjectionSummaryValues` is every view value minus those keys.
|
||||
- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), and `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares). The todo entry is `SessionProjectionSummaryExcluded<TodoItem[]> | null`, because an intersection with `null` would absorb the null member.
|
||||
- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares), and `contextPressure` and `contextBreakdown` (token-meter). The todo entry is `SessionProjectionSummaryExcluded<TodoItem[]> | null`, because an intersection with `null` would absorb the null member.
|
||||
- Registration carries the same decision as a plain boolean: `wire.summary` says whether a Session summary includes the unit, and omitting it means `true`. A key whose map entry carries the mark declares `false` so the runtime key set and the client view type agree. The mark itself has no runtime representation — `wire.view` and `viewSchema` are typed with `SessionProjectionViewValue`, which strips it, so the erase in `register` only widens the state type.
|
||||
- [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) exposes `summaryKeys(): ReadonlySet<key>`, derived from the live registrations and rebuilt only when a new key registers or a key's last registrant disposes. `cachedSnapshot`, `viewCheckpoint`, and `snapshot` accept `ProjectionKeySelection` (`readonly Key[] | ReadonlySet<Key>`) and use a passed set as-is.
|
||||
- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live and cold cached faces. `cachedPredecessorTitle` keeps its title-only read.
|
||||
- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live registry's `snapshot` and to the cold cache read. `cachedPredecessorTitle` keeps its title-only read.
|
||||
- Client types only: `SessionSummary.projectionValues` narrows to `Partial<SessionProjectionSummaryValues>`, while `SessionProjectionSnapshot.values` keeps every view value. No wire field, function, or UI name changes, and an opened Session still receives every key through `session/follow`, the control baseline, and `session.projections`.
|
||||
|
||||
Estimated effect: with logs whose outlines dominate the projection payload, the four keys are estimated at roughly two thirds of a Session-list response for a few hundred Sessions — the outline alone near 57%, the figures and todo list near 5% together, and the shared image limits near 5% — and at roughly 85% of the same response after transport compression, where the outline's unique text barely compresses while the remaining keys do.
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ Session 列表行携带每个已注册的客户端视图。其中四个视图从
|
||||
Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。
|
||||
|
||||
- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded<T>`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue<T>` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。
|
||||
- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded<TodoItem[]> | null`,因为与 `null` 求交会吞掉 null 成员。
|
||||
- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo)、`imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享),以及 `contextPressure` 和 `contextBreakdown`(token-meter)。todo 条目写作 `SessionProjectionSummaryExcluded<TodoItem[]> | null`,因为与 `null` 求交会吞掉 null 成员。
|
||||
- 注册用普通布尔值承载同一决定:`wire.summary` 表示 Session 摘要是否包含该单元,省略即 `true`。map 条目带标记的 key 声明 `false`,让运行时键集合与客户端视图类型一致。标记本身没有运行时表示 —— `wire.view` 与 `viewSchema` 的签名已用 `SessionProjectionViewValue` 去标记,因此 `register` 里的擦除只是放宽状态类型。
|
||||
- [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) 提供 `summaryKeys(): ReadonlySet<key>`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet<Key>`),传入 Set 时直接使用。
|
||||
- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给 live 与 cold 两个缓存读取面。`cachedPredecessorTitle` 保持只读 title。
|
||||
- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给实时注册表的 `snapshot` 与冷会话的缓存读取。`cachedPredecessorTitle` 保持只读 title。
|
||||
- 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial<SessionProjectionSummaryValues>`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。
|
||||
|
||||
估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。
|
||||
|
||||
@@ -23,11 +23,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:680`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:707`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:708`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:687`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:694`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
|
||||
@@ -1353,8 +1353,8 @@
|
||||
en: 2786fef560851bb4
|
||||
zh: 5d787475356b2606
|
||||
/session-persistence-event-catalog/resolved-persistence-types/string:
|
||||
en: 1e484bd12747db9f
|
||||
zh: 1e6fc6aa9066a702
|
||||
en: 0179cc082ac8637c
|
||||
zh: e740ade45db5c263
|
||||
/session-persistence-event-catalog/resolved-persistence-types/string~2:
|
||||
en: 0089620bf4638263
|
||||
zh: 1922bef1713094bb
|
||||
@@ -1869,8 +1869,8 @@
|
||||
en: 4e102391d685eff5
|
||||
zh: 9762243a13014f3d
|
||||
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
|
||||
en: 984cac7296874653
|
||||
zh: 13b4ee707a8ce305
|
||||
en: eaecebdbcd5cc4e4
|
||||
zh: f519d7ade666f48a
|
||||
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
|
||||
en: a52d2dfbaf844127
|
||||
zh: 4c5306ca40f20873
|
||||
|
||||
@@ -5611,7 +5611,7 @@ Array of `number`.
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
@@ -7705,7 +7705,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde
|
||||
|
||||
SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -5613,7 +5613,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
@@ -7707,7 +7707,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
|
||||
|
||||
SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+2
-2
@@ -252,7 +252,7 @@
|
||||
{"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]},
|
||||
{"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]},
|
||||
{"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]},
|
||||
{"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:388","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]},
|
||||
{"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:389","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]},
|
||||
{"digest":"4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b","names":["packages/experimental/liveshare/src/host/viewer-principal.ts#ShareViewerPrincipal"],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:24"]},
|
||||
{"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]},
|
||||
{"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]},
|
||||
@@ -472,7 +472,7 @@
|
||||
{"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]},
|
||||
{"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]},
|
||||
{"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]},
|
||||
{"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:397"]},
|
||||
{"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:398"]},
|
||||
{"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]},
|
||||
{"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]},
|
||||
{"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]},
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: bc9d438b90fc2baa
|
||||
zh: df7cbea2dad3786b
|
||||
/session-projections/summary-reads:
|
||||
en: d2bc9bcc775acfb7
|
||||
zh: b1d2ba74496305b8
|
||||
en: fcc9d07e5c41abb7
|
||||
zh: 798004ad1c66787b
|
||||
/session-projections/summary-reads/summary-vocabulary:
|
||||
en: 6a93a4e1682fd6e6
|
||||
zh: 28729129c3d8a3b1
|
||||
|
||||
@@ -156,9 +156,9 @@ type ProjectionEventChangeListener = (
|
||||
|
||||
A Session summary carries every registered client view except the keys whose `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded<T>` mark. The mark has no runtime representation: it decides the client view type, while `wire.summary` decides the runtime key set, with omission meaning `true`. A key whose entry carries the mark declares `wire.summary: false` so the two agree; the field is a plain boolean, so the type does not enforce it.
|
||||
|
||||
`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `cachedSnapshot(session, keys)` for cached cells and `viewCheckpoint(rows, keys)` for checkpoint rows, both keeping their watermark rules and both using a set without copying — so there is no summary-specific read face and no second definition of the set.
|
||||
`summaryKeys()` derives the key set from the live registrations and keeps returning the same instance until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking faces — `snapshot(session, keys)` for live Sessions and `viewCheckpoint(rows, keys)` for checkpoint rows, both using a set without copying — so there is no summary-specific read face and no second definition of the set.
|
||||
|
||||
A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` remain complete when called without a key subset, which is how an opened Session, its follow baseline, and the control baseline keep every projected value; the `@` reference reads only its label keys through the same key-taking faces.
|
||||
A list row carries that summary projection, never the full projection: it promises the summary key set and nothing more. `snapshot(session, keys)` computes only the units it returns. Without a key subset, `snapshot` computes every client-visible unit and no Host-only unit, and `viewCheckpoint` serves every wire key, so an opened Session, its follow baseline, and the control baseline carry every projected value; the `@` reference reads only its label keys through the same key-taking faces. The registry's `cachedSnapshot(session, keys)` is deprecated: it returns only values another read already computed and may omit keys.
|
||||
|
||||
### Summary vocabulary
|
||||
|
||||
@@ -376,30 +376,22 @@ onEventChanged(listener: ProjectionEventChangeListener): () => void
|
||||
/**
|
||||
* One consistent cut over every registered client-visible unit for one session, read from
|
||||
* the watermark cache (missing cells fold lazily over the in-memory log).
|
||||
* Only the returned units are computed: other units, including Host-only
|
||||
* units without `wire`, are neither folded nor advanced by this read.
|
||||
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
||||
* position. Each value passes its unit's `viewSchema` before leaving.
|
||||
* @param session - the session whose projection values are read.
|
||||
* @param keys - optional client-visible outputs; state materialization remains complete.
|
||||
* @param keys - optional client-visible outputs; only these units are computed.
|
||||
* @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
|
||||
*/
|
||||
snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot
|
||||
|
||||
/**
|
||||
* Read only already-materialized client-visible cells without folding history.
|
||||
* Values may trail the live Session and are therefore hints, not a complete
|
||||
* baseline. Missing cells are omitted.
|
||||
* @param session - attached Session whose cached cells are inspected.
|
||||
* @param keys - optional wire keys to view.
|
||||
* @returns the lowest common cached cut, or `undefined` when no wire cell exists.
|
||||
*/
|
||||
cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined
|
||||
|
||||
/**
|
||||
* The keys a Session summary carries: every registered client-visible key
|
||||
* whose unit does not declare `summary: false`. The returned set is stable —
|
||||
* the same instance on every call — until a registration or its disposal
|
||||
* changes the key table, so a consumer may hold it across rows. Callers pass
|
||||
* it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())`
|
||||
* it to a key-taking read face such as `snapshot(session, summaryKeys())`
|
||||
* or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.
|
||||
* @returns the current summary key set; do not mutate it.
|
||||
*/
|
||||
|
||||
@@ -156,9 +156,9 @@ type ProjectionEventChangeListener = (
|
||||
|
||||
Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目带 `SessionProjectionSummaryExcluded<T>` 标记的 key。标记没有运行时表示:它决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段是普通布尔值,类型层不做强制。
|
||||
|
||||
`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 缓存 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者保持各自的水位规则且直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。
|
||||
`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 Session 用 `snapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者都直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。
|
||||
|
||||
列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot`、`cachedSnapshot` 与 `viewCheckpoint` 在不传 key 子集时仍是完整的,打开的 Session、其 follow 基线与控制基线因此保留每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。
|
||||
列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot(session, keys)` 只计算它返回的单元。不传 key 子集时,`snapshot` 计算每个客户端可见单元、不计算 Host 专用单元,`viewCheckpoint` 返回每个 wire key,因此打开的 Session、其 follow 基线与控制基线携带每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。注册表的 `cachedSnapshot(session, keys)` 已废弃:它只返回其他读取已经算好的值,可能缺少 key。
|
||||
|
||||
### 摘要词汇
|
||||
|
||||
@@ -376,30 +376,22 @@ onEventChanged(listener: ProjectionEventChangeListener): () => void
|
||||
/**
|
||||
* One consistent cut over every registered client-visible unit for one session, read from
|
||||
* the watermark cache (missing cells fold lazily over the in-memory log).
|
||||
* Only the returned units are computed: other units, including Host-only
|
||||
* units without `wire`, are neither folded nor advanced by this read.
|
||||
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
||||
* position. Each value passes its unit's `viewSchema` before leaving.
|
||||
* @param session - the session whose projection values are read.
|
||||
* @param keys - optional client-visible outputs; state materialization remains complete.
|
||||
* @param keys - optional client-visible outputs; only these units are computed.
|
||||
* @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
|
||||
*/
|
||||
snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot
|
||||
|
||||
/**
|
||||
* Read only already-materialized client-visible cells without folding history.
|
||||
* Values may trail the live Session and are therefore hints, not a complete
|
||||
* baseline. Missing cells are omitted.
|
||||
* @param session - attached Session whose cached cells are inspected.
|
||||
* @param keys - optional wire keys to view.
|
||||
* @returns the lowest common cached cut, or `undefined` when no wire cell exists.
|
||||
*/
|
||||
cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined
|
||||
|
||||
/**
|
||||
* The keys a Session summary carries: every registered client-visible key
|
||||
* whose unit does not declare `summary: false`. The returned set is stable —
|
||||
* the same instance on every call — until a registration or its disposal
|
||||
* changes the key table, so a consumer may hold it across rows. Callers pass
|
||||
* it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())`
|
||||
* it to a key-taking read face such as `snapshot(session, summaryKeys())`
|
||||
* or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.
|
||||
* @returns the current summary key set; do not mutate it.
|
||||
*/
|
||||
|
||||
@@ -0,0 +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.2-alpha.2/projection-snapshot-scope/guide.md
|
||||
/:
|
||||
en: 82a6e0261297097c
|
||||
zh: c86e496c258e72b2
|
||||
/projection-snapshot-scope:
|
||||
en: c8b08322a943d6b2
|
||||
zh: 31862a259b7738bb
|
||||
/projection-snapshot-scope/change:
|
||||
en: d95cd644b01419a1
|
||||
zh: 89038a6de9b74e8a
|
||||
/projection-snapshot-scope/migration:
|
||||
en: 408d88a4ea6fe3a9
|
||||
zh: 397de0dc2d8318df
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Projection snapshot reads compute only the returned units, the registry cached snapshot read is deprecated, and Session list summaries omit context pressure and breakdown."
|
||||
---
|
||||
|
||||
# Projection snapshot scope
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`ctx.sessionProjections.snapshot(session, keys)` from `@deepseek-ai/dsh-session-projection` computes only the units it returns: with `keys` it computes only the selected units, and without `keys` it computes every unit that defines `wire` and no Host-only unit. A plugin can no longer rely on other units being computed after it calls `snapshot`. The registry's `cachedSnapshot(session, keys)` is deprecated; it returns only values another read already computed and may omit keys. The persisted cache read `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` is unchanged. `session.list` summary `projections` no longer carry `contextBreakdown` or `contextPressure`; the `@deepseek-ai/dsh-token-meter` entries are now `SessionProjectionSummaryExcluded`. Plugins and SDK or wire clients that read these values are affected.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Read Host state of your own unit through the handle that `await ctx.sessionProjections.register(definition)` returns, with `handle.stateOf(session)`, instead of calling `snapshot` first and reading cells afterwards.
|
||||
2. Replace calls to the registry's `cachedSnapshot(session, keys)` with `snapshot(session, keys)`.
|
||||
3. Read `contextBreakdown` and `contextPressure` from an opened Session, for example the Session projection snapshot or follow baseline, instead of from `session.list` rows.
|
||||
4. Rebuild your plugins and clients, confirm that type checks pass without `no-deprecated` findings for `cachedSnapshot`, and confirm that an opened Session still shows its context meter.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "投影 snapshot 读取只计算返回的单元,注册表的 cached snapshot 读取已废弃,Session 列表摘要不再包含 context pressure 和 breakdown。"
|
||||
---
|
||||
|
||||
# 投影 snapshot 的计算范围
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-session-projection` 的 `ctx.sessionProjections.snapshot(session, keys)` 只计算它返回的单元:传 `keys` 时只计算选中的单元,不传时计算每个定义了 `wire` 的单元、不计算 Host 专用单元。插件不能再依赖调用 `snapshot` 之后其他单元已经算好。注册表的 `cachedSnapshot(session, keys)` 已废弃,它只返回其他读取已经算好的值,可能缺少 key。持久化缓存的读取 `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` 不变。`session.list` 摘要的 `projections` 不再携带 `contextBreakdown` 和 `contextPressure`,`@deepseek-ai/dsh-token-meter` 的这两项改为 `SessionProjectionSummaryExcluded`。读取这些值的插件以及 SDK 或 wire 客户端受影响。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 通过 `await ctx.sessionProjections.register(definition)` 返回的句柄,用 `handle.stateOf(session)` 读取自己单元的 Host 状态,不要先调用 `snapshot` 再读取 cell。
|
||||
2. 把对注册表 `cachedSnapshot(session, keys)` 的调用替换为 `snapshot(session, keys)`。
|
||||
3. 从打开的 Session 读取 `contextBreakdown` 和 `contextPressure`,例如 Session 投影 snapshot 或 follow 基线,不要从 `session.list` 行读取。
|
||||
4. 重新构建插件和客户端,确认类型检查通过、`cachedSnapshot` 没有 `no-deprecated` 告警,并确认打开的 Session 仍显示上下文用量。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1fdf707322fb6996
|
||||
zh: 0204f7e9d4aa3cce
|
||||
/session-controller/use-this-package:
|
||||
en: d3d53e899960d96e
|
||||
zh: 16b2bc7fe74215d8
|
||||
en: 22136176884a239b
|
||||
zh: b8eeea5908b9a6fd
|
||||
/session-controller/client-references:
|
||||
en: 41bdda1ac32df558
|
||||
zh: aa5d0e325924066b
|
||||
|
||||
@@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
|
||||
|
||||
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
|
||||
Each endpoint states its activation policy. For a cold Session, list reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. An attached Session is summarized from its in-memory log. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
|
||||
|
||||
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
|
||||
|
||||
@@ -41,7 +41,7 @@ Every Remote endpoint is access-controlled: a Peer whose principal is not the ow
|
||||
|
||||
List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections.
|
||||
|
||||
Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints.
|
||||
Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. An attached Session's summary reads its blankness and last-prompt recency through the `sessionListMetadata` projection handle, and its projection hints carry every summary key computed through `snapshot`. The first summary of an attached Session computes these projections over its whole in-memory log, and later summaries advance them incrementally; this computation runs inside one synchronous row, so `listWorkSliceMs` cannot split it.
|
||||
|
||||
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
|
||||
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
|
||||
每个 endpoint 都声明自己的激活策略。对冷 Session,列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。已接入 Session 从其内存日志生成摘要。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
|
||||
|
||||
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
|
||||
|
||||
@@ -41,7 +41,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当
|
||||
|
||||
列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。
|
||||
|
||||
Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
|
||||
Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。已接入 Session 的摘要通过 `sessionListMetadata` 投影句柄读取是否为空和最后一次提示词的最近活动时间,其投影提示携带通过 `snapshot` 计算的每个摘要 key。已接入 Session 的首次摘要在其完整内存日志上计算这些投影,之后的摘要增量推进;这次计算在单行的同步工作内进行,`listWorkSliceMs` 无法切分它。
|
||||
|
||||
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionHandle, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
@@ -76,6 +76,8 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin
|
||||
|
||||
/** Owns list projection registration, bounded cold summaries, and authorized search. */
|
||||
export class ApiSessionList {
|
||||
private listMetadata!: ProjectionHandle<SessionListMetadata>
|
||||
|
||||
/**
|
||||
* @param ctx - Host context carrying Session, query, persistence, and projection services.
|
||||
* @param workSliceMs - Resolved positive integral list-work budget in milliseconds.
|
||||
@@ -87,7 +89,7 @@ export class ApiSessionList {
|
||||
* @returns when all required registrations are ready.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
|
||||
this.listMetadata = await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
|
||||
key: 'sessionListMetadata',
|
||||
stateSchema: sessionListMetadataSchema,
|
||||
init: () => ({ blank: true, lastPromptAt: null }),
|
||||
@@ -111,19 +113,25 @@ export class ApiSessionList {
|
||||
|
||||
/**
|
||||
* Build one current attached-Session summary.
|
||||
* Blankness and recency come from the `sessionListMetadata` handle; projection
|
||||
* hints carry every summary key computed through `snapshot`. The first call
|
||||
* for a Session computes these projections over its whole in-memory log, and
|
||||
* later calls advance them incrementally. The handle read and the hint with
|
||||
* the same key come from one cell; the handle read does not depend on any
|
||||
* other unit's view.
|
||||
* @param session - attached Session to summarize.
|
||||
* @returns current list metadata and available projections.
|
||||
*/
|
||||
summaryFor(session: Session): SessionSummary {
|
||||
const metadata = this.listMetadata.stateOf(session)
|
||||
const projections = this.projectionsFor(session.header, session)
|
||||
const metadata = projections?.values.sessionListMetadata
|
||||
return {
|
||||
sessionId: session.id,
|
||||
updatedAt: updatedAt(session.header, metadata),
|
||||
agentAvailable: this.ctx.agents.get(session.id)?.session === session,
|
||||
formatStatus: 'current',
|
||||
running: this.ctx.agents.get(session.id)?.status === 'running',
|
||||
blank: metadata?.blank ?? session.seq === 0,
|
||||
blank: metadata.blank,
|
||||
...listFields(session.header),
|
||||
...(projections === undefined ? {} : { projections }),
|
||||
}
|
||||
@@ -304,9 +312,9 @@ export class ApiSessionList {
|
||||
// itself summary-excluded.
|
||||
const keys = this.ctx.sessionProjections.summaryKeys()
|
||||
if (session !== undefined) {
|
||||
// The live registry computed the block for this Session: its watermark
|
||||
// The live registry computes the block for this Session: its watermark
|
||||
// shares the sequence space of the Session's baselines and frames.
|
||||
return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(session, keys))
|
||||
return hintsOf('sequenced', this.ctx.sessionProjections.snapshot(session, keys))
|
||||
}
|
||||
// A cold row reads the persisted cache by header alone; the cache serves
|
||||
// seeded and unseeded lifecycles alike because a listing never seeds a
|
||||
@@ -334,8 +342,8 @@ function hintsOf(
|
||||
block: ProjectionSnapshot | undefined,
|
||||
): SessionProjectionHints | undefined {
|
||||
if (block === undefined || Object.keys(block.values).length === 0) return undefined
|
||||
// Listing hints contain every summary wire value the source currently holds
|
||||
// but remain partial: missing cells and cache rows are never materialized here.
|
||||
// Listing hints contain every summary wire value the source holds; cached
|
||||
// hints remain partial because missing cache rows are never folded here.
|
||||
return { kind, asOfSeq: block.asOfSeq, values: block.values }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,10 @@ export interface SessionListMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every available summary wire value a Session-list row carries as partial,
|
||||
* possibly stale hints. The block omits the keys whose unit declares
|
||||
* `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says
|
||||
* Summary wire values a Session-list row carries. A `sequenced` block carries
|
||||
* every summary key computed at its `asOfSeq`; a `cached` block carries the
|
||||
* values the persisted cache holds and may omit keys or trail the Session.
|
||||
* The block omits the keys whose unit declares `summary: false`. `kind` and `asOfSeq` are independent facts: `kind` says
|
||||
* which sequence space `asOfSeq` belongs to, and therefore how a client may
|
||||
* merge the block; `asOfSeq` is the producer's watermark in that space.
|
||||
*/
|
||||
|
||||
@@ -275,7 +275,8 @@ describe('attached updatedAt tracks human prompts', () => {
|
||||
const listed = await remote.list(request({}))
|
||||
if (!listed.ok) throw new Error('list failed')
|
||||
const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(summary?.updatedAt).toBe(500)
|
||||
// The resumed log's last human prompt, not its creation time or the later pickup.
|
||||
expect(summary?.updatedAt).toBe(worked)
|
||||
|
||||
// A lifecycle boundary is not a human update.
|
||||
resumed.append('turn/start', { turn: 2 })
|
||||
|
||||
@@ -5,21 +5,40 @@
|
||||
* fresh session keeps it list-hidden and reusable, while the first accepted
|
||||
* prompt's turn/start clears it. The host/session-added frame shares the
|
||||
* same predicate function (covered by the workspace spec's frame assertion).
|
||||
* A resumed Session computes its own list metadata (blankness and last-prompt
|
||||
* recency) when it is published, independent of which other projections were
|
||||
* read before publication.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
// Side-effect type imports: the configuration-event SessionEventMap merges.
|
||||
import type {} from '@deepseek-ai/dsh-permission-presets'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { SessionSummary } from '../src/types.ts'
|
||||
import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
|
||||
|
||||
const ownedContexts = new Set<Context>()
|
||||
const ownedRoots = new Set<string>()
|
||||
afterEach(async () => {
|
||||
await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
|
||||
ownedContexts.clear()
|
||||
await Promise.all([...ownedRoots].map(root => rm(root, { recursive: true, force: true })))
|
||||
ownedRoots.clear()
|
||||
})
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -74,3 +93,110 @@ describe('summary blank = conversation not started', () => {
|
||||
expect(await listBlank(remote, session.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Mount the real AgentLoop over JSONL persistence without the projection
|
||||
* cache, whose creation checkpoint would otherwise compute every unit before
|
||||
* the Session Controller observes the published Session.
|
||||
*/
|
||||
async function resumeHarness(): Promise<{ ctx: Context; remote: TestSessionRemote; cwd: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-list-blank-'))
|
||||
ownedRoots.add(root)
|
||||
const ctx = new Context()
|
||||
ownedContexts.add(ctx)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: root })
|
||||
await mountAgentLoopTestHarness(ctx)
|
||||
return { ctx, remote, cwd: root }
|
||||
}
|
||||
|
||||
/** Store one Session through persistence so a later resume seeds it from disk. */
|
||||
async function storeSession(ctx: Context, id: SessionId, cwd: string, events: readonly SessionEvent[]): Promise<void> {
|
||||
const prepared = ctx.sessions.prepare(id, { meta: { cwd } })
|
||||
const handle = await ctx.sessionPersistence.create(prepared.header)
|
||||
await handle.append(events)
|
||||
await handle.close()
|
||||
}
|
||||
|
||||
/** Configuration events a Session records at creation, before any prompt. */
|
||||
const configurationEvents = [
|
||||
{ type: 'permission/preset', seq: SessionSeq(0), time: 1, data: { preset: 'danger-full-access' } },
|
||||
{ type: 'sandbox/mode', seq: SessionSeq(1), time: 2, data: { mode: 'danger-full-access' } },
|
||||
{ type: 'approval/policy', seq: SessionSeq(2), time: 3, data: { policy: 'never' } },
|
||||
] as SessionEvent[]
|
||||
|
||||
/** Resume one stored Session and collect the summaries published for it. */
|
||||
async function resumeAndObserve(ctx: Context, remote: TestSessionRemote, id: SessionId): Promise<{
|
||||
added: SessionSummary[]
|
||||
listed: SessionSummary | undefined
|
||||
}> {
|
||||
const added: SessionSummary[] = []
|
||||
ctx.on('api-session/added', (summary) => {
|
||||
if (summary.sessionId === id) added.push(summary)
|
||||
})
|
||||
await ctx.agents.resume({ resumeSessionId: id })
|
||||
const listed = await remote.list({})
|
||||
if (!listed.ok) throw new Error('list failed')
|
||||
return { added, listed: listed.value.items.find(item => item.sessionId === id) }
|
||||
}
|
||||
|
||||
describe('resumed Session summaries', () => {
|
||||
it('publish a turnless resumed Session as blank', async () => {
|
||||
const { ctx, remote, cwd } = await resumeHarness()
|
||||
const id = 'session-resumed-blank' as SessionId
|
||||
await storeSession(ctx, id, cwd, configurationEvents)
|
||||
|
||||
const { added, listed } = await resumeAndObserve(ctx, remote, id)
|
||||
|
||||
expect(added.length).toBeGreaterThan(0)
|
||||
expect(added.map(summary => summary.blank)).toEqual(added.map(() => true))
|
||||
expect(listed?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('publish a resumed conversation as non-blank', async () => {
|
||||
const { ctx, remote, cwd } = await resumeHarness()
|
||||
const id = 'session-resumed-conversation' as SessionId
|
||||
await storeSession(ctx, id, cwd, [
|
||||
...configurationEvents,
|
||||
{ type: 'turn/start', seq: SessionSeq(3), time: 4, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message', seq: SessionSeq(4), time: 4,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'turn/end', seq: SessionSeq(5), time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
|
||||
const { added, listed } = await resumeAndObserve(ctx, remote, id)
|
||||
|
||||
expect(added.length).toBeGreaterThan(0)
|
||||
expect(added.map(summary => summary.blank)).toEqual(added.map(() => false))
|
||||
expect(listed?.blank).toBe(false)
|
||||
})
|
||||
|
||||
it('publish a resumed conversation with its last prompt time from the first frame', async () => {
|
||||
const { ctx, remote, cwd } = await resumeHarness()
|
||||
const id = 'session-resumed-recency' as SessionId
|
||||
const promptAt = Date.now() + 60_000
|
||||
await storeSession(ctx, id, cwd, [
|
||||
...configurationEvents,
|
||||
{ type: 'turn/start', seq: SessionSeq(3), time: promptAt, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message', seq: SessionSeq(4), time: promptAt,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'turn/end', seq: SessionSeq(5), time: promptAt + 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
|
||||
const { added, listed } = await resumeAndObserve(ctx, remote, id)
|
||||
|
||||
expect(added.length).toBeGreaterThan(0)
|
||||
expect(added.map(summary => [summary.updatedAt, summary.blank])).toEqual(added.map(() => [promptAt, false]))
|
||||
expect(Object.keys(added[0]?.projections?.values ?? {})).toContain('sessionListMetadata')
|
||||
expect(Object.keys(added[0]?.projections?.values ?? {})).toEqual(expect.arrayContaining([...ctx.sessionProjections.summaryKeys()]))
|
||||
expect(listed?.updatedAt).toBe(promptAt)
|
||||
expect(listed?.blank).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, Sess
|
||||
import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
@@ -43,6 +44,7 @@ let nextHarnessSession = 1
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/last-user': { text: string } | null
|
||||
'test/summary-excluded': SessionProjectionSummaryExcluded<number>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +470,7 @@ describe('session.history projections block', () => {
|
||||
})
|
||||
|
||||
describe('session.list projections column', () => {
|
||||
it('serves every already-materialized wire value from the live registry without folding', async () => {
|
||||
it('serves every summary wire value from the live registry', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
await ctx.sessionProjections.register(lastUserUnit())
|
||||
const gateway = await remote(ctx)
|
||||
@@ -502,29 +504,38 @@ describe('session.list projections column', () => {
|
||||
expect(row?.projections?.values.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('omits an unmaterialized live projection instead of folding history for listing', async () => {
|
||||
it('computes every summary key for a live row without computing summary-excluded units', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
seedMessages(session, 1)
|
||||
const unit = lastUserUnit()
|
||||
const apply = vi.fn(unit.apply)
|
||||
await ctx.sessionProjections.register({ ...unit, apply })
|
||||
await ctx.sessionProjections.register(lastUserUnit())
|
||||
const excludedApply = vi.fn((state: number) => state + 1)
|
||||
await ctx.sessionProjections.register({
|
||||
key: 'test/summary-excluded',
|
||||
stateSchema: z.number().int().nonnegative(),
|
||||
init: () => 0,
|
||||
apply: excludedApply,
|
||||
wire: { viewSchema: z.number().int().nonnegative(), view: state => state, summary: false },
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
const response = await (await remote(ctx)).list(request({}))
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
const row = response.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row).toBeDefined()
|
||||
expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
expect('test/summary-excluded' in (row?.projections?.values ?? {})).toBe(false)
|
||||
expect(excludedApply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits the column entirely when no registry is mounted', async () => {
|
||||
it('carries every registered summary key, which is only its own list metadata when no other unit is registered', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
seedMessages(session, 1)
|
||||
const response = await (await remote(ctx)).list(request({}))
|
||||
if (!response.ok) throw new Error('unreachable')
|
||||
const row = response.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row).toBeDefined()
|
||||
expect(row !== undefined && 'projections' in row).toBe(false)
|
||||
expect(row?.projections?.kind).toBe('sequenced')
|
||||
expect(Object.keys(row?.projections?.values ?? {})).toEqual([...ctx.sessionProjections.summaryKeys()])
|
||||
expect(Object.keys(row?.projections?.values ?? {})).toEqual(['sessionListMetadata'])
|
||||
})
|
||||
|
||||
it('serves the summary projection hints from the cache with zero log loads', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Session-list summary payloads: a `session.list` row carries every registered
|
||||
* wire projection except the units that declare `summary: false`
|
||||
* (`turnOutline`, `sessionStats`, `todos`, `imageLimits`), while an opened Session's
|
||||
* (`turnOutline`, `sessionStats`, `todos`, `imageLimits`, `contextPressure`,
|
||||
* `contextBreakdown`), while an opened Session's
|
||||
* `session/follow` opening snapshot keeps serving them.
|
||||
*/
|
||||
|
||||
@@ -21,9 +22,10 @@ import {
|
||||
import {
|
||||
apply as toolTodoApply, Config as toolTodoConfig, inject as toolTodoInject, name as toolTodoName,
|
||||
} from '@deepseek-ai/dsh-tool-todo'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import { createSessionTestRemote } from './test-remote.ts'
|
||||
|
||||
const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits'] as const
|
||||
const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits', 'contextPressure', 'contextBreakdown'] as const
|
||||
|
||||
const ownedContexts = new Set<Context>()
|
||||
afterEach(async () => {
|
||||
@@ -51,7 +53,7 @@ async function opening(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the registry plus the four summary-excluded units and one ordinary
|
||||
* Mount the registry plus the summary-excluded units and one ordinary
|
||||
* wire unit. The tool plugin needs a tool registry; the list path never
|
||||
* executes a tool, so a registration sink is enough.
|
||||
*/
|
||||
@@ -71,6 +73,7 @@ async function harness(): Promise<{ ctx: Context; sessionContext: Context }> {
|
||||
Config: toolTodoConfig,
|
||||
apply: toolTodoApply,
|
||||
}, { allowParallelInProgress: false })
|
||||
await ctx.plugin(TokenMeter)
|
||||
// Build the remote (and with it the list owner that registers
|
||||
// `sessionListMetadata`) before any Session exists, so every registered unit
|
||||
// seeds a cell at `session/created`.
|
||||
|
||||
@@ -15,6 +15,14 @@ export const BINDING_RECORD = 'plugin:forge-session/binding'
|
||||
/** Tools whose calls run shell commands with a string `command` argument. */
|
||||
const SHELL_TOOLS: ReadonlySet<string> = new Set(['bash', 'pwsh'])
|
||||
|
||||
/**
|
||||
* Literal text checked on the raw JSON arguments before parsing: recognized
|
||||
* `gh pr` forms name `gh`, and `git push` names `push`. Arguments whose JSON
|
||||
* text contains neither are not parsed, so a command spelled only through JSON
|
||||
* escapes is not recognized.
|
||||
*/
|
||||
const EVIDENCE_HINT = /gh|push/
|
||||
|
||||
const EMPTY: ForgeChangeRequestsState = Object.freeze({
|
||||
pending: Object.freeze([]),
|
||||
bindings: Object.freeze([]),
|
||||
@@ -84,7 +92,7 @@ function apply(state: ForgeChangeRequestsState, event: SessionEvent): ForgeChang
|
||||
}
|
||||
|
||||
function applyCall(state: ForgeChangeRequestsState, call: SessionEventMap['tool/call']): ForgeChangeRequestsState {
|
||||
if (!SHELL_TOOLS.has(call.name)) return state
|
||||
if (!SHELL_TOOLS.has(call.name) || !EVIDENCE_HINT.test(call.arguments)) return state
|
||||
const command = commandOf(call.arguments)
|
||||
const evidence = command === undefined ? undefined : commandEvidence(command)
|
||||
if (evidence === undefined) return state
|
||||
|
||||
@@ -19,6 +19,8 @@ const VALUE_OPTIONS = new Set([
|
||||
const BOOLEAN_OPTIONS = new Set(['--comments', '-c', '--web', '-w', '--detach', '-d', '--force', '-f', '--recurse-submodules', '--remove-milestone'])
|
||||
const SEPARATORS = new Set(['&&', '||', ';', '|', '|&', '&'])
|
||||
const REPOSITORY = /^(?:([a-z\d.-]+(?::\d+)?)\/)?([a-z\d_-]+)\/([a-z\d_.-]+)$/i
|
||||
/** A literal `gh` word; a command that names `gh` only after shell quoting is not recognized. */
|
||||
const GH_WORD = /\bgh\b/
|
||||
|
||||
/**
|
||||
* Read explicit URL selectors or numeric selectors with `--repo`/`-R`.
|
||||
@@ -30,6 +32,8 @@ const REPOSITORY = /^(?:([a-z\d.-]+(?::\d+)?)\/)?([a-z\d_-]+)\/([a-z\d_.-]+)$/i
|
||||
* @returns references in command order; no process or environment is read.
|
||||
*/
|
||||
export function ghReferences(command: string): GhReference[] {
|
||||
// A reference needs a `gh` word; skip tokenizing commands that never name it.
|
||||
if (!GH_WORD.test(command)) return []
|
||||
if (/[\r\n`]|\$\(/.test(command) || !/^(?:[^'"\\]|\\.|'(?:[^']*)'|"(?:[^"\\]|\\.)*")*$/.test(command)) return []
|
||||
let tokens: parse.ParseEntry[]
|
||||
try {
|
||||
|
||||
@@ -2980,19 +2980,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot',
|
||||
description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.',
|
||||
parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'keys', description: 'optional client-visible outputs; state materialization remains complete.' }],
|
||||
description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Only the returned units are computed: other units, including Host-only units without `wire`, are neither folded nor advanced by this read. Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.',
|
||||
parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'keys', description: 'optional client-visible outputs; only these units are computed.' }],
|
||||
returns: 'the snapshot; `values` is empty when no selected client-visible unit is registered.',
|
||||
},
|
||||
{
|
||||
signature: 'cachedSnapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined',
|
||||
description: 'Read only already-materialized client-visible cells without folding history. Values may trail the live Session and are therefore hints, not a complete baseline. Missing cells are omitted.',
|
||||
parameters: [{ name: 'session', description: 'attached Session whose cached cells are inspected.' }, { name: 'keys', description: 'optional wire keys to view.' }],
|
||||
returns: 'the lowest common cached cut, or `undefined` when no wire cell exists.',
|
||||
},
|
||||
{
|
||||
signature: 'summaryKeys(): ReadonlySet<Extract<keyof SessionProjectionMap, string>>',
|
||||
description: 'The keys a Session summary carries: every registered client-visible key whose unit does not declare `summary: false`. The returned set is stable — the same instance on every call — until a registration or its disposal changes the key table, so a consumer may hold it across rows. Callers pass it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())` or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.',
|
||||
description: 'The keys a Session summary carries: every registered client-visible key whose unit does not declare `summary: false`. The returned set is stable — the same instance on every call — until a registration or its disposal changes the key table, so a consumer may hold it across rows. Callers pass it to a key-taking read face such as `snapshot(session, summaryKeys())` or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.',
|
||||
parameters: [],
|
||||
returns: 'the current summary key set; do not mutate it.',
|
||||
},
|
||||
|
||||
@@ -179,5 +179,6 @@ export const contextBreakdownProjectionDefinition = {
|
||||
wire: {
|
||||
viewSchema: breakdownSchema,
|
||||
view: state => state.breakdown,
|
||||
summary: false,
|
||||
},
|
||||
} satisfies ProjectionDefinition<'contextBreakdown', ContextBreakdownState>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* @module @deepseek-ai/dsh-token-meter/projection
|
||||
*/
|
||||
|
||||
import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* Durable cumulative provider usage for a complete session log.
|
||||
*
|
||||
@@ -70,8 +72,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
/** Provider-reported usage accumulated across the complete durable log. */
|
||||
tokenUsage: TokenUsageProjection
|
||||
/** Newest request pressure paired with the newest known route capacity. */
|
||||
contextPressure: ContextPressureProjection
|
||||
contextPressure: SessionProjectionSummaryExcluded<ContextPressureProjection>
|
||||
/** Heuristic system/tools/message composition of the next request. */
|
||||
contextBreakdown: ContextBreakdownProjection
|
||||
contextBreakdown: SessionProjectionSummaryExcluded<ContextBreakdownProjection>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,5 +208,6 @@ export const contextPressureProjectionDefinition = {
|
||||
? {}
|
||||
: { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
|
||||
}),
|
||||
summary: false,
|
||||
},
|
||||
} satisfies ProjectionDefinition<'contextPressure', ContextPressureState>
|
||||
|
||||
@@ -30,14 +30,14 @@
|
||||
en: dac4ea4d67ce057d
|
||||
zh: 1cf3e5c7af5a5203
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/deprecated-plugin-compatibility:
|
||||
en: eed1ad9d62755251
|
||||
zh: 88a07c787528009a
|
||||
en: bfbeab7e33fc7f8f
|
||||
zh: 74e0b871f1639f14
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints:
|
||||
en: 3bbc61a438aeabd0
|
||||
zh: bd1824f4da278764
|
||||
/deepseek-ai-dsh-session-projection/use-this-package/session-summaries:
|
||||
en: bbdefde61fb3b02f
|
||||
zh: c6871e05e63ff4ed
|
||||
en: ee868b44413a126f
|
||||
zh: f98c66d8e4da9a72
|
||||
/deepseek-ai-dsh-session-projection/understand-the-implementation:
|
||||
en: 3397f58d6f49bfda
|
||||
zh: efb85d382e25574d
|
||||
|
||||
@@ -91,6 +91,8 @@ Legacy `stateOf(session, key)` is deprecated and returns `unknown`. Reading a re
|
||||
|
||||
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.
|
||||
|
||||
The registry's `cachedSnapshot(session, keys)` is deprecated. It views only cells another read already computed, may omit selected keys, and returns `undefined` when no selected client-visible cell exists; existing callers keep working. Use `snapshot(session, keys)` for complete values at the Session cursor, or the registration's `handle.stateOf(session)` for Host state. The persisted cache read `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` is a separate method and is unchanged.
|
||||
|
||||
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
|
||||
@@ -99,11 +101,11 @@ Every unit's state is checkpointed — client-visible and host-only alike — th
|
||||
|
||||
### Session summaries
|
||||
|
||||
A Session summary is every registered client view except the keys whose non-nullish `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded<T>` mark. `summaryKeys()` returns that set, derived from the live registrations and stable — the same instance — until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking reads — `cachedSnapshot(session, keys)` for live cells, `viewCheckpoint(rows, keys)` for checkpoint rows, and `cachedSnapshot(header, keys)` through the persisted cache — which treat a set as already selected, so there is no summary-specific read face, no copy per read, and no second definition of the set. Because the registry derives it, a summary consumer cannot request an excluded key through the derived set; a complete face still serves an explicitly named key, which is how an opened Session keeps every value. A list row carries the summary projection and never the full projection.
|
||||
A Session summary is every registered client view except the keys whose non-nullish `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded<T>` mark. `summaryKeys()` returns that set, derived from the live registrations and stable — the same instance — until a registration or its disposal changes the key table. A summary consumer passes it to the existing key-taking reads — `snapshot(session, keys)` for live Sessions, `viewCheckpoint(rows, keys)` for checkpoint rows, and `cachedSnapshot(header, keys)` through the persisted cache — which treat a set as already selected, so there is no summary-specific read face, no copy per read, and no second definition of the set. Because the registry derives it, a summary consumer cannot request an excluded key through the derived set; a complete face still serves an explicitly named key, which is how an opened Session keeps every value. A list row carries the summary projection and never the full projection.
|
||||
|
||||
The mark decides the client view type; `wire.summary` decides the runtime key set, and omitting it means `true`. Declare `wire.summary: false` on a key whose map entry carries the mark so the two agree; the field is a plain boolean, not a type-level interlock. A nullable entry keeps `null` outside the wrapper (`SessionProjectionSummaryExcluded<TodoItem[]> | null`) so the pre-first-write value stays expressible. The keys currently marked are `turnOutline`, `sessionStats`, `todos`, and `imageLimits`: an outline grows with the whole log, no list row renders the stats or todo list, and the image limits are one host-wide boot constant every Session shares.
|
||||
The mark decides the client view type; `wire.summary` decides the runtime key set, and omitting it means `true`. Declare `wire.summary: false` on a key whose map entry carries the mark so the two agree; the field is a plain boolean, not a type-level interlock. A nullable entry keeps `null` outside the wrapper (`SessionProjectionSummaryExcluded<TodoItem[]> | null`) so the pre-first-write value stays expressible. The keys currently marked are `turnOutline`, `sessionStats`, `todos`, `imageLimits`, `contextPressure`, and `contextBreakdown`: an outline grows with the whole log, no list row renders the stats, todo list, or context meter, and the image limits are one host-wide boot constant every Session shares.
|
||||
|
||||
Summaries serve the Session list rows, which list Sessions without opening them. The `@` reference lists Sessions the same way but reads only its `title` and `subagent` label keys through the key-taking `snapshot(session, keys)` and `cachedSnapshot(header, keys)` faces. `snapshot`, `cachedSnapshot`, and `viewCheckpoint` still serve every wire key when called without a key subset, so an opened Session receives every projected value.
|
||||
Summaries serve the Session list rows, which list Sessions without opening them. The `@` reference lists Sessions the same way but reads only its `title` and `subagent` label keys through the key-taking `snapshot(session, keys)` and `cachedSnapshot(header, keys)` faces. `snapshot(session, keys)` computes only the units it returns; without a key subset it computes every client-visible unit and no Host-only unit, so an opened Session receives every projected value, and `viewCheckpoint` likewise serves every wire key without a key subset.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ const todo = projections.todo()
|
||||
|
||||
每个 registry 实例对每个键的首次旧式 `stateOf(session, key)` 读取输出一次 `ctx.logger.warn` 弃用警告。注册、调用其返回值、句柄方法和 owner 清理不产生警告。
|
||||
|
||||
注册表的 `cachedSnapshot(session, keys)` 已弃用。它只读取其他读取已经算好的 cell,可能缺少选中的 key,没有选中的客户端可见 cell 时返回 `undefined`;已有调用方仍可继续使用。需要 Session 当前游标处的完整值时使用 `snapshot(session, keys)`,读取 Host 状态时使用注册返回的 `handle.stateOf(session)`。持久化缓存的读取 `ctx.sessionProjectionCache.cachedSnapshot(header, keys)` 是另一个方法,本次未变。
|
||||
|
||||
定义同步安装,句柄交付保持异步。注册错误使返回值拒绝。当前宏任务及其微任务结束前没有调用方观察该拒绝时,registry 将其记录为错误,而不留下未处理的拒绝,插件保持激活;发生键冲突后,其按键读取返回另一注册方的状态;owner 已不在加载或激活状态的拒绝会被丢弃。新插件在提供读取操作前等待注册完成。同步 `try/catch` 无法捕获异步注册错误。
|
||||
|
||||
### 持久检查点
|
||||
@@ -99,11 +101,11 @@ const todo = projections.todo()
|
||||
|
||||
### Session 摘要
|
||||
|
||||
Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目(非空值)带 `SessionProjectionSummaryExcluded<T>` 标记的 key。`summaryKeys()` 返回该集合,从实时注册派生;在注册或注销改变 key 表之前,它保持同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,持久化缓存用 `cachedSnapshot(header, keys)` —— 这些读取面把 Set 视为已选好的集合,因此不存在摘要专用读取面、每次读取无需复制,集合也只有一处定义。由于集合由注册表派生,摘要消费方无法通过该集合请求被排除的 key;完整读取面仍会按显式命名的 key 提供值,打开的 Session 因此保留全部取值。列表行携带的是摘要投影,绝不是全量投影。
|
||||
Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目(非空值)带 `SessionProjectionSummaryExcluded<T>` 标记的 key。`summaryKeys()` 返回该集合,从实时注册派生;在注册或注销改变 key 表之前,它保持同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 Session 用 `snapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,持久化缓存用 `cachedSnapshot(header, keys)` —— 这些读取面把 Set 视为已选好的集合,因此不存在摘要专用读取面、每次读取无需复制,集合也只有一处定义。由于集合由注册表派生,摘要消费方无法通过该集合请求被排除的 key;完整读取面仍会按显式命名的 key 提供值,打开的 Session 因此保留全部取值。列表行携带的是摘要投影,绝不是全量投影。
|
||||
|
||||
标记决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段就是普通布尔值,而不是类型层的互锁。可空条目把 `null` 放在包装之外(`SessionProjectionSummaryExcluded<TodoItem[]> | null`),以便首次写入前的值仍可表达。当前被标记的 key 是 `turnOutline`、`sessionStats`、`todos` 和 `imageLimits`:outline 随整份日志增长,列表行不渲染统计或 todo 清单,而图片上限是每个 Session 共享的同一个宿主级启动常量。
|
||||
标记决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段就是普通布尔值,而不是类型层的互锁。可空条目把 `null` 放在包装之外(`SessionProjectionSummaryExcluded<TodoItem[]> | null`),以便首次写入前的值仍可表达。当前被标记的 key 是 `turnOutline`、`sessionStats`、`todos`、`imageLimits`、`contextPressure` 和 `contextBreakdown`:outline 随整份日志增长,列表行不渲染统计、todo 清单或上下文用量,而图片上限是每个 Session 共享的同一个宿主级启动常量。
|
||||
|
||||
摘要服务于 Session 列表行,列表无需打开 Session。`@` 引用同样以列表方式枚举 Session,但只通过带 key 的 `snapshot(session, keys)` 与 `cachedSnapshot(header, keys)` 读取其 `title` 和 `subagent` 标签 key。`snapshot`、`cachedSnapshot` 和 `viewCheckpoint` 在不传 key 子集时仍返回每个 wire key,因此打开的 Session 仍会收到全部投影值。
|
||||
摘要服务于 Session 列表行,列表无需打开 Session。`@` 引用同样以列表方式枚举 Session,但只通过带 key 的 `snapshot(session, keys)` 与 `cachedSnapshot(header, keys)` 读取其 `title` 和 `subagent` 标签 key。`snapshot(session, keys)` 只计算它返回的单元;不传 key 子集时计算每个客户端可见单元、不计算 Host 专用单元,因此打开的 Session 仍会收到全部投影值,`viewCheckpoint` 不传 key 子集时同样返回每个 wire key。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -493,10 +493,12 @@ export class SessionProjectionRegistry extends Service {
|
||||
/**
|
||||
* One consistent cut over every registered client-visible unit for one session, read from
|
||||
* the watermark cache (missing cells fold lazily over the in-memory log).
|
||||
* Only the returned units are computed: other units, including Host-only
|
||||
* units without `wire`, are neither folded nor advanced by this read.
|
||||
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
||||
* position. Each value passes its unit's `viewSchema` before leaving.
|
||||
* @param session - the session whose projection values are read.
|
||||
* @param keys - optional client-visible outputs; state materialization remains complete.
|
||||
* @param keys - optional client-visible outputs; only these units are computed.
|
||||
* @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
|
||||
*/
|
||||
snapshot(
|
||||
@@ -505,7 +507,6 @@ export class SessionProjectionRegistry extends Service {
|
||||
): ProjectionSnapshot {
|
||||
const values: Record<string, unknown> = {}
|
||||
const selected = selectedKeys(keys)
|
||||
this.materializeCells(session)
|
||||
for (const registration of this.registrations.values()) {
|
||||
if (registration.def.wire === undefined) continue
|
||||
if (selected !== undefined && !selected.has(registration.def.key)) continue
|
||||
@@ -522,6 +523,8 @@ export class SessionProjectionRegistry extends Service {
|
||||
* @param session - attached Session whose cached cells are inspected.
|
||||
* @param keys - optional wire keys to view.
|
||||
* @returns the lowest common cached cut, or `undefined` when no wire cell exists.
|
||||
* @deprecated Returns only values another read already computed and may omit keys; never use it to decide behavior.
|
||||
* Use snapshot(session, keys) for complete values, or a retained handle for host state.
|
||||
*/
|
||||
cachedSnapshot(
|
||||
session: Session,
|
||||
@@ -548,7 +551,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
* whose unit does not declare `summary: false`. The returned set is stable —
|
||||
* the same instance on every call — until a registration or its disposal
|
||||
* changes the key table, so a consumer may hold it across rows. Callers pass
|
||||
* it to a key-taking read face such as `cachedSnapshot(session, summaryKeys())`
|
||||
* it to a key-taking read face such as `snapshot(session, summaryKeys())`
|
||||
* or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is.
|
||||
* @returns the current summary key set; do not mutate it.
|
||||
*/
|
||||
@@ -770,6 +773,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
)
|
||||
for (const registration of this.registrations.values()) {
|
||||
const row = restored.checkpoint[registration.def.key]
|
||||
/* v8 ignore next -- restore() writes a row for every registration in the same synchronous pass. */
|
||||
if (row === undefined) continue
|
||||
const current = registration.cells.get(session)
|
||||
if (current !== undefined && current.observedSeq > row.seq) continue
|
||||
@@ -828,6 +832,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
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))
|
||||
/* v8 ignore next 3 -- throughSeq never exceeds the committed log, whose seqs are contiguous by the Session invariant. */
|
||||
if (event === undefined || event.seq !== seq) {
|
||||
throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`)
|
||||
}
|
||||
@@ -911,6 +916,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
/** Return one schema-validated wire value. */
|
||||
private viewCell(registration: Registration, cell: UnitCell): unknown {
|
||||
const wire = registration.def.wire
|
||||
/* v8 ignore next -- every caller filters to units with wire before calling viewCell. */
|
||||
if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`)
|
||||
return wire.viewSchema.parse(wire.view(cell.state))
|
||||
}
|
||||
|
||||
@@ -129,11 +129,13 @@ describe('projection registration compatibility', () => {
|
||||
apply: definition.apply,
|
||||
})
|
||||
expect(ctx.sessionProjections.stateOf(session, 'test/missing-legacy')).toBeUndefined()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Only the deprecated cached face observes cells without computing them.
|
||||
expect(ctx.sessionProjections.cachedSnapshot(session)).toBeUndefined()
|
||||
expect(hostInit).not.toHaveBeenCalled()
|
||||
|
||||
expect(ctx.sessionProjections.stateOf(session, 'test/legacy')).toBe(2)
|
||||
expect(unrelatedView).not.toHaveBeenCalled()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Only the deprecated cached face observes cells without computing them.
|
||||
expect(ctx.sessionProjections.cachedSnapshot(session)).toEqual({
|
||||
asOfSeq: 1,
|
||||
values: { 'test/legacy': 2, 'test/legacy-unread-wire': 2 },
|
||||
|
||||
@@ -628,6 +628,38 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect('test/unregistered' in values).toBe(false)
|
||||
})
|
||||
|
||||
it('snapshot with keys computes only the selected units', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
mark(session, ['a'])
|
||||
await ctx.sessionProjections.register(marksUnit())
|
||||
const unselectedApply = vi.fn((state: StableViewState) => state)
|
||||
await ctx.sessionProjections.register({ ...stableViewUnit(state => state.value), apply: unselectedApply })
|
||||
expect(ctx.sessionProjections.snapshot(session, ['test/marks']).values).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(unselectedApply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('snapshot without keys does not compute host-only units', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
mark(session, ['a'])
|
||||
await ctx.sessionProjections.register(marksUnit())
|
||||
const hostApply = vi.fn((state: number) => state + 1)
|
||||
await ctx.sessionProjections.register({ ...countUnit(), apply: hostApply })
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(hostApply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('snapshot with keys succeeds while an unselected unit fails to apply', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
mark(session, ['a'])
|
||||
await ctx.sessionProjections.register(marksUnit())
|
||||
await ctx.sessionProjections.register({
|
||||
...stableViewUnit(state => state.value),
|
||||
apply: (): StableViewState => { throw new Error('unselected unit failed') },
|
||||
})
|
||||
expect(ctx.sessionProjections.snapshot(session, ['test/marks']).values).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(() => ctx.sessionProjections.snapshot(session)).toThrow('unselected unit failed')
|
||||
})
|
||||
|
||||
it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
await ctx.sessionProjections.register(marksUnit())
|
||||
@@ -865,4 +897,96 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
})
|
||||
expect(() => ctx.sessionProjections.snapshot(session)).toThrow()
|
||||
})
|
||||
|
||||
it('keeps a cell built before a fresh Session is announced', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const stable = await ctx.sessionProjections.register(stableViewUnit(state => state.value))
|
||||
const session = ctx.sessions.prepare(SessionId('projection-early-cell'))
|
||||
const before = stable.stateOf(session)
|
||||
const detach = ctx.sessions.enter(session)
|
||||
onTestFinished(detach)
|
||||
ctx.sessions.announce(session)
|
||||
expect(stable.stateOf(session)).toBe(before)
|
||||
})
|
||||
|
||||
it('restore rejects events that are not contiguous from baseSeq', async () => {
|
||||
const { ctx } = await harness()
|
||||
await ctx.sessionProjections.register(countUnit())
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } },
|
||||
{ type: 'turn/start', seq: SessionSeq(2), time: 2, data: { turn: 2 } },
|
||||
]
|
||||
expect(() => ctx.sessionProjections.restore(
|
||||
{},
|
||||
gapped,
|
||||
SessionLogOffset(0),
|
||||
RESTORE_HEADER,
|
||||
SessionLogOffset(0),
|
||||
)).toThrow('cannot restore across missing seq 1')
|
||||
})
|
||||
|
||||
it('hydrate with an empty event list serves the cut before baseSeq', async () => {
|
||||
const { ctx } = await harness()
|
||||
const marks = await ctx.sessionProjections.register(marksUnit())
|
||||
const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-empty'))
|
||||
const hydrated = ctx.sessionProjections.hydrate(prepared, {}, [], SessionLogOffset(0))
|
||||
expect(hydrated).toEqual({ asOfSeq: -1, values: { 'test/marks': { marks: [] } } })
|
||||
expect(marks.stateOf(prepared)).toBeNull()
|
||||
})
|
||||
|
||||
it('hydrate at an already hydrated cut serves the installed cells', async () => {
|
||||
const { ctx } = await harness()
|
||||
await ctx.sessionProjections.register(marksUnit())
|
||||
await ctx.sessionProjections.register(countUnit())
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'test/mark', seq: SessionSeq(0), time: 0, data: { marks: ['seed'] } },
|
||||
{ type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } },
|
||||
]
|
||||
const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-twice'), { seed: events })
|
||||
const first = ctx.sessionProjections.hydrate(prepared, {}, events, SessionLogOffset(0))
|
||||
const restore = vi.spyOn(ctx.sessionProjections, 'restore')
|
||||
const second = ctx.sessionProjections.hydrate(prepared, {}, events, SessionLogOffset(0))
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
expect(second).toEqual(first)
|
||||
expect(second).toEqual({ asOfSeq: 1, values: { 'test/marks': { marks: ['seed'] } } })
|
||||
})
|
||||
|
||||
it('hydrate keeps a cell that is already past the hydrated cut', async () => {
|
||||
const { ctx } = await harness()
|
||||
const marks = await ctx.sessionProjections.register(marksUnit())
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'test/mark', seq: SessionSeq(0), time: 0, data: { marks: ['first'] } },
|
||||
{ type: 'test/mark', seq: SessionSeq(1), time: 1, data: { marks: ['second'] } },
|
||||
]
|
||||
const prepared = ctx.sessions.prepare(SessionId('projection-hydrate-behind'), { seed: events })
|
||||
expect(marks.stateOf(prepared)).toEqual({ marks: ['second'] })
|
||||
const hydrated = ctx.sessionProjections.hydrate(prepared, {}, events.slice(0, 1), SessionLogOffset(0))
|
||||
expect(hydrated).toEqual({ asOfSeq: 0, values: { 'test/marks': { marks: ['first'] } } })
|
||||
expect(marks.stateOf(prepared)).toEqual({ marks: ['second'] })
|
||||
expect(ctx.sessionProjections.checkpoint(prepared)['test/marks']?.seq).toBe(prepared.seq - 1)
|
||||
})
|
||||
|
||||
it('drive skips a cell that an earlier session/event listener already advanced', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const apply = vi.fn((state: number, _event: SessionEvent) => state + 1)
|
||||
const count = await ctx.sessionProjections.register({ ...countUnit(), apply })
|
||||
await ctx.sessionProjections.register(marksUnit())
|
||||
const seen: number[] = []
|
||||
ctx.on('session/event', (target: Session) => {
|
||||
seen.push(count.stateOf(target))
|
||||
}, { prepend: true })
|
||||
const changed = vi.fn()
|
||||
ctx.sessionProjections.onChanged(changed)
|
||||
const first = mark(session, ['a'])
|
||||
const second = mark(session, ['b'])
|
||||
expect(seen).toEqual([1, 2])
|
||||
expect(apply.mock.calls.map(([, event]) => event)).toEqual([first, second])
|
||||
expect(count.stateOf(session)).toBe(2)
|
||||
expect(changed.mock.calls).toEqual([
|
||||
[session, 'test/marks', { marks: ['a'] }, first.seq],
|
||||
[session, 'test/marks', { marks: ['b'] }, second.seq],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,9 +152,11 @@ describe('session projection summary keys', () => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
// The derived set is passed as a set: no key-list copy inside the read.
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself.
|
||||
expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({
|
||||
'test/summary-kept': { text: 'turn-1' },
|
||||
})
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself.
|
||||
expect(registry.cachedSnapshot(session)?.values).toEqual({
|
||||
'test/summary-kept': { text: 'turn-1' },
|
||||
'test/summary-excluded': 1,
|
||||
@@ -173,7 +175,9 @@ describe('session projection summary keys', () => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
expect([...registry.summaryKeys()]).toEqual([])
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself.
|
||||
expect(registry.cachedSnapshot(session, [])).toBeUndefined()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself.
|
||||
expect(registry.cachedSnapshot(session)?.values).toEqual({
|
||||
'test/summary-excluded': 1,
|
||||
'test/summary-excluded-nullable': ['turn-1'],
|
||||
@@ -199,6 +203,7 @@ describe('session projection summary keys', () => {
|
||||
try {
|
||||
await registry.register(keptUnit())
|
||||
const session = ctx.sessions.create(SessionId('summary-unmaterialized'), { meta: { cwd: '/workspace' } })
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- This case pins the deprecated cached face itself.
|
||||
expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({
|
||||
'test/summary-kept': null,
|
||||
})
|
||||
|
||||
+2
-4
@@ -406,11 +406,9 @@ export default defineConfig({
|
||||
// by decision: its correctness signal is its uninstrumented suite and
|
||||
// the packer's end-to-end image spec.
|
||||
'packages/experimental/webworker-runtime/src/**/*.ts',
|
||||
// Projection/command round: executor lifecycle branches and the
|
||||
// registry's drive tails need the same maturing lanes. TODO(gui):
|
||||
// cover and remove with the client test lane above.
|
||||
// Command executor lifecycle branches need the same maturing lanes.
|
||||
// TODO(gui): cover and remove with the client test lane above.
|
||||
'packages/interaction/commands/src/index.ts',
|
||||
'packages/session/session-projection/src/index.ts',
|
||||
...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`),
|
||||
...windowsOnlyCoverageExclusions,
|
||||
...windowsRunnerCoverageExclusions,
|
||||
|
||||
Reference in New Issue
Block a user