diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml new file mode 100644 index 0000000000..954a507a27 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-18-session-list-summary-projections.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 .agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md +/agent-note-serve-session-list-rows-from-a-summary-key-set: + en: 983b028b1d2aeb7c + zh: 2a2d3ced786ee686 +/agent-note-serve-session-list-rows-from-a-summary-key-set/problem: + en: 9f84c9bd85b6aace + zh: 8ebb10962c219624 +/agent-note-serve-session-list-rows-from-a-summary-key-set/decision: + en: 6e8f43d7a53c35fd + zh: a6e2bccc20474515 +/agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered: + en: ce8320f1032018ed + zh: 4c5fad5e605bf524 +/agent-note-serve-session-list-rows-from-a-summary-key-set/consequences: + en: d3465b78160d8c1b + zh: dcbad6768b74b2bf +/agent-note-serve-session-list-rows-from-a-summary-key-set/testing: + en: 6b34571bdadb0a9d + zh: 41ebfc1b5325b8d6 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md new file mode 100644 index 0000000000..89e7b2c37d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md @@ -0,0 +1,52 @@ +# Agent Note: Serve Session-list rows from a summary key set + +Status: implemented + +English | [中文](2026-10-18-session-list-summary-projections.zh.md) + +## Problem + +A Session-list row carries every registered client view. Four of those views are never rendered by a row: the whole-log turn outline, the whole-log conversation figures, the current todo list, and the host-wide image limits. The outline grows with the Session's log, so a list payload scales with corpus size and log length even though no row shows it. Omitting values from the list needs a rule that keeps the wire and client types honest: a summary consumer must not obtain an omitted key, a value that is omitted must still reach an opened Session, and the owning packages must not drift from whatever declaration drives the omission. + +## Decision + +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` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue` 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 | 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`, 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`) 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. +- Client types only: `SessionSummary.projectionValues` narrows to `Partial`, 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. + +## Alternatives considered + +**A summary-specific read face per source** (`viewSummaryRecord` for checkpoint rows and `cachedSummarySnapshot` for live cells). Rejected: the summary key set would then be defined and carried by two read faces plus their call sites, and a caller could still pass explicit keys to the complete faces. The derived set plus the existing key-taking faces keeps one definition, one code path, and no per-read key copy. + +**A hardcoded excluded-key list in the session controller.** Rejected: the list would drift from the owning packages, and nothing would fail when a package renamed a key or added a large value. + +**Filtering keys after the complete block is built.** Rejected: the per-row state parse and view work would still happen, and the wire type would keep claiming the excluded keys are present. + +**Making every projection opt in to summaries.** Rejected: it inverts today's default, requires touching every owning package, and silently drops a new large key from summaries whenever a package forgets to opt in. + +**A lazy per-value read for large projections.** Rejected: rows do not need the values at all, and deferring one value per row adds a request shape and loading states the list never had. + +## Consequences + +Bought: a list response no longer scales with outline length or log count, a summary consumer cannot obtain an excluded key through the derived set (the complete faces still serve an explicitly named key), and reading an excluded key from a list row's type is a type error. `wire.summary` is a plain boolean, so keeping a marked key at `false` is the registering package's responsibility rather than a compile error. + +A list row carries the summary projection, never the full projection: it promises the summary key set and nothing more. A connected Session's client store may hold more values from its control/follow baseline, but those are outside the list contract and must not be read from a row; the opened Session's own baselines keep every value. + +No upgrade guide records this change. The externally perceptible part is the wire: `session/list` rows no longer carry the four keys, and a client type that reads `projectionValues.turnOutline` no longer compiles. A consumer migrating reads the opened Session instead — its `session/follow` opening snapshot, the `session/control` baseline, or a `session.projections` read — which keep every projected value; the package README and the subsystem page state the rule. + +Paid: `ProjectionKeySelection` is a new public signature type and `ReadonlySet` joins the catalog's foundation types; `summaryKeys()` has two invalidation points (a new key, the last disposer) that a future registration path must not miss; the summary key set, like the unit table, is process-wide, so a key excluded by one preset is excluded for every Session; and a nullable value cannot be marked directly — its `null` must stay outside the wrapper. + +## Testing + +- [`packages/api/session-controller/tests/summary-list.host.spec.ts`](../../../../packages/api/session-controller/tests/summary-list.host.spec.ts): a list row's keys equal the registry's complete snapshot minus the marked keys, a `session/follow` opening snapshot still carries them with equal values, and two consecutive pulls do not re-add them. +- [`packages/session/session-projection/tests/summary.spec.ts`](../../../../packages/session/session-projection/tests/summary.spec.ts): the derived set, its stable identity, its rebuild after registration and disposal, summary-shaped reads through the key-taking faces, an empty summary set, and the nullable mark. +- [`packages/session/session-projection-cache/tests/cache.spec.ts`](../../../../packages/session/session-projection-cache/tests/cache.spec.ts): a summary read from stored rows omits the marked key and takes its watermark from the served rows only. +- [`packages/api/session-controller/tests/summary-types.client.spec.ts`](../../../../packages/api/session-controller/tests/summary-types.client.spec.ts): `@ts-expect-error` reads of `turnOutline`, `sessionStats`, and `todos` on `SessionSummary.projectionValues`, with the full snapshot still exposing them and `todos` still nullable. +- [`packages/api/session-controller/tests/manager.client.spec.ts`](../../../../packages/api/session-controller/tests/manager.client.spec.ts): a reconnect refresh replaces the row block, so a stale excluded key does not survive. diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md new file mode 100644 index 0000000000..75c753c345 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md @@ -0,0 +1,52 @@ +# Agent Note: Serve Session-list rows from a summary key set + +Status: implemented + +[English](2026-10-18-session-list-summary-projections.md) | 中文 + +## Problem + +Session 列表行携带每个已注册的客户端视图。其中四个视图从不被行渲染:整份日志的 turn outline、整份日志的会话统计、当前 todo 清单,以及宿主级的图片上限。outline 随 Session 日志增长,因此列表响应会随语料规模与日志长度增长,而没有一行展示它。从列表省略取值需要一条规则同时守住 wire 与客户端类型:摘要消费方不能取得被省略的键;被省略的值仍要送达打开的 Session;owning package 不得与驱动省略的声明漂移。 + +## Decision + +Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。 + +- [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。 +- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded | 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`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet`),传入 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。 +- 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。 + +估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。 + +## Alternatives considered + +**为每个来源各做一个摘要专用读取面**(checkpoint 行的 `viewSummaryRecord` 与实时 cell 的 `cachedSummarySnapshot`)。否决:摘要键集合会由两个读取面及其调用点各定义一次,而调用方仍可向完整读取面传显式键。派生集合加既有带键读取面只保留一处定义、一条代码路径,且每次读取无需复制键。 + +**在 session controller 里硬编码被排除键清单。** 否决:清单会与 owning package 漂移,且包改名或新增一个大值时没有任何检查会失败。 + +**先构建完整块再按键过滤。** 否决:逐行的 state 解析与 view 工作仍会发生,而且 wire 类型会继续声称被排除键存在。 + +**让每个投影主动选择加入摘要。** 否决:这反转了当前默认,需要改动每个 owning package,并且某包忘记加入时新的大键会被静默地从摘要中漏掉。 + +**为大投影做按值惰性读取。** 否决:行根本不需要这些值,逐行延迟一个值会引入列表从未有过的请求形状与加载态。 + +## Consequences + +换来的:列表响应不再随 outline 长度或日志数量增长;摘要消费方无法通过派生集合取得被排除的键(完整读取面仍按显式键提供);从列表行的类型读取被排除键是类型错误。`wire.summary` 是普通布尔值,因此让带标记的键保持 `false` 是注册方自己的责任,而不是编译错误。 + +列表行携带的是摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。已连接 Session 的客户端 store 可能因 control/follow 基线持有更多值,但那不属于列表契约,不得从行上读取;打开的 Session 自身的基线保留全部取值。 + +本次没有升级指南记录该变更。外部可感知的部分在 wire:`session/list` 行不再携带四个键,读取 `projectionValues.turnOutline` 的客户端类型不再编译。迁移方式是改读打开的 Session —— 其 `session/follow` 初始快照、`session/control` 基线或 `session.projections` 读取 —— 它们保留全部投影值;包 README 与子系统页写明了该规则。 + +付出的:`ProjectionKeySelection` 是新的公开签名类型,`ReadonlySet` 也需加入 catalog 的 foundation 类型;`summaryKeys()` 有两个失效点(新键、最后一个 disposer),未来的注册路径不得漏掉;摘要键集合与单元表一样是进程级的,某个 preset 排除的键对所有 Session 都排除;可空值不能直接标记 —— 它的 `null` 必须留在包装之外。 + +## Testing + +- [`packages/api/session-controller/tests/summary-list.host.spec.ts`](../../../../packages/api/session-controller/tests/summary-list.host.spec.ts):列表行的键等于注册表完整快照减去被标记的键;`session/follow` 初始快照仍携带它们且值相等;连续两次拉取不会重新加入它们。 +- [`packages/session/session-projection/tests/summary.spec.ts`](../../../../packages/session/session-projection/tests/summary.spec.ts):派生集合、其稳定实例、注册与注销后的重建、经带键读取面的摘要式读取、空摘要集合,以及可空标记。 +- [`packages/session/session-projection-cache/tests/cache.spec.ts`](../../../../packages/session/session-projection-cache/tests/cache.spec.ts):从持久行读取摘要时省略被标记键,且水位只取自被服务的行。 +- [`packages/api/session-controller/tests/summary-types.client.spec.ts`](../../../../packages/api/session-controller/tests/summary-types.client.spec.ts):在 `SessionSummary.projectionValues` 上读取 `turnOutline`、`sessionStats`、`todos` 触发 `@ts-expect-error`,而完整快照仍暴露它们且 `todos` 仍可空。 +- [`packages/api/session-controller/tests/manager.client.spec.ts`](../../../../packages/api/session-controller/tests/manager.client.spec.ts):断线重连刷新会替换行块,残留的被排除键不会存活。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index be5681c61e..ec601aca21 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2914,7 +2914,7 @@ export type JsonlCompression = 'zstd' | 'none' ## `@deepseek-ai/dsh-session-projection-cache` - `inject`: `storageDomain` · `sessionProjections` · `sessions` -- `source`: [`packages/session/session-projection-cache/src/index.ts:75`](../packages/session/session-projection-cache/src/index.ts) +- `source`: [`packages/session/session-projection-cache/src/index.ts:76`](../packages/session/session-projection-cache/src/index.ts) ```ts config-catalog /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 61b506ec56..1837eb869b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2916,7 +2916,7 @@ export type JsonlCompression = 'zstd' | 'none' ## `@deepseek-ai/dsh-session-projection-cache` - `inject`: `storageDomain` · `sessionProjections` · `sessions` -- `source`: [`packages/session/session-projection-cache/src/index.ts:75`](../packages/session/session-projection-cache/src/index.ts) +- `source`: [`packages/session/session-projection-cache/src/index.ts:76`](../packages/session/session-projection-cache/src/index.ts) ```ts config-catalog /** diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 277786cc82..9661237f21 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../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:280`](../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:381`](../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:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:634`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:641`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../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` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 79a63c476c..77f6a926d4 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../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:280`](../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:381`](../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:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:634`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:641`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../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` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ff3ff20d19..624cf55583 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -141,8 +141,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: 0d9e20c2357ae7ac - zh: 23401d59e97d6011 + en: 5332aa18683a642f + zh: 025aa9bd6c4e022f /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -240,8 +240,8 @@ en: 308d4bb4b6b96e35 zh: 308d4bb4b6b96e35 /session-persistence-event-catalog/events/todo/todo-write-log-only: - en: 371e378bc5dc27c3 - zh: 4e12a6d2ec1bd139 + en: e2526a9e54307a41 + zh: 9da1a17aaf6abe6c /session-persistence-event-catalog/events/tool: en: 28a24430609494b2 zh: 28a24430609494b2 @@ -1146,8 +1146,8 @@ en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: 6d66ffc1b393f2f2 - zh: ef6cb30b71f287e7 + en: f75d48e8c151b06d + zh: c0f0dc5f69465285 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1266,8 +1266,8 @@ en: c5fcd2cdd37ea387 zh: 9c678fbdea0bf25c /session-persistence-event-catalog/resolved-persistence-types/todoitem: - en: 06a9fe36dd3ff120 - zh: bf8f6fdeb666c1df + en: ee24f9407df530ac + zh: 310bedf3ac730d77 /session-persistence-event-catalog/resolved-persistence-types/todoitem~2: en: 8bf023246cbd0887 zh: dbf7113574c1bf59 @@ -1347,8 +1347,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 009c7ecb8c7d3bb5 - zh: 7f56d672f376b22f + en: 596f0a10b5a493b9 + zh: 9a2dd1fbe7c29141 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1833,8 +1833,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 9b6318ab4d7eba12 - zh: f475439ef40b2681 + en: a58f339332877b15 + zh: b7406af7d8197b64 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1914,8 +1914,8 @@ en: a2fcd46e54b14120 zh: d9a6e0e50bbff9ad /session-persistence-event-catalog/resolved-persistence-types/todos: - en: 06e6c1837e2b5d50 - zh: 37cebfe3e41294ec + en: 8ab873d30e062fd5 + zh: a138832c6e299ebb /session-persistence-event-catalog/resolved-persistence-types/turn: en: c58b89cec274f956 zh: 056a29ed1ada9690 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 777d356058..3fcc549cde 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -700,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:45`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -1072,7 +1072,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experi Types: [TodoItem](subsystems/todo.md) -Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +Source: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) ### `tool/*` @@ -4337,7 +4337,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:106`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5076,7 +5076,7 @@ Sources: [`packages/llm/llm/src/types.ts:62`](../packages/llm/llm/src/types.ts) SHA-256: `a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a` -Sources: [`packages/todo/tool-todo/src/types.ts:21`](../packages/todo/tool-todo/src/types.ts) +Sources: [`packages/todo/tool-todo/src/types.ts:23`](../packages/todo/tool-todo/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5583,7 +5583,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:409`](../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:414`](../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` @@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:423`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7909,7 +7909,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/exper SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` -Sources: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +Sources: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 43d12bdb49..9e4f34dbe0 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -702,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:45`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -1074,7 +1074,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/todo.zh.md) -来源:[`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +来源:[`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) ### `tool/*` @@ -4339,7 +4339,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:106`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5078,7 +5078,7 @@ SHA-256: `4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c` SHA-256: `a99f76fd149051c363f960bfeeb5b9509587ecaf7591a42797e4bfe810d8987a` -来源:[`packages/todo/tool-todo/src/types.ts:21`](../packages/todo/tool-todo/src/types.ts) +来源:[`packages/todo/tool-todo/src/types.ts:23`](../packages/todo/tool-todo/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:409`](../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:414`](../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` @@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:423`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7911,7 +7911,7 @@ SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` -来源:[`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/src/types.ts) +来源:[`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 5974d8a7be..c4b83e27f8 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23574,7 +23574,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:409", + "packages/api/session-controller/src/types.ts:414", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:21", @@ -24096,7 +24096,7 @@ "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:418" + "packages/api/session-controller/src/types.ts:423" ] }, { @@ -24245,7 +24245,7 @@ "digest": "79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99", "names": [], "sources": [ - "packages/todo/tool-todo/src/types.ts:31" + "packages/todo/tool-todo/src/types.ts:33" ] }, { @@ -24965,7 +24965,7 @@ "packages/todo/tool-todo/src/types.ts#TodoItem" ], "sources": [ - "packages/todo/tool-todo/src/types.ts:21" + "packages/todo/tool-todo/src/types.ts:23" ] }, { @@ -25916,7 +25916,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:101" + "packages/api/session-controller/src/types.ts:106" ] }, { diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 8d53d7ed36..25f948c599 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -11,6 +11,12 @@ /session-projections/the-snapshot-and-the-change-feed: en: a49a57c600c3cbd0 zh: c92f00b8a9d71bad +/session-projections/summary-reads: + en: d2bc9bcc775acfb7 + zh: b1d2ba74496305b8 +/session-projections/summary-reads/summary-vocabulary: + en: 6a93a4e1682fd6e6 + zh: 28729129c3d8a3b1 /session-projections/the-registry-ctx-sessionprojections: en: 50e79c1bc16abb63 zh: 200ecbf0e4ab19d2 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8580b94c4d..69bda39657 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -46,7 +46,7 @@ interface ProjectionDefinition< /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ - viewSchema: ZodType + viewSchema: ZodType> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an @@ -55,7 +55,15 @@ interface ProjectionDefinition< * @param state - the current state. * @returns the whole current value for this unit's key. */ - view(state: NoInfer): SessionProjectionMap[K] + view(state: NoInfer): SessionProjectionViewValue + /** + * Whether a Session summary carries this unit; omitted means `true`. + * Declare `false` on a key whose map entry carries the + * `SessionProjectionSummaryExcluded` mark: the runtime key set follows this + * field, while the client view type follows the mark. + * @default true + */ + summary?: boolean } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -101,6 +109,82 @@ type ProjectionChangeListener = ( `snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. +## Summary reads + +A Session summary carries every registered client view except the keys whose `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` 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. + +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. + +### Summary vocabulary + +The declarations below state the summary rule. The mark, its stripping, the excluded-key test, and the summary value type are declared in the pure-type outlet; the key selection both read faces accept is declared in the service module. + +```ts type-equiv +/** + * One `SessionProjectionMap` entry excluded from Session summary payloads + * (the Session list row and any other summary read). The mark is type-only; + * `T` keeps its exact runtime value and wire representation. Wrap only the + * excluded contributor's own map entry, and place a nullable entry's `null` + * outside the wrapper (`SessionProjectionSummaryExcluded | null`) so + * the required symbol cannot absorb that member. + */ +type SessionProjectionSummaryExcluded = T & { + readonly [sessionProjectionSummaryExcluded]: true +} +``` + +```ts type-equiv +/** + * One map entry's client view value: the summary mark stripped. The + * conditional distributes over a nullable entry's union, so a `| null` member + * written outside the wrapper survives unchanged. + */ +type SessionProjectionViewValue = + T extends SessionProjectionSummaryExcluded ? Value : T +``` + +```ts type-equiv +/** Every client view value, keyed by projection key, with no summary mark. */ +type SessionProjectionViewValues = { + [K in keyof SessionProjectionMap]: SessionProjectionViewValue +} +``` + +```ts type-equiv +/** + * Keys a Session summary omits: map entries whose non-nullish value carries + * the summary mark. Nullish members are ignored, so a nullable entry is still + * recognized when its `null` lives outside the wrapper. + */ +type SessionProjectionSummaryExcludedKey = { + [K in keyof SessionProjectionMap]: + NonNullable extends SessionProjectionSummaryExcluded ? K : never +}[keyof SessionProjectionMap] +``` + +```ts type-equiv +/** + * The client values a Session summary carries: every client view value except + * {@link SessionProjectionSummaryExcludedKey}. A summary read returns these + * keys only; an opened Session's full snapshot keeps every view value. + */ +type SessionProjectionSummaryValues = + Omit +``` + +```ts type-equiv +/** + * One read's client-visible key selection: an explicit key list, or a derived + * set such as {@link SessionProjectionRegistry.summaryKeys}. A set is used as + * given; only a list is copied into one. + */ +type ProjectionKeySelection = + | readonly Extract[] + | ReadonlySet> +``` + ## The registry: `ctx.sessionProjections` `SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. @@ -139,7 +223,7 @@ The persisted projection cache service. Opens the `session_projcache` domain at * @returns the viewed block, or `undefined` when no usable row exists for * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -252,7 +336,7 @@ stateOf( session: Session, key: K, ): * @param keys - optional client-visible outputs; state materialization remains complete. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ -snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot +snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot /** * Read only already-materialized client-visible cells without folding history. @@ -262,7 +346,18 @@ snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +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())` + * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. + * @returns the current summary key set; do not mutate it. + */ +summaryKeys(): ReadonlySet> /** * State-level checkpoint of every persisted unit for one session, read @@ -309,7 +404,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. */ -viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial +viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial /** * Cold read: fold every persisted unit over a stored log suffix, seeding diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b98d348a36..8385dddb3b 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -46,7 +46,7 @@ interface ProjectionDefinition< /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ - viewSchema: ZodType + viewSchema: ZodType> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an @@ -55,7 +55,15 @@ interface ProjectionDefinition< * @param state - the current state. * @returns the whole current value for this unit's key. */ - view(state: NoInfer): SessionProjectionMap[K] + view(state: NoInfer): SessionProjectionViewValue + /** + * Whether a Session summary carries this unit; omitted means `true`. + * Declare `false` on a key whose map entry carries the + * `SessionProjectionSummaryExcluded` mark: the runtime key set follows this + * field, while the client view type follows the mark. + * @default true + */ + summary?: boolean } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -101,6 +109,82 @@ type ProjectionChangeListener = ( `snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 +## 摘要读取 + +Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目带 `SessionProjectionSummaryExcluded` 标记的 key。标记没有运行时表示:它决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段是普通布尔值,类型层不做强制。 + +`summaryKeys()` 从实时注册派生该集合,并在注册或注销改变 key 表之前持续返回同一实例。摘要消费方把它传给既有的带 key 读取面 —— 缓存 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,两者保持各自的水位规则且直接使用 Set、不做复制 —— 因此不存在摘要专用读取面,集合也只有一处定义。 + +列表行携带的是这个摘要投影,绝不是全量投影:它只承诺摘要键集合,不承诺更多。`snapshot`、`cachedSnapshot` 与 `viewCheckpoint` 在不传 key 子集时仍是完整的,打开的 Session、其 follow 基线与控制基线因此保留每个投影值;`@` 引用只通过同一组带 key 的读取面读取其标签 key。 + +### 摘要词汇 + +以下声明构成摘要规则。标记、其剥离、被排除键的判定与摘要值类型声明在纯类型出口;两个读取面共同接受的 key 选择声明在服务模块。 + +```ts type-equiv +/** + * One `SessionProjectionMap` entry excluded from Session summary payloads + * (the Session list row and any other summary read). The mark is type-only; + * `T` keeps its exact runtime value and wire representation. Wrap only the + * excluded contributor's own map entry, and place a nullable entry's `null` + * outside the wrapper (`SessionProjectionSummaryExcluded | null`) so + * the required symbol cannot absorb that member. + */ +type SessionProjectionSummaryExcluded = T & { + readonly [sessionProjectionSummaryExcluded]: true +} +``` + +```ts type-equiv +/** + * One map entry's client view value: the summary mark stripped. The + * conditional distributes over a nullable entry's union, so a `| null` member + * written outside the wrapper survives unchanged. + */ +type SessionProjectionViewValue = + T extends SessionProjectionSummaryExcluded ? Value : T +``` + +```ts type-equiv +/** Every client view value, keyed by projection key, with no summary mark. */ +type SessionProjectionViewValues = { + [K in keyof SessionProjectionMap]: SessionProjectionViewValue +} +``` + +```ts type-equiv +/** + * Keys a Session summary omits: map entries whose non-nullish value carries + * the summary mark. Nullish members are ignored, so a nullable entry is still + * recognized when its `null` lives outside the wrapper. + */ +type SessionProjectionSummaryExcludedKey = { + [K in keyof SessionProjectionMap]: + NonNullable extends SessionProjectionSummaryExcluded ? K : never +}[keyof SessionProjectionMap] +``` + +```ts type-equiv +/** + * The client values a Session summary carries: every client view value except + * {@link SessionProjectionSummaryExcludedKey}. A summary read returns these + * keys only; an opened Session's full snapshot keeps every view value. + */ +type SessionProjectionSummaryValues = + Omit +``` + +```ts type-equiv +/** + * One read's client-visible key selection: an explicit key list, or a derived + * set such as {@link SessionProjectionRegistry.summaryKeys}. A set is used as + * given; only a list is copied into one. + */ +type ProjectionKeySelection = + | readonly Extract[] + | ReadonlySet> +``` + ## 注册表:`ctx.sessionProjections` `SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 @@ -139,7 +223,7 @@ The persisted projection cache service. Opens the `session_projcache` domain at * @returns the viewed block, or `undefined` when no usable row exists for * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -252,7 +336,7 @@ stateOf( session: Session, key: K, ): * @param keys - optional client-visible outputs; state materialization remains complete. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */ -snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot +snapshot( session: Session, keys?: ProjectionKeySelection, ): ProjectionSnapshot /** * Read only already-materialized client-visible cells without folding history. @@ -262,7 +346,18 @@ snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +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())` + * or `viewCheckpoint(rows, summaryKeys())`; those faces use the set as-is. + * @returns the current summary key set; do not mutate it. + */ +summaryKeys(): ReadonlySet> /** * State-level checkpoint of every persisted unit for one session, read @@ -309,7 +404,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. */ -viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial +viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial /** * Cold read: fold every persisted unit over a stored log suffix, seeding diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index e74cd9dce5..298ff2183b 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -3,14 +3,19 @@ // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import type { SessionSummary } from '../../types.ts' /** Host list summary enriched with the latest Session Controller title projection. */ export interface TitledSessionSummary extends SessionSummary { title?: string - /** Current host-computed projection values for list consumers. */ - projectionValues?: Readonly> + /** + * The row's summary projection values, never the full projection set. A + * connected Session's client store may hold more values from its + * control/follow baseline; those are outside the list contract and must not + * be read here. + */ + projectionValues?: Readonly> } /** One flattened session-list row with lineage depth. */ @@ -30,8 +35,11 @@ export interface SessionListEntry { /** Format classification supplied by the Host list. */ formatStatus?: SessionSummary['formatStatus'] cwd?: string - /** Current host-computed projection values for list consumers. */ - projectionValues?: Readonly> + /** + * The row's summary projection values, never the full projection set; see + * `TitledSessionSummary.projectionValues`. + */ + projectionValues?: Readonly> /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index b8bec9be8a..f800a8c2be 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -2,7 +2,7 @@ import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionMap, SessionProjectionViewValues } from '@deepseek-ai/dsh-session-projection/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, @@ -58,9 +58,14 @@ export interface SessionListSnapshot { } -/** Shared projection values and the lifecycle of their explicit baseline read. */ +/** + * Every projection value the store currently holds, with the summary mark + * stripped, and the lifecycle of its explicit baseline read. This is the full + * attached-Session set, not a list row's summary contract (see + * `SessionListEntry.projectionValues`). + */ export interface SessionProjectionSnapshot { - readonly values: Readonly> + readonly values: Readonly> readonly state: 'idle' | 'loading' | 'ready' | 'migration-required' | 'error' readonly error: RemoteFailure | null } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index b992a34920..ee409f5fb6 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -5,7 +5,7 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import { createSnapshotStore, notifySubscribers, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' @@ -49,8 +49,12 @@ export interface SessionSummary { */ blank: boolean updatedAt: number - /** Current host-computed projection values retained by the object layer. */ - projectionValues?: Readonly> + /** + * The row's summary projection values retained by the object layer, never the + * full projection set. A connected Session's store may hold more values from + * its control/follow baseline; those are outside the list contract. + */ + projectionValues?: Readonly> } /** Catalog metadata and local source counts; catalog membership owns no Client generation. */ diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d7ef1ad43c..d14c4fbd17 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -16,7 +16,7 @@ import { SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, } from './types.ts' import type { - SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem, + SessionListMetadata, SessionProjectionHints, SessionSearchItem, SessionSearchValue, SessionSummary, } from './types.ts' @@ -98,6 +98,7 @@ export class ApiSessionList { wire: { viewSchema: imageLimitsSchema, view: () => attachmentCtx.attachments.imageLimits, + summary: false, }, stateVersion: 1, }) @@ -293,16 +294,21 @@ export class ApiSessionList { session: Session | undefined, ): SessionProjectionHints | undefined { try { + // The registry derives the summary key set from its registrations and + // hands back one stable set until the key table changes; both faces below + // serve only those keys, so a list row cannot carry a unit that declares + // itself summary-excluded. + const keys = this.ctx.sessionProjections.summaryKeys() if (session !== undefined) { // The live registry computed 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)) + return hintsOf('sequenced', this.ctx.sessionProjections.cachedSnapshot(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 // fold. The watermark is the stored record's own. const cache = this.ctx.get('sessionProjectionCache') - return hintsOf('cached', cache?.cachedSnapshot(header) ?? cache?.cachedPredecessorTitle(header)) + return hintsOf('cached', cache?.cachedSnapshot(header, keys) ?? cache?.cachedPredecessorTitle(header)) } catch (error) { this.ctx.logger.warn( `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, @@ -313,7 +319,8 @@ export class ApiSessionList { } /** - * Wrap one projection block as Session-list hints of the named sequence space. + * Wrap one summary projection block as Session-list hints of the named sequence space. + * The block is the summary key set's values, not every value the source holds. * @param kind - which sequence space the block's watermark belongs to. * @param block - the block, or `undefined` when no source served one. * @returns the hints, or `undefined` when the block is absent or carries no value. @@ -323,9 +330,9 @@ function hintsOf( block: ProjectionSnapshot | undefined, ): SessionProjectionHints | undefined { if (block === undefined || Object.keys(block.values).length === 0) return undefined - // Listing hints contain every wire value the source currently holds but - // remain partial: missing cells and cache rows are never materialized here. - return { kind, asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues } + // Listing hints contain every summary wire value the source currently holds + // but remain partial: missing cells and cache rows are never materialized here. + return { kind, asOfSeq: block.asOfSeq, values: block.values } } function normalizeSearchQuery(query: string): string { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b2b9a573..dcde6460ab 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -8,7 +8,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { SessionProjectionMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -25,8 +25,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata - /** Image-intake limits enforced by the Session prompt endpoint. */ - imageLimits: ImageAttachmentLimits + /** + * Image-intake limits enforced by the Session prompt endpoint. Boot + * constant, excluded from Session summary payloads: every Session shares + * it, so a list row would repeat one host-wide fact. + */ + imageLimits: SessionProjectionSummaryExcluded /** Durable model selection already used and selected for the next request. */ modelSelection: ModelSelectionProjection } @@ -51,10 +55,11 @@ export interface SessionListMetadata { } /** - * Every available wire value a Session-list row carries as partial, possibly - * stale hints. `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. + * 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 + * which sequence space `asOfSeq` belongs to, and therefore how a client may + * merge the block; `asOfSeq` is the producer's watermark in that space. */ export interface SessionProjectionHints { /** @@ -67,8 +72,8 @@ export interface SessionProjectionHints { readonly kind: 'cached' | 'sequenced' /** Watermark of the block in the sequence space named by `kind`. */ readonly asOfSeq: number - /** Provider-validated values present in the block; omitted keys remain unknown. */ - readonly values: SessionProjectionValues + /** Provider-validated summary values present in the block; omitted keys remain unknown. */ + readonly values: Partial } /** Complete projection values at an exact Session event cursor. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index bcf4a033be..a4f9367735 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -17,6 +17,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { ClientSessions } from '../src/client/sessions/service.ts' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' +import type { SessionProjectionHints } from '../src/types.ts' import { entries, plainTurn } from './event-script.client.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -1613,3 +1614,30 @@ describe('running facts without UI reminders', () => { expect(manager.getListSnapshot()).not.toHaveProperty('current') }) }) + +describe('Session summary projection replacement', () => { + it('drops a stale summary-excluded key when a reconnect refresh replaces the row block', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + // A row block from a verbose Host still carrying an excluded key; the wire + // type no longer admits it, so the fixture is parsed as annotated JSON. + const verboseBlock = JSON.parse( + '{"kind":"cached","asOfSeq":1,"values":{"title":"stale","turnOutline":[]}}', + ) as SessionProjectionHints + remote.session.list.mockResolvedValueOnce(ok({ items: [{ ...summary(S1), projections: verboseBlock }] })) + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.projectionValues).toHaveProperty('turnOutline') + + // A reconnect clears every per-Session value store before pulling again, so + // the refreshed summary block is the row's only source: the excluded key + // cannot survive the replacement. + remote.session.list.mockResolvedValueOnce(ok({ + items: [{ ...summary(S1), projections: { kind: 'cached', asOfSeq: 2, values: { title: 'fresh' } } }], + })) + manager.handleConnected() + await manager.refreshList() + const entry = manager.getListSnapshot().items[0] + expect(entry?.projectionValues).toEqual({ title: 'fresh' }) + expect(entry?.title).toBe('fresh') + }) +}) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index ed4f4a6b0c..1c216cbcd5 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -532,7 +532,7 @@ describe('session.list projections column', () => { expect(row !== undefined && 'projections' in row).toBe(false) }) - it('serves every available cold projection hint from the cache with zero log loads', async () => { + it('serves the summary projection hints from the cache with zero log loads', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-listing') const load = () => { throw new Error('list must not load event logs') } @@ -541,10 +541,12 @@ describe('session.list projections column', () => { inspect: load, open: load, }) as never) + let seenKeys: unknown ctx.provide('sessionProjectionCache', { // The carrier hands the listed header through as the identity witness. - cachedSnapshot: (meta: { id: unknown; createdAt: number }) => - (meta.id === coldId && meta.createdAt === 5 + cachedSnapshot: (meta: { id: unknown; createdAt: number }, keys: unknown) => { + seenKeys = keys + return meta.id === coldId && meta.createdAt === 5 ? { asOfSeq: SessionSeq(7), values: { @@ -553,7 +555,8 @@ describe('session.list projections column', () => { title: 'Cached title', }, } - : undefined), + : undefined + }, } as never) const response = await remote(ctx).list(request({})) if (!response.ok) throw new Error('unreachable') @@ -568,6 +571,8 @@ describe('session.list projections column', () => { title: 'Cached title', }, }) + // The cold branch passes the registry's own summary set, not a fresh list. + expect(seenKeys).toBe(ctx.sessionProjections.summaryKeys()) }) it('keeps persisted host-only state out of a cold session.list response', async () => { diff --git a/packages/api/session-controller/tests/summary-list.host.spec.ts b/packages/api/session-controller/tests/summary-list.host.spec.ts new file mode 100644 index 0000000000..041d79e094 --- /dev/null +++ b/packages/api/session-controller/tests/summary-list.host.spec.ts @@ -0,0 +1,146 @@ +/** + * 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 + * `session/follow` opening snapshot keeps serving them. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { + apply as sessionStatsApply, inject as sessionStatsInject, name as sessionStatsName, +} from '@deepseek-ai/dsh-session-stats' +import { + apply as turnOutlineApply, inject as turnOutlineInject, name as turnOutlineName, +} from '@deepseek-ai/dsh-session-turn-outline' +import { + apply as toolTodoApply, Config as toolTodoConfig, inject as toolTodoInject, name as toolTodoName, +} from '@deepseek-ai/dsh-tool-todo' +import { createSessionTestRemote } from './test-remote.ts' + +const SUMMARY_EXCLUDED_KEYS = ['turnOutline', 'sessionStats', 'todos', 'imageLimits'] as const + +const ownedContexts = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() +}) + +const remote = (ctx: Context) => createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', +}) + +/** Read and close one snapshot-first follow generation. */ +async function opening( + ctx: Context, + sessionId: SessionId, +): Promise> { + const abort = new AbortController() + const iterator = remote(ctx).follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() + const first = await iterator.next() + abort.abort() + await iterator.return?.() + if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot') + return first.value +} + +/** + * Mount the registry plus the four 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. + */ +async function harness(): Promise<{ ctx: Context; sessionContext: Context }> { + const ctx = new Context() + ownedContexts.add(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.provide('tools', { register: () => () => {} } as never) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.plugin({ name: turnOutlineName, inject: turnOutlineInject, apply: turnOutlineApply }) + await ctx.plugin({ name: sessionStatsName, inject: sessionStatsInject, apply: sessionStatsApply }) + await ctx.plugin({ + name: toolTodoName, + inject: toolTodoInject, + Config: toolTodoConfig, + apply: toolTodoApply, + }, { allowParallelInProgress: false }) + // 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`. + remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + return { ctx, sessionContext: ctx } +} + +/** One Session with a closed turn, a tool-free todo write, and a preset. */ +function seedSession(ctx: Context): { id: SessionId } { + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace', agentPreset: 'standard' } }) + session.append('turn/start', { turn: 1 }) + session.append('todo/write', { todos: [{ content: 'ship it', status: 'in_progress' }] }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return { id: session.id } +} + +describe('session.list summary payload', () => { + it('carries every registered wire key except the summary-excluded units', async () => { + const { ctx } = await harness() + const { id } = seedSession(ctx) + const session = ctx.sessions.get(id) + if (session === undefined) throw new Error('session was not created') + const everyWireKey = Object.keys(ctx.sessionProjections.snapshot(session).values) + // The fixture must actually exercise the excluded units. + for (const key of SUMMARY_EXCLUDED_KEYS) expect(everyWireKey).toContain(key) + + expect([...ctx.sessionProjections.summaryKeys()]).not.toContain('turnOutline') + + const response = await remote(ctx).list({}) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + const served = Object.keys(row?.projections?.values ?? {}) + expect(served.sort()).toEqual(everyWireKey.filter(key => !SUMMARY_EXCLUDED_KEYS.includes( + key as (typeof SUMMARY_EXCLUDED_KEYS)[number], + )).sort()) + for (const key of SUMMARY_EXCLUDED_KEYS) expect(served).not.toContain(key) + // An ordinary wire unit is still there with its value. + expect(row?.projections?.values.sessionListMetadata).toMatchObject({ blank: false }) + }) + + it('keeps the excluded units on an opened Session\'s follow snapshot', async () => { + const { ctx } = await harness() + const { id } = seedSession(ctx) + const session = ctx.sessions.get(id) + if (session === undefined) throw new Error('session was not created') + const everyValue = ctx.sessionProjections.snapshot(session).values + + const snapshot = await opening(ctx, id) + for (const key of SUMMARY_EXCLUDED_KEYS) { + expect(snapshot.projections.values).toHaveProperty(key) + expect(snapshot.projections.values[key as keyof typeof snapshot.projections.values]) + .toEqual(everyValue[key as keyof typeof everyValue]) + } + expect(snapshot.projections.values.todos).toEqual([{ content: 'ship it', status: 'in_progress' }]) + expect(snapshot.projections.values.imageLimits).toEqual(ctx.attachments.imageLimits) + }) + + it('drops the excluded units from the list while a Session is still attached', async () => { + const { ctx } = await harness() + const { id } = seedSession(ctx) + // Two consecutive pulls: a refresh must not reintroduce a dropped unit. + for (const attempt of [0, 1]) { + const response = await remote(ctx).list({}) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('turnOutline') + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('sessionStats') + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('todos') + expect(row?.projections?.values, `attempt ${String(attempt)}`).not.toHaveProperty('imageLimits') + } + }) +}) diff --git a/packages/api/session-controller/tests/summary-types.client.spec.ts b/packages/api/session-controller/tests/summary-types.client.spec.ts new file mode 100644 index 0000000000..3900aebfd4 --- /dev/null +++ b/packages/api/session-controller/tests/summary-types.client.spec.ts @@ -0,0 +1,43 @@ +/** + * Type-level contract of the Session summary projection values: the list row + * carries every view value except the units that declare `summary: false`, + * while an opened Session's full projection snapshot keeps all of them. + */ + +import { describe, expect, it } from 'vitest' +import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/types' +import type {} from '@deepseek-ai/dsh-session-stats/types' +import type {} from '@deepseek-ai/dsh-session-turn-outline/types' +import type { SessionSummary } from '../src/client/sessions/service.ts' +import type { SessionProjectionSnapshot } from '../src/client/sessions/manager.ts' + +/** + * Compile-time contract, never called: each `@ts-expect-error` fails the + * typecheck gate if a summary value type ever regains an excluded key. + * @param summary - a Host list row summary. + * @param full - an opened Session's complete projection snapshot. + */ +function contract(summary: SessionSummary, full: SessionProjectionSnapshot): void { + // @ts-expect-error turnOutline is excluded from Session summary values + void summary.projectionValues?.turnOutline + // @ts-expect-error sessionStats is excluded from Session summary values + void summary.projectionValues?.sessionStats + // @ts-expect-error todos is excluded from Session summary values + void summary.projectionValues?.todos + // @ts-expect-error imageLimits is excluded from Session summary values + void summary.projectionValues?.imageLimits + + // Included keys stay readable, and the full snapshot keeps the excluded ones + // (with `todos` still nullable). + const title: string | null | undefined = summary.projectionValues?.title + const outline: readonly unknown[] | undefined = full.values.turnOutline + const todos: readonly TodoItem[] | null | undefined = full.values.todos + const limits = full.values.imageLimits + void [title, outline, todos, limits] +} + +describe('session summary projection types', () => { + it('declares the excluded keys on the list row type only through the full snapshot', () => { + expect(contract).toBeTypeOf('function') + }) +}) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 1dd6bf22b9..aa66cbc151 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -913,7 +913,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionProjectionSnapshot', - declaration: 'export interface SessionProjectionSnapshot {\n readonly values: Readonly>;\n readonly state: \'idle\' | \'loading\' | \'ready\' | \'migration-required\' | \'error\';\n readonly error: RemoteFailure | null;\n}', + declaration: 'export interface SessionProjectionSnapshot {\n readonly values: Readonly>;\n readonly state: \'idle\' | \'loading\' | \'ready\' | \'migration-required\' | \'error\';\n readonly error: RemoteFailure | null;\n}', }, { name: 'SessionProviderComponent', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cca1649f96..a5d48d2eca 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2313,7 +2313,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.', methods: [ { - signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + signature: 'cachedSnapshot( meta: SessionHeader, keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined', description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only) of the record bound to the caller\'s lifecycle. The header is the only identity witness a listing holds, so this face matches the lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) and not the inherited cut: within one format generation the cut is fixed at fork time, so it distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold. The view is as stale as the last durable checkpoint but never wrong and never from an unrelated log. Its `asOfSeq` is the lowest watermark among the served rows: the stored record\'s own position, which the header cannot relate to the log the caller later opens. The Session list therefore labels the block as cached, and the client lets every value the connected Session produces supersede it whatever this number says.', parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], returns: 'the viewed block, or `undefined` when no usable row exists for this lifecycle at the current Session format.', @@ -2374,17 +2374,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'current state, or `undefined` when the key is not registered.', }, { - signature: 'snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot', + 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.' }], returns: 'the snapshot; `values` is empty when no selected client-visible unit is registered.', }, { - signature: 'cachedSnapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + 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>', + 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.', + parameters: [], + returns: 'the current summary key set; do not mutate it.', + }, { signature: 'checkpoint(session: Session): ProjectionCheckpoint', description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total).', @@ -2398,7 +2404,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the offset for the stored-log suffix read (`SessionHandle.read`), or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', }, { - signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial', + signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: ProjectionKeySelection, ): Partial', description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent (a cold or listing consumer treats it as not-yet-available and a fuller read path refolds it). The zero-I/O rung of the read ladder — values are as stale as their rows, never wrong.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'keys', description: 'optional wire keys to view.' }], returns: 'whole values per key with a usable row; empty when none.', @@ -6570,7 +6576,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType>;\n view(state: NoInfer): SessionProjectionViewValue;\n summary?: boolean;\n } : never;\n stateVersion: number;\n}', + }, + { + name: 'ProjectionKeySelection', + declaration: 'export type ProjectionKeySelection = readonly Extract[] | ReadonlySet>;', }, { name: 'ProjectionSnapshot', @@ -7362,7 +7372,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionProjectionHints', - declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', + declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: Partial;\n}', }, { name: 'SessionProjectionMap', @@ -7376,6 +7386,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionStateMap', declaration: 'export interface SessionProjectionStateMap {\n}', }, + { + name: 'SessionProjectionSummaryExcluded', + declaration: 'export type SessionProjectionSummaryExcluded = T & {\n readonly [sessionProjectionSummaryExcluded]: true;\n};', + }, + { + name: 'SessionProjectionSummaryExcludedKey', + declaration: 'export type SessionProjectionSummaryExcludedKey = {\n [K in keyof SessionProjectionMap]: NonNullable extends SessionProjectionSummaryExcluded ? K : never;\n}[keyof SessionProjectionMap];', + }, + { + name: 'SessionProjectionSummaryValues', + declaration: 'export type SessionProjectionSummaryValues = Omit;', + }, { name: 'SessionProjectionsValue', declaration: 'export type SessionProjectionsValue = (SessionProjectionBaseline & {\n readonly kind: \'sequenced\';\n}) | {\n readonly kind: \'migration-required\';\n readonly values: SessionProjectionValues;\n} | null;', @@ -7392,6 +7414,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionValues', declaration: 'export type SessionProjectionValues = Partial & Readonly>;', }, + { + name: 'SessionProjectionViewValue', + declaration: 'export type SessionProjectionViewValue = T extends SessionProjectionSummaryExcluded ? Value : T;', + }, + { + name: 'SessionProjectionViewValues', + declaration: 'export type SessionProjectionViewValues = {\n [K in keyof SessionProjectionMap]: SessionProjectionViewValue;\n};', + }, { name: 'SessionPromptRequest', declaration: 'export interface SessionPromptRequest {\n readonly requestId: SessionRequestId;\n readonly sessionId: SessionId;\n readonly mode: \'queue\' | \'steer\';\n readonly content: readonly PromptContentPart[];\n readonly clientTimeZone?: string;\n}', diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 34726fc3b8..10119d26a3 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -28,6 +28,7 @@ import type { } from '@deepseek-ai/dsh-session' import type { ProjectionCheckpoint, + ProjectionKeySelection, ProjectionSnapshot, SessionProjectionMap, } from '@deepseek-ai/dsh-session-projection' @@ -160,7 +161,7 @@ export class SessionProjectionCache extends Service { */ cachedSnapshot( meta: SessionHeader, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined { const expected = lifecycleIdentityOf(meta) const record = this.requireTable().get(meta.id) @@ -199,7 +200,7 @@ export class SessionProjectionCache extends Service { */ private viewRecord( record: CheckpointRecord, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined { const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) let asOfSeq: ProjectionSnapshot['asOfSeq'] | undefined diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 87860844ee..95b4ad92c4 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -26,6 +26,7 @@ import SessionStore, { import type { SessionEvent, SessionHeader } 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 Storage from '@deepseek-ai/dsh-storage' import { apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName, @@ -45,12 +46,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/count': number 'cache-test/secret': string 'cache-test/marks3': MarksState + 'cache-test/summary-excluded': { n: number } title: string | null } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } 'cache-test/secondary-marks': { marks: string[] } 'cache-test/marks3': { marks: string[] } + 'cache-test/summary-excluded': SessionProjectionSummaryExcluded<{ n: number }> title: string | null } } @@ -110,6 +113,16 @@ const secondaryMarksUnit = { stateVersion: 1, } satisfies ProjectionDefinition<'cache-test/secondary-marks', MarksState> +/** A summary-excluded unit: its rows must never reach the summary block. */ +const summaryExcludedUnit = { + key: 'cache-test/summary-excluded', + stateSchema: z.object({ n: z.number() }), + init: () => ({ n: 0 }), + apply: state => state, + wire: { viewSchema: z.object({ n: z.number() }), view: state => state, summary: false }, + stateVersion: 1, +} satisfies ProjectionDefinition<'cache-test/summary-excluded', { n: number }> + /** Mirrors the shipped title unit's storage face (stateVersion 1, bare-string state). */ const titleUnit = { key: 'title', @@ -809,3 +822,33 @@ describe('SessionProjectionCache cold-read seeding', () => { }, { timeout: 5_000 }) }) }) + +describe('SessionProjectionCache summary reads', () => { + it('serves the summary block from stored rows and excludes the marked unit', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + await seedRecord(root, 'summary-watermark', { + 'cache-test/marks': { ver: 1, seq: SessionSeq(5), val: { marks: ['primary'] } }, + 'cache-test/summary-excluded': { ver: 1, seq: SessionSeq(1), val: { n: 3 } }, + }) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(summaryExcludedUnit) + const summaryKeys = ctx.sessionProjections.summaryKeys() + + // The summary watermark follows the served rows only: the marked row sits + // at seq 1, and its absence must not drag the summary block down. + expect(cache.cachedSnapshot(headerOf(SessionId('summary-watermark')), summaryKeys)).toEqual({ + asOfSeq: 5, + values: { 'cache-test/marks': { marks: ['primary'] } }, + }) + expect(cache.cachedSnapshot(headerOf(SessionId('summary-watermark')))).toEqual({ + asOfSeq: 1, + values: { + 'cache-test/marks': { marks: ['primary'] }, + 'cache-test/summary-excluded': { n: 3 }, + }, + }) + // No record for this id: a key-subset read reports absence like the complete face. + expect(cache.cachedSnapshot(headerOf(SessionId('summary-absent')), summaryKeys)).toBeUndefined() + }) +}) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c6305ff877..4907213c01 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -27,8 +27,11 @@ en: 6a19c075f826b3f7 zh: c200277cb58062be /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: - en: 45f1c05fb7e08180 - zh: f8d62c749899651a + en: 3bbc61a438aeabd0 + zh: bd1824f4da278764 +/deepseek-ai-dsh-session-projection/use-this-package/session-summaries: + en: bbdefde61fb3b02f + zh: c6871e05e63ff4ed /deepseek-ai-dsh-session-projection/understand-the-implementation: en: 3397f58d6f49bfda zh: efb85d382e25574d @@ -36,8 +39,8 @@ en: 8afa0fdb49898f6d zh: 6b2da34455963935 /deepseek-ai-dsh-session-projection/understand-the-implementation/source-map: - en: b0263d26c5a65d7a - zh: 2e58d3181dde901c + en: 01b1a2ad7a7cab97 + zh: bd0436807998dda6 /deepseek-ai-dsh-session-projection/understand-the-implementation/drive-and-checkpoint-flow: en: 00136880bb586403 zh: bd35f645111c068c @@ -51,8 +54,8 @@ en: 8a592dae9bc946ab zh: eca939995bb5f8e0 /deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work: - en: 8fd41113832feb7c - zh: d50021b135278d77 + en: dda14db5a0fbe2fa + zh: 9675b2a39e7aea8b /deepseek-ai-dsh-session-projection/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 79c5e0b213..97c8b4c6ab 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -68,6 +68,14 @@ A domain that requires projected state declares `sessionProjections` as a Cordis Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. +### Session summaries + +A Session summary is every registered client view except the keys whose non-nullish `SessionProjectionMap` entry carries the `SessionProjectionSummaryExcluded` 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. + +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 | 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. + +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. + ----- @@ -87,7 +95,7 @@ The package is the Service Definition and drive role of a capability seam: the f | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionProjectionRegistry` service, `ProjectionDefinition`, snapshot and checkpoint machinery | -| [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables | +| [`src/types.ts`](src/types.ts) | The merge-extensible `SessionProjectionMap` and `SessionProjectionStateMap` type tables, plus the summary-exclusion mark and its derived view and summary types | ### Drive and checkpoint flow @@ -126,7 +134,7 @@ None; projections never assemble or send provider requests. These limits define where the projection registry needs care at scale. They are current package constraints, not a task backlog. -- **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states, revisit if a domain's value grows large. +- **Only a key-subset read omits keys; a tail page still carries every client-visible key and there is no lazy per-value request** — a summary omits exactly the keys whose `SessionProjectionMap` entry carries `SessionProjectionSummaryExcluded` and whose registration declares `wire.summary: false`. The opt-out is per key, not per value: no read defers one large value. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index cb44e32ef9..2d6673979d 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -68,6 +68,14 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) 系统通过 `checkpoint(session)` 为每个单元的状态创建检查点,client-visible 与 host-only 一视同仁;同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 实现读取流程,且不会混淆已有事件与日志间隙。 +### Session 摘要 + +Session 摘要包含每个已注册客户端视图,但排除 `SessionProjectionMap` 条目(非空值)带 `SessionProjectionSummaryExcluded` 标记的 key。`summaryKeys()` 返回该集合,从实时注册派生;在注册或注销改变 key 表之前,它保持同一实例。摘要消费方把它传给既有的带 key 读取面 —— 实时 cell 用 `cachedSnapshot(session, keys)`,checkpoint 行用 `viewCheckpoint(rows, keys)`,持久化缓存用 `cachedSnapshot(header, keys)` —— 这些读取面把 Set 视为已选好的集合,因此不存在摘要专用读取面、每次读取无需复制,集合也只有一处定义。由于集合由注册表派生,摘要消费方无法通过该集合请求被排除的 key;完整读取面仍会按显式命名的 key 提供值,打开的 Session 因此保留全部取值。列表行携带的是摘要投影,绝不是全量投影。 + +标记决定客户端视图类型,`wire.summary` 决定运行时键集合,省略即 `true`。map 条目带标记的 key 应声明 `wire.summary: false` 以保持一致;该字段就是普通布尔值,而不是类型层的互锁。可空条目把 `null` 放在包装之外(`SessionProjectionSummaryExcluded | null`),以便首次写入前的值仍可表达。当前被标记的 key 是 `turnOutline`、`sessionStats`、`todos` 和 `imageLimits`:outline 随整份日志增长,列表行不渲染统计或 todo 清单,而图片上限是每个 Session 共享的同一个宿主级启动常量。 + +摘要服务于 Session 列表行,列表无需打开 Session。`@` 引用同样以列表方式枚举 Session,但只通过带 key 的 `snapshot(session, keys)` 与 `cachedSnapshot(header, keys)` 读取其 `title` 和 `subagent` 标签 key。`snapshot`、`cachedSnapshot` 和 `viewCheckpoint` 在不传 key 子集时仍返回每个 wire key,因此打开的 Session 仍会收到全部投影值。 + ----- @@ -87,7 +95,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionProjectionRegistry` 服务、`ProjectionDefinition`、快照与检查点机制 | -| [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表 | +| [`src/types.ts`](src/types.ts) | 可合并扩展的 `SessionProjectionMap` 与 `SessionProjectionStateMap` 类型表,以及摘要排除标记及其派生的视图与摘要类型 | ### 驱动与检查点流程 @@ -126,7 +134,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) 这些限制说明投影注册表在大规模下何时需要特别处理。它们是当前包约束,不是任务积压。 -- **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态时可以接受,若某领域的值变大再重议。 +- **只有带 key 子集的读取才省略 key;尾页仍携带每个客户端可见 key,也不存在惰性逐值请求**——摘要只省略那些 `SessionProjectionMap` 条目带有 `SessionProjectionSummaryExcluded` 且注册声明了 `wire.summary: false` 的 key。该 opt-out 按 key 生效,不按值生效:没有任何读取会延后加载单个大值。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——任何 agent preset 注册的 key 都会出现在每个会话的快照里;客户端必须读值,不能把 key 缺席当作功能缺席。 - **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则与 state/view 引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index ac6e63458f..b196567ad5 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -33,9 +33,21 @@ declare module '@deepseek-ai/cordis' { } } -import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +import type { + SessionProjectionMap, + SessionProjectionStateMap, + SessionProjectionViewValue, +} from './types.ts' -export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +export type { + SessionProjectionMap, + SessionProjectionStateMap, + SessionProjectionSummaryExcluded, + SessionProjectionSummaryExcludedKey, + SessionProjectionSummaryValues, + SessionProjectionViewValue, + SessionProjectionViewValues, +} from './types.ts' /** * One domain's state-driven computation unit: a pure synchronous fold plus @@ -72,7 +84,7 @@ export interface ProjectionDefinition< /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ - viewSchema: ZodType + viewSchema: ZodType> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an @@ -81,7 +93,15 @@ export interface ProjectionDefinition< * @param state - the current state. * @returns the whole current value for this unit's key. */ - view(state: NoInfer): SessionProjectionMap[K] + view(state: NoInfer): SessionProjectionViewValue + /** + * Whether a Session summary carries this unit; omitted means `true`. + * Declare `false` on a key whose map entry carries the + * `SessionProjectionSummaryExcluded` mark: the runtime key set follows this + * field, while the client view type follows the mark. + * @default true + */ + summary?: boolean } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -104,6 +124,27 @@ export type ProjectionChangeListener = ( seq: SessionSeq, ) => void +/** + * One read's client-visible key selection: an explicit key list, or a derived + * set such as {@link SessionProjectionRegistry.summaryKeys}. A set is used as + * given; only a list is copied into one. + */ +export type ProjectionKeySelection = + | readonly Extract[] + | ReadonlySet> + +/** + * Narrow one key selection to the set a read loop consults. A caller that + * already holds a set (such as `summaryKeys()`) is served without a copy; only + * an explicit list allocates. + * @param keys - the caller's selection, or `undefined` for every key. + * @returns the selection as a set, or `undefined` when no selection was given. + */ +function selectedKeys(keys: ProjectionKeySelection | undefined): ReadonlySet | undefined { + if (keys === undefined) return undefined + return keys instanceof Set ? keys : new Set(keys) +} + /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value @@ -142,7 +183,12 @@ interface ErasedDefinition { stateSchema: { parse(value: unknown): unknown } init(header: SessionHeader, inheritedEventCount: SessionLogOffset): unknown apply(state: unknown, event: SessionEvent): unknown - wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined + wire: { + viewSchema: { parse(value: unknown): unknown } + view(state: unknown): unknown + /** Whether a Session summary carries this unit (false when the unit declared `summary: false`). */ + summary: boolean + } | undefined stateVersion: number } @@ -198,6 +244,8 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() + /** Stable summary key set; dropped whenever the key table changes. */ + private summaryKeySet: ReadonlySet> | undefined private readonly listeners = new Set() /** @@ -256,15 +304,20 @@ export class SessionProjectionRegistry extends Service { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown + summary?: boolean } | undefined const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, init: (header, inheritedEventCount) => definition.init(header, inheritedEventCount), apply: (state, event) => definition.apply(state as S, event), + // The summary mark has no runtime representation: `wire.view` and + // `viewSchema` are already typed with the mark stripped, so this erase + // only widens the state type. `summary` carries the declared membership + // into the erased unit, where the key set reads it. wire: wire === undefined ? undefined - : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, + : { viewSchema: wire.viewSchema, view: state => wire.view(state as S), summary: wire.summary !== false }, stateVersion: definition.stateVersion, } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { @@ -275,10 +328,14 @@ export class SessionProjectionRegistry extends Service { const existing = this.registrations.get(key) if (existing === undefined) { this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) + this.summaryKeySet = undefined } else { if (existing.def.stateVersion !== erased.stateVersion) { throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) } + if (existing.def.wire?.summary !== erased.wire?.summary) { + throw new Error(`session projection key ${JSON.stringify(key)} is already registered with summary=${String(existing.def.wire?.summary)}; refusing to share it with summary=${String(erased.wire?.summary)}`) + } existing.refs += 1 } yield () => { @@ -286,7 +343,10 @@ export class SessionProjectionRegistry extends Service { /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ if (live === undefined) return live.refs -= 1 - if (live.refs === 0) this.registrations.delete(key) + if (live.refs === 0) { + this.registrations.delete(key) + this.summaryKeySet = undefined + } } }.bind(this), 'sessionProjections.register()') // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; keep the Cordis disposer so effects can adopt it. @@ -338,10 +398,10 @@ export class SessionProjectionRegistry extends Service { */ snapshot( session: Session, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot { const values: Record = {} - const selected = keys === undefined ? undefined : new Set(keys) + const selected = selectedKeys(keys) this.materializeCells(session) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue @@ -362,11 +422,11 @@ export class SessionProjectionRegistry extends Service { */ cachedSnapshot( session: Session, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): ProjectionSnapshot | undefined { const values: Record = {} let asOfSeq: SessionSeqCursor | undefined - const selected = keys === undefined ? undefined : new Set(keys) + const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { if (registration.def.wire === undefined) continue if (selected !== undefined && !selected.has(registration.def.key)) continue @@ -380,6 +440,30 @@ export class SessionProjectionRegistry extends Service { return asOfSeq === undefined ? undefined : { asOfSeq, values } } + /** + * 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. + * @returns the current summary key set; do not mutate it. + */ + summaryKeys(): ReadonlySet> { + this.summaryKeySet ??= this.deriveSummaryKeys() + return this.summaryKeySet + } + + /** Rebuild the summary key set from the current registrations. */ + private deriveSummaryKeys(): ReadonlySet> { + const keys = new Set>() + for (const registration of this.registrations.values()) { + if (registration.def.wire === undefined || !registration.def.wire.summary) continue + keys.add(registration.def.key as Extract) + } + return keys + } + /** * State-level checkpoint of every persisted unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory @@ -448,10 +532,10 @@ export class SessionProjectionRegistry extends Service { */ viewCheckpoint( checkpoint: ProjectionCheckpoint, - keys?: readonly Extract[], + keys?: ProjectionKeySelection, ): Partial { const values: Record = {} - const selected = keys === undefined ? undefined : new Set(keys) + const selected = selectedKeys(keys) for (const registration of this.registrations.values()) { const def = registration.def if (def.wire === undefined) continue diff --git a/packages/session/session-projection/src/types.ts b/packages/session/session-projection/src/types.ts index 8373e4677b..ec5ef1d579 100644 --- a/packages/session/session-projection/src/types.ts +++ b/packages/session/session-projection/src/types.ts @@ -8,11 +8,36 @@ * @module @deepseek-ai/dsh-session-projection/types */ +/** + * Type-only marker of a `SessionProjectionMap` entry that a Session summary + * leaves out. The symbol never exists at runtime: a marked entry keeps its + * plain JSON value, `SessionProjectionViewValue` strips the mark in the wire + * and client view types, and the only runtime fact is the `summary: false` + * declared at registration. A marked key's registration declares + * `summary: false` so the runtime key set and the client view type agree, and + * `summaryKeys()` skips exactly those keys; the registration types do not + * enforce that pairing. + */ +declare const sessionProjectionSummaryExcluded: unique symbol + +/** + * One `SessionProjectionMap` entry excluded from Session summary payloads + * (the Session list row and any other summary read). The mark is type-only; + * `T` keeps its exact runtime value and wire representation. Wrap only the + * excluded contributor's own map entry, and place a nullable entry's `null` + * outside the wrapper (`SessionProjectionSummaryExcluded | null`) so + * the required symbol cannot absorb that member. + */ +export type SessionProjectionSummaryExcluded = T & { + readonly [sessionProjectionSummaryExcluded]: true +} + /** * The merge-extensible client projection table shared by wire blocks, client * cells, and React hooks. Domain packages merge their client-visible key here; * values are wire-JSON whole values. How a value is rendered is the slot * system's business, never this layer's. + * */ export interface SessionProjectionMap {} @@ -22,3 +47,34 @@ export interface SessionProjectionMap {} * Values must be plain JSON so the projection cache can persist them. */ export interface SessionProjectionStateMap {} + +/** + * One map entry's client view value: the summary mark stripped. The + * conditional distributes over a nullable entry's union, so a `| null` member + * written outside the wrapper survives unchanged. + */ +export type SessionProjectionViewValue = + T extends SessionProjectionSummaryExcluded ? Value : T + +/** Every client view value, keyed by projection key, with no summary mark. */ +export type SessionProjectionViewValues = { + [K in keyof SessionProjectionMap]: SessionProjectionViewValue +} + +/** + * Keys a Session summary omits: map entries whose non-nullish value carries + * the summary mark. Nullish members are ignored, so a nullable entry is still + * recognized when its `null` lives outside the wrapper. + */ +export type SessionProjectionSummaryExcludedKey = { + [K in keyof SessionProjectionMap]: + NonNullable extends SessionProjectionSummaryExcluded ? K : never +}[keyof SessionProjectionMap] + +/** + * The client values a Session summary carries: every client view value except + * {@link SessionProjectionSummaryExcludedKey}. A summary read returns these + * keys only; an opened Session's full snapshot keeps every view value. + */ +export type SessionProjectionSummaryValues = + Omit diff --git a/packages/session/session-projection/tests/summary.spec.ts b/packages/session/session-projection/tests/summary.spec.ts new file mode 100644 index 0000000000..af65cb58ab --- /dev/null +++ b/packages/session/session-projection/tests/summary.spec.ts @@ -0,0 +1,218 @@ +/** + * Session-summary key set: `summaryKeys()` derives the keys a summary read may + * carry from the live registrations (a registered wire key whose unit did not + * declare `summary: false`), and the existing key-taking read faces serve + * exactly that set. The complete faces stay complete: `viewCheckpoint` and + * `cachedSnapshot` serve every wire key when called without a key subset. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { + ProjectionCheckpoint, + ProjectionDefinition, +} from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'test/summary-kept': { text: string } | null + 'test/summary-excluded': number + 'test/summary-excluded-nullable': string[] | null + } + interface SessionProjectionMap { + 'test/summary-kept': { text: string } | null + 'test/summary-excluded': SessionProjectionSummaryExcluded + 'test/summary-excluded-nullable': SessionProjectionSummaryExcluded | null + } +} + +const keptUnit = () => ({ + key: 'test/summary-kept', + stateSchema: z.union([z.object({ text: z.string() }), z.null()]), + init: () => null, + apply: (state: { text: string } | null, event) => (event.type === 'turn/start' + ? { text: `turn-${String(event.data.turn)}` } + : state), + wire: { + viewSchema: z.union([z.object({ text: z.string() }), z.null()]), + view: state => state, + }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/summary-kept', { text: string } | null> + +const excludedUnit = () => ({ + key: 'test/summary-excluded', + stateSchema: z.number().int().nonnegative(), + init: () => 0, + apply: (state: number) => state + 1, + wire: { viewSchema: z.number().int().nonnegative(), view: state => state, summary: false }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/summary-excluded', number> + +const excludedNullableUnit = () => ({ + key: 'test/summary-excluded-nullable', + stateSchema: z.array(z.string()).nullable(), + init: () => null, + apply: (state: string[] | null, event) => (event.type === 'turn/start' + ? [`turn-${String(event.data.turn)}`] + : state), + wire: { viewSchema: z.array(z.string()).nullable(), view: state => state, summary: false }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/summary-excluded-nullable', string[] | null> + +async function mount(): Promise<{ ctx: Context; registry: SessionProjectionRegistry }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + return { ctx, registry: ctx.sessionProjections } +} + +describe('session projection summary keys', () => { + it('derives the summary set from the registered units and their marks', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + registry.register(excludedUnit()) + registry.register(excludedNullableUnit()) + const keys = registry.summaryKeys() + expect([...keys]).toEqual(['test/summary-kept']) + expect(keys.has('test/summary-excluded')).toBe(false) + // The set is stable while the key table is unchanged. + expect(registry.summaryKeys()).toBe(keys) + } finally { + await ctx.fiber.dispose() + } + }) + + it('refuses a second registration of one key with a different summary membership', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + expect(() => registry.register({ + ...keptUnit(), + wire: { ...keptUnit().wire, summary: false }, + })).toThrow(/test\/summary-kept.*already registered with summary=true/) + } finally { + await ctx.fiber.dispose() + } + }) + + it('rebuilds the summary set only when a registration or its disposal changes the key table', async () => { + const { ctx, registry } = await mount() + try { + const empty = registry.summaryKeys() + expect([...empty]).toEqual([]) + const fiber = await ctx.plugin(Object.assign((hostCtx: Context) => { + hostCtx.sessionProjections.register(keptUnit()) + }, { inject: ['sessionProjections'] })) + const registered = registry.summaryKeys() + expect(registered).not.toBe(empty) + expect([...registered]).toEqual(['test/summary-kept']) + expect(registry.summaryKeys()).toBe(registered) + await fiber.dispose() + const disposed = registry.summaryKeys() + expect(disposed).not.toBe(registered) + expect([...disposed]).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('serves a summary-shaped block through the key-taking checkpoint face', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + registry.register(excludedUnit()) + registry.register(excludedNullableUnit()) + const checkpoint: ProjectionCheckpoint = { + 'test/summary-kept': { ver: 1, seq: SessionSeq(3), val: { text: 'kept' } }, + 'test/summary-excluded': { ver: 1, seq: SessionSeq(4), val: 7 }, + 'test/summary-excluded-nullable': { ver: 1, seq: SessionSeq(5), val: ['a'] }, + } + + expect(registry.viewCheckpoint(checkpoint, registry.summaryKeys())).toEqual({ + 'test/summary-kept': { text: 'kept' }, + }) + // Without the key subset the complete face still serves the marked keys. + expect(registry.viewCheckpoint(checkpoint)).toEqual({ + 'test/summary-kept': { text: 'kept' }, + 'test/summary-excluded': 7, + 'test/summary-excluded-nullable': ['a'], + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('serves cached summary cells through the key-taking cached face', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + registry.register(excludedUnit()) + const session = ctx.sessions.create(SessionId('summary-cached'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + + // The derived set is passed as a set: no key-list copy inside the read. + expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ + 'test/summary-kept': { text: 'turn-1' }, + }) + expect(registry.cachedSnapshot(session)?.values).toEqual({ + 'test/summary-kept': { text: 'turn-1' }, + 'test/summary-excluded': 1, + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('reports an empty summary set while the complete face still serves only marked units', async () => { + const { ctx, registry } = await mount() + try { + registry.register(excludedUnit()) + registry.register(excludedNullableUnit()) + const session = ctx.sessions.create(SessionId('summary-empty'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + + expect([...registry.summaryKeys()]).toEqual([]) + expect(registry.cachedSnapshot(session, [])).toBeUndefined() + expect(registry.cachedSnapshot(session)?.values).toEqual({ + 'test/summary-excluded': 1, + 'test/summary-excluded-nullable': ['turn-1'], + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps the marked nullable entry nullable through the mark', async () => { + const { ctx, registry } = await mount() + try { + registry.register(excludedNullableUnit()) + const before = ctx.sessions.create(SessionId('summary-nullable'), { meta: { cwd: '/workspace' } }) + expect(registry.snapshot(before).values['test/summary-excluded-nullable']).toBeNull() + } finally { + await ctx.fiber.dispose() + } + }) + + it('drops an unmaterialized cell and a version-mismatched checkpoint row', async () => { + const { ctx, registry } = await mount() + try { + registry.register(keptUnit()) + const session = ctx.sessions.create(SessionId('summary-unmaterialized'), { meta: { cwd: '/workspace' } }) + expect(registry.cachedSnapshot(session, registry.summaryKeys())?.values).toEqual({ + 'test/summary-kept': null, + }) + const checkpoint: ProjectionCheckpoint = { + 'test/summary-kept': { ver: 2, seq: SessionSeq(1), val: { text: 'stale' } }, + } + expect(registry.viewCheckpoint(checkpoint, registry.summaryKeys())).toEqual({}) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index e7d3f32377..48763db8aa 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -208,5 +208,6 @@ export const sessionStatsProjectionDefinition = { decodeMs: state.decodeMs, decodeTokens: state.decodeTokens, }), + summary: false, }, } satisfies ProjectionDefinition<'sessionStats', SessionStatsState> diff --git a/packages/session/session-stats/src/types.ts b/packages/session/session-stats/src/types.ts index e11a300b19..214bfa8225 100644 --- a/packages/session/session-stats/src/types.ts +++ b/packages/session/session-stats/src/types.ts @@ -8,6 +8,8 @@ * @module @deepseek-ai/dsh-session-stats/types */ +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + // Marks this file a module so the declaration below AUGMENTS the projection // table instead of declaring an ambient module. export {} @@ -40,7 +42,11 @@ export interface SessionStatsProjection { declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { - /** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */ - sessionStats: SessionStatsProjection + /** + * Whole-log turn/step counts and wall times; see + * {@link SessionStatsProjection}. Excluded from Session summary payloads: + * no list row renders these figures. + */ + sessionStats: SessionProjectionSummaryExcluded } } diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 992273e72a..bee3313265 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -133,5 +133,6 @@ export const turnOutlineProjectionDefinition = { wire: { viewSchema: turnOutlineEntriesSchema, view: state => state.turns, + summary: false, }, } satisfies ProjectionDefinition<'turnOutline', TurnOutlineState> diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts index c4ccbcdab3..b6ffcea3dd 100644 --- a/packages/session/session-turn-outline/src/types.ts +++ b/packages/session/session-turn-outline/src/types.ts @@ -8,6 +8,7 @@ */ import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' export {} @@ -43,7 +44,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { turnOutline: TurnOutlineState } interface SessionProjectionMap { - /** Every started turn with its `turn/start` seq and bounded previews, strictly increasing by turn; see {@link TurnOutlineEntry}. */ - turnOutline: readonly TurnOutlineEntry[] + /** + * Every started turn with its `turn/start` seq and bounded previews, + * strictly increasing by turn; see {@link TurnOutlineEntry}. Excluded from + * Session summary payloads: the outline grows with the whole log and no + * list row renders it. + */ + turnOutline: SessionProjectionSummaryExcluded } } diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index f345045c93..8835e74614 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -129,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { if (event.type === 'turn/start') return null return state }, - wire: { viewSchema: todosProjectionSchema, view: state => state }, + wire: { viewSchema: todosProjectionSchema, view: state => state, summary: false }, stateVersion: 2, }) ctx.tools.register(defineTool({ diff --git a/packages/todo/tool-todo/src/types.ts b/packages/todo/tool-todo/src/types.ts index 68d5649bb0..15f5dab88c 100644 --- a/packages/todo/tool-todo/src/types.ts +++ b/packages/todo/tool-todo/src/types.ts @@ -8,6 +8,8 @@ * @module @deepseek-ai/dsh-tool-todo/types */ +import type { SessionProjectionSummaryExcluded } from '@deepseek-ai/dsh-session-projection/types' + /** * One entry in an agent's todo list — the unit of the `todo/write` * whole-list snapshot declared by this package. @@ -41,7 +43,9 @@ declare module '@deepseek-ai/dsh-session-projection/types' { * The agent's current whole todo list (the latest `todo/write` snapshot), * or `null` before the first write. Whole-value rule: every `todo/write` * carries the complete replacement list, so the fold is last-wins. + * Excluded from Session summary payloads, with `null` kept outside the + * exclusion mark so the pre-first-write value stays expressible. */ - todos: TodoItem[] | null + todos: SessionProjectionSummaryExcluded | null } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index de705e4bb2..ee3ea8feec 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -824,6 +824,7 @@ export const LINK_MAP: Readonly> = { ProjectionDefinition: 'session-projection.md', SessionProjectionMap: 'session-projection.md', SessionProjectionStateMap: 'session-projection.md', + ProjectionKeySelection: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', @@ -861,6 +862,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Record', 'Readonly', 'ReadonlyMap', + 'ReadonlySet', 'Request', 'Response', 'IncomingMessage', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 637c9d7bf5..39752bafd3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2481,6 +2481,36 @@ "doc": "docs/subsystems/boot.md", "symbol": "PendingModule", "source": "packages/boot/hmr/src/index.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionSummaryExcluded", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionViewValue", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionViewValues", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionSummaryExcludedKey", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "SessionProjectionSummaryValues", + "source": "packages/session/session-projection/src/types.ts" + }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionKeySelection", + "source": "packages/session/session-projection/src/index.ts" } ] }