diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index c0e4a6d3e3..93feec2b95 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -27,14 +27,14 @@ en: d5d0d6a57a4fa581 zh: 265973b157f957d9 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller: - en: e408868a60ed4876 - zh: c6c930da77279f81 + en: f432480a52414d56 + zh: 15a3f175b69cdb53 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/activation-policy: en: 03e24cdee8519a92 zh: 82dc02eb26d7d7f4 /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-journal: - en: bb710f8cf35f2028 - zh: aae2cfb8cd848069 + en: 4de2965717900b96 + zh: 1d129003824fa6bd /agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-live-control: en: a11d627cd68650c3 zh: 9aff565b9f712853 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 45631eaf11..ee43b551eb 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -139,7 +139,7 @@ If a page request is canceled with its physical carrier generation, the journal `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. -It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. +It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, `events`, the deprecated `page`, `follow`, `control`, plus `observeEvent`, `projections`, `observeProjections`, `state`, `observeState`, and `wait`. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. @@ -174,9 +174,9 @@ Reading titles, lists, and projections does not require an Agent. An observation #### Session journal -`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. +`session.events` returns a history window clipped on message boundaries with contiguous internal sequence numbers. `throughSeq` is optional: when omitted, the read selects the observed log end and returns it as the page's inclusive `cursor`; a supplied value fixes the read at that cut. A page without `beforeSeq` ends exactly at the selected cut, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. The deprecated `session.page` entry returns the same type. -The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark. +A page carries only its records, `hasMore`, and inclusive `cursor`; a projection baseline comes from a follow opening snapshot or the control stream, never from a page. The Client merges pages and subsequent live control updates by projection watermark. Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 46cc1d0b7a..9df5c55701 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -139,7 +139,7 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 -它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 +它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、`events`、已弃用的 `page`、`follow`、`control`,以及 `observeEvent`、`projections`、`observeProjections`、`state`、`observeState` 与 `wait`。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 @@ -174,9 +174,9 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 #### Session 日志 -`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 +`session.events` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。`throughSeq` 可省略:省略时选取观察到的日志末尾,并把该切点作为页面的 inclusive `cursor` 返回;显式提供时把本次读取固定在该切点。无 `beforeSeq` 的 page 精确结束于所选切点,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。已弃用的 `session.page` 入口返回同一类型。 -tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。 +page 只携带记录、`hasMore` 和 inclusive `cursor`;projection baseline 来自 follow 的开场快照或 control 流,不来自 page。Client 以 projection watermark 合并 page 与后续 live control 更新。 普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index e70e858963..86b5f42ac4 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -51,8 +51,8 @@ en: 51f99cfca9c4c808 zh: ce95bab241d306f7 /agent-note-client-derived-presentation-from-raw-session-tool-events/host-design: - en: 16fd2b3e545ede21 - zh: 4d28f52132495441 + en: d5e5d1376dc42558 + zh: c2f302b2a5d1de39 /agent-note-client-derived-presentation-from-raw-session-tool-events/host-design/performance-constraints: en: 102b14e3eb9367ff zh: d391099dbc41db24 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 8a0c4ea6fc..8c32436c6a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -195,9 +195,9 @@ The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to mor ## Host Design -After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. +After obtaining source events, `HistoryController.history()` performs only pagination and requests projections only for a subagent address that needs descriptor validation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. -`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. +`WebController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 2dd4ad8bc9..6ba3e040bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -195,9 +195,9 @@ Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名 ## Host 端设计 -`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 +`HistoryController.history()` 在取得 source events 后只执行分页,并且只有需要校验 descriptor 的 subagent 地址才请求投影。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 -`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 +`WebController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 0cce40f858..c67dff0722 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -15,17 +15,17 @@ en: 480284ae1a6ce210 zh: d42c72ada9f019f8 /agent-note-session-observations-and-projection-owned-client-state/decision/observation-is-the-point-read-unit: - en: fac92f8547482a49 - zh: 60240c47fbe4611f + en: 283136ae36512767 + zh: e342b446c77ae300 /agent-note-session-observations-and-projection-owned-client-state/decision/source-resolution-and-lifetime: - en: e550ae9892474276 - zh: fb6a2d9e91ebd4d9 + en: bced4b90cb03525d + zh: b0c1ca4bf1064f89 /agent-note-session-observations-and-projection-owned-client-state/decision/projection-execution-boundary: - en: 148de86941b90f41 - zh: c065a8e96e28b9d7 + en: b3b235fa0250868a + zh: 0bb5eedf14cdafee /agent-note-session-observations-and-projection-owned-client-state/decision/read-audiences: - en: f6cd715c8a0dc151 - zh: 3e1897ae49b19eb7 + en: e394e9480340f69c + zh: 7eea994ee9fa380d /agent-note-session-observations-and-projection-owned-client-state/decision/replayable-client-facts-are-projection-owned: en: efded09f0cd30a8e zh: 5e16580a39ff3359 @@ -48,8 +48,8 @@ en: 8d699dfc22ab5c5f zh: 8dcd9fc6bd1594db /agent-note-session-observations-and-projection-owned-client-state/verification: - en: df17217023668428 - zh: 88f7da4deffc6bf9 + en: 29f364d1031b7d85 + zh: 2b193ceb52fb0406 /agent-note-session-observations-and-projection-owned-client-state/alternatives-considered: en: 90574351058d5d8c zh: 6aa514cf47495ed2 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index dd8c332fd1..530aeb36e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -46,7 +46,7 @@ flowchart LR `SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load. -Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. +Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` transfer that exact prepared source to background Agent promotion before delivering the snapshot, without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold. ### Source resolution and lifetime @@ -56,7 +56,7 @@ Live preference is checked both before and after a cold borrow. The second check Persistence absence maps to Session-not-found only after no attached Session exists. Durable corruption, source-identity conflict, cancellation, and operational persistence failure remain distinct `SessionQueryError` outcomes so API owners can preserve their own public error vocabulary without duplicating source detection. -The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer performed by the Session Controller after it has emitted the opening snapshot; other readers cannot turn an observation into a live Agent. +The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer the Session Controller starts once the opening is computed, before delivering it; other readers cannot turn an observation into a live Agent. Projection work is deliberately `all | none`. `all` computes every registered projection at the observation's event cursor; `none` leaves projection state untouched. There is no per-key preparation state, `projectionKeys` mode, or cached `viewedState`/`viewedValue` layer. A publisher may filter the completed values for an audience, but the underlying observation is never partly projected. @@ -72,7 +72,7 @@ The registry owns fold state; each domain owns its `init`, `apply`, `view`, sche Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list first uses live projection state or durable projection-cache rows. Subagent discovery reads parent-catalog projections through exact observations. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints. -`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered. +`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` supports current or fixed-cut history reads, older-history reads, and gap repair. Observation-only reads never activate an Agent; only an activation-enabled ordinary Follow may retain its prepared observation and request promotion once the opening snapshot is computed, before it is delivered. The [snapshot and subscription decision](2026-10-21-session-history-stream-and-snapshot.md) defines the Controller composition and passive subscription semantics. ### Read audiences @@ -82,7 +82,7 @@ Each public operation chooses one query and projection policy. The choice is par |---|---|---|---| | `session.list` | Corpus headers, live state, and cached rows; bounded small-log fallback | Partial hints, or one full small-log observation | Never | | `session.search` | Corpus authorization plus the configured search provider | None for result listing | Never | -| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery | +| `session.follow` | One exact observation | All, carried in the opening snapshot | Only with `activate: true`, for an ordinary cold Session once the opening is computed, before delivery | | `session.page` | One exact observation | None, except projection-backed subagent authorization | Never | | Fork source | One exact observation | None unless authorization requires it | Never for the source | | Attachment | Direct store read under the [image access rule](2026-10-23-store-scoped-image-reads.md) | None | Never | @@ -172,7 +172,7 @@ These rules apply to new Session-derived Client state even when a direct event s ## Verification -Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery. +Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion once the opening is computed. Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin parent-catalog order, recursive traversal, branch diagnostics, cancellation, fork isolation, and no Agent activation during listing. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 7434603314..62ac9db852 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -46,7 +46,7 @@ flowchart LR `SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。 -每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 +每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能把完全相同的 prepared source 转交给后台 Agent promotion,并在交付 snapshot 前启动它,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。 ### 数据源解析与生命周期 @@ -56,7 +56,7 @@ flowchart LR 只有在不存在已挂载 Session 后,persistence absence 才映射为 Session-not-found。持久数据损坏、source identity 冲突、取消和 persistence 操作失败分别保留不同的 `SessionQueryError`,API owner 因而可以维持自身公开错误词汇,而不用重复数据源判定。 -Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening snapshot 发出后执行的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 +Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening 计算完成后、交付前启动的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。 Projection 工作明确只有 `all | none` 两种模式。`all` 在 observation 的事件 cursor 上计算所有已注册 projection;`none` 完全不触碰 projection 状态。系统不存在按 key preparation 的状态、`projectionKeys` 模式或额外的 `viewedState`/`viewedValue` cache。发布方可以按 audience 筛选已完成的值,但底层 observation 不会处于只算完部分 projection 的状态。 @@ -72,7 +72,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` 语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 先读取 live projection 状态或持久 projection-cache row。Subagent 发现通过精确 observation 读取父目录 projection。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。 -`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。 +`session.follow` 发布必需的 opening snapshot,包含 header、cursor、初始事件窗口与完整 projection baseline。重连通过另一份完整快照替换之前的 generation。`session.page` 支持当前或固定切点的历史读取、旧历史分页及缺口修复。只读 Observation 不激活 Agent;只有显式启用激活的普通 Follow 可以在开场计算完成后、交付之前 retain prepared observation 并请求提升。[快照与订阅决策](2026-10-21-session-history-stream-and-snapshot.zh.md)定义 Controller 组合和纯订阅语义。 ### 读取 audience @@ -82,7 +82,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` |---|---|---|---| | `session.list` | Corpus header、live state 和 cached row;有界小日志 fallback | 部分 hints,或一次完整小日志 observation | 从不 | | `session.search` | Corpus 鉴权加已配置 search provider | 结果列表不计算 | 从不 | -| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 | +| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅 `activate: true` 的普通 cold Session,且在开场计算完成后、交付前 | | `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 | | Fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 | | Attachment | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)直接读取存储 | 不计算 | 从不 | @@ -172,7 +172,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ## 验证 -Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。 +Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及开场计算完成后的 promotion。 Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定父目录顺序、递归遍历、分支诊断、取消、fork 隔离,以及 listing 期间不激活 Agent。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml index 93080791e0..ed47cbd168 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml @@ -30,8 +30,8 @@ en: 9d2d50cb76754b74 zh: 9ceed3652475f18d /agent-note-historical-session-reads-prepare-before-write-publication/decision/follow-and-agent-promotion: - en: 692c29db3e7ab07e - zh: 7da0cfdbc80f407d + en: 46da45b9b63e7298 + zh: 3d55099479a80bd9 /agent-note-historical-session-reads-prepare-before-write-publication/problem-to-solution-mapping: en: e067e187cd4d0a8c zh: b0c79b635a637296 diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md index 081044e34d..4948e31a32 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md @@ -102,7 +102,7 @@ No external caller can append before the handle exists. `append`, `flush`, and ` ### Follow and Agent promotion -`session.follow` opens history through the read path, restores the Session and projections, emits the opening snapshot, and then starts Agent promotion. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state. +`session.follow` opens history through the read path, restores the Session and projections, starts Agent promotion from the separately retained Observation when the caller requests activation, and then delivers the opening snapshot. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state. ## Problem-to-solution mapping diff --git a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md index 7406e7cf31..41a87efa86 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md @@ -102,7 +102,7 @@ Handle 返回前,外部 caller 无法 append。因此 `append`、`flush` 与 ` ### Follow 与 Agent promotion -`session.follow` 通过 read path 打开历史、恢复 Session 与 projections、发出 opening snapshot,然后启动 Agent promotion。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。 +`session.follow` 通过 read path 打开历史、恢复 Session 与 projections,在调用方请求激活时先用单独 retain 的 Observation 启动 Agent promotion,再交付 opening snapshot。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。 ## 问题与方案对照 diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml new file mode 100644 index 0000000000..9ae0452069 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-session-history-stream-and-snapshot.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-21-session-history-stream-and-snapshot.md +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation: + en: 0c9a7bc6591feb16 + zh: 3606a2c1caffd36f +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/problem: + en: ac1583e7ae1377f8 + zh: c60bdbda9ee89144 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision: + en: 45c63df5dac389eb + zh: d72a5bcb94de22f1 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/reads-and-subscriptions: + en: 38df4f3c4fba2cc9 + zh: a34f3376faf26f0c +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/follow-ordering-and-ownership: + en: 352ff87a3e9cd110 + zh: 4ac6366cecfc08a3 +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/state-and-wait: + en: a87c7427582e6b03 + zh: b28d2a73b36eb27f +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/alternatives-considered: + en: c5fb06ab9df9e7ad + zh: b20adf151018111c +/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/consequences: + en: dc1da262b4911a31 + zh: 2a4e503696fc477b diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md new file mode 100644 index 0000000000..b5e1112a18 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md @@ -0,0 +1,59 @@ +# Agent Note: Session snapshots, passive subscriptions, and Web aggregation + +Status: implemented + +English | [中文](2026-10-21-session-history-stream-and-snapshot.zh.md) + +## Problem + +A point read, an event subscription, and a Web conversation opening have different lifetimes. Combining them inside one history controller makes passive observation perform unnecessary reads and gives each aggregate its own acquisition logic. Reading before subscribing also creates a gap that requires another read to repair. The APIs need independent operations that can compose without losing events or duplicating ownership. + +## Decision + +`SessionController` is the single Session Remote Service. It owns ordinary `HistoryController`, `StateController`, and `WebController` instances, not three additional Cordis Services or Remote namespaces. The implementation separates six operations: history snapshot and observation, projection snapshot and observation, and state snapshot and observation. Follow and Wait compose those capabilities for their callers; the existing `page` entry forwards to the history reader. + +### Reads and subscriptions + +History reads return chronological records, `hasMore`, and an inclusive cursor. An omitted `throughSeq` selects the observed log end; `beforeSeq` selects older records without changing that read cut. Projection reads return exact sequenced values, sequence-free cache hints when migration is required, or null when the Session is absent. Live projection reads avoid persistence metadata access. + +`usingObservation({ sessionId, projection }, signal, callback?)` acquires the existing disposable SessionObservation. A callback borrows it until the callback settles; without a callback the caller owns disposal. Ordinary history, state, and Wait output reads do not request projections. Follow, exact projection reads, and subagent history descriptor validation request them. No read activates an Agent implicitly. + +`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; projection updates replace individual keys and may share a sequence number. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations. + +History, history observation, and Follow accept Address requests; projection and state operations accept Session IDs. All internal readers and listeners use Session IDs. History and Follow validate Address against their opening Observation at the Controller layer, including the existing subagent descriptor rules. Pure history observation does not acquire a read for entity validation. Gateway resource authorization remains independent: the registered Session resolver extracts the target ID from the request before access is decided. + +### Follow ordering and ownership + +Follow installs history and optional assistant listeners first, then acquires one projected Observation. It captures the assistant baseline and binding-local arrival ordinal synchronously, sets both cuts, publishes the opening snapshot, and consumes the shared output queue. History notifications at or before the snapshot cursor and assistant notifications at or before the ordinal are discarded at consumption. Later notifications remain ordered without a second history read, background merge pumps, or a tail repair introduced by the opening itself. + +`session/created` contributes constructor seed events through the same history buffer. The adapter retains the supplied Session and its captured end position, then reads that in-memory prefix when consumed; it does not acquire another Observation. Each binding owns its subscriptions; a shared output queue belongs to Follow rather than to either listener. + +Follow activates only when the caller passes `activate: true`; the Web Client does so explicitly. Background promotion starts once the opening is computed and before it is delivered, and does not promise Agent readiness. Ordinary promotion reuses the existing Agent controller and receives a separately retained Observation. Either Follow releases its opening Observation once the opening is computed, before yielding it; activation continues on the separately retained lease, so the cold entry's reference count drops to that lease alone. Subagent Sessions do not use ordinary promotion. + +### State and Wait + +State contains the event cursor, active and running flags, the complete readable queue or null, and pending-input kinds. It exposes neither a header nor an internal revision. Gateway owns unsettled Remote events; State samples their detached metadata and classifies approval, question, or other input without copying a request registry. + +Wait evaluates complete states, confirms active-Agent quiescence with `whenIdle`, and rejects stale observations using listener-local revisions and Agent/Session identity checks. Session and Agent events wake reads immediately; while waiting, a one-second recheck detects pending input that produces no such event. This avoids adding a Gateway change event, at the cost of polling and bounded sampling delay rather than instantaneous notification. `observeState` does not poll, so pending-only changes need another state invalidation to become visible there. + +Wait returns idle, input-required, or timeout with the last state it accepted and bounded assistant output. Output contains only `assistant/message` records in `(afterSeq, state.cursor]`, newest first, and reports truncation only when another eligible record exists. The caller's cancellation signal controls that output read independently of the expired wait deadline. A wait that accepts no state before its deadline fails instead of inventing a state. + +## Alternatives considered + +**Read history, then subscribe and repair the gap.** Subscription-before-read already captures opening-time increments. A second read adds acquisition and replay obligations without improving that ordering. + +**Give pure subscriptions an Observation or ready cursor.** These values conflate subscription readiness with an independently selected read cut. Callers that need both subscribe and buffer first, then read and discard covered increments. + +**Keep separate Remote Services for the implementation controllers.** The classes divide behavior, not external service identity. One Session Service owns their composition, resource policy, and Remote entry points. + +**Have Gateway interpret pending input or emit Session status.** Gateway only owns Remote event transport and unsettled-event metadata. State owns classification; Wait's periodic sampling avoids adding a transport notification solely for Session waiting. + +**Merge assistant and history with independent asynchronous pumps.** One caller-owned buffer retains cross-source arrival order without extra pump cancellation or queue ownership. Snapshot construction and per-binding buffering retain distinct lifetimes. + +## Consequences + +The standard reads and Web aggregate share one Observation acquisition path, while pure subscriptions remain read-free. The old combined History controller is removed; still-used pagination, validation, and wire helpers remain at their original path. Cancellation, consumer termination, failed opening reads, and Context disposal release subscriptions and owned leases without cancelling Agent execution. + +The [Observation decision](2026-08-25-session-observations-and-projection-owned-client-state.md) remains the authority for exact read cuts and projection derivation. The [history and Remote transport decision](2026-08-18-session-history-and-event-transport.md) retains ownership of Client reconnection and transport lifetimes. This decision refines Session API composition rather than replacing either broader decision. + +The migrated history, cold-read, and transport tests exercise the new classes, including cursor overlap, assistant ordinal cuts, sequence gaps, subagent validation, and teardown. Slow consumers can still retain queued notifications; silent dropping is not a supported recovery strategy. Independent completion-acknowledged activation is not implemented by these reads or by Follow's background activation option. diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md new file mode 100644 index 0000000000..773ab039b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md @@ -0,0 +1,59 @@ +# Agent Note: Session 快照、纯订阅与 Web 聚合 + +Status: implemented + +[English](2026-10-21-session-history-stream-and-snapshot.md) | 中文 + +## Problem + +定点读取、事件订阅和 Web 会话开场具有不同的生命周期。把它们合并在同一个历史控制器中,会使被动观察执行不必要的读取,并让每个聚合各自维护获取数据的逻辑。先读后订阅还会产生需要再次读取来修复的缺口。接口需要相互独立的操作,使组合既不丢事件,也不重复资源所有权。 + +## Decision + +`SessionController` 是唯一的 Session Remote Service。它持有普通的 `HistoryController`、`StateController` 和 `WebController` 实例,而不是额外的三个 Cordis Service 或 Remote namespace。实现分离六个操作:历史快照与观察、投影快照与观察、状态快照与观察。Follow 和 Wait 为调用方组合这些能力;已有的 `page` 入口转发到历史读取器。 + +### 读取与订阅 + +历史读取返回时间正序的记录、`hasMore` 和包含末项的游标。省略 `throughSeq` 时选择观察到的日志末尾;`beforeSeq` 只选择更早的记录,不改变读取点位。投影读取返回精确的有序值、需要迁移时无序号的缓存提示,或在 Session 不存在时返回 null。live 投影读取不访问持久化元信息。 + +`usingObservation({ sessionId, projection }, signal, callback?)` 获取已有的可释放 SessionObservation。有回调时租约借给回调直到其结束;无回调时由调用方释放。普通历史、状态及 Wait 输出读取不请求投影。Follow、精确投影读取和子会话历史 descriptor 校验请求投影。读取不会隐式激活 Agent。 + +`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;投影更新替换单个键,多个更新可以共享序号。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。 + +历史、历史观察和 Follow 接收 Address 请求;投影和状态操作接收 Session ID。所有内部读取器和监听器都使用 Session ID。History 和 Follow 在 Controller 层以开场 Observation 校验 Address,包括已有的子会话 descriptor 规则。纯历史观察不为实体校验获取读取。Gateway 资源授权与之独立:注册的 Session 解析器在访问判定前从请求提取目标 ID。 + +### Follow 顺序与所有权 + +Follow 先安装历史和可选 assistant 监听,再获取一份带投影的 Observation。它同步捕获 assistant 基线和绑定局部的到达序号,设置两种截断点,发布开场快照,然后消费共享输出队列。消费时丢弃序号不超过快照游标的历史通知,以及到达序号不超过截断点的 assistant 通知。后续通知保持顺序,无需第二次历史读取、后台合并任务,或由开场自身引入的尾部修复。 + +`session/created` 经同一历史缓冲提供构造期种子事件。适配器保留收到的 Session 及捕获的末尾位置,在消费时读取对应内存前缀,不获取另一份 Observation。每个绑定拥有自己的订阅;共享输出队列属于 Follow,而不是任一监听器。 + +只有调用方传入 `activate: true` 时 Follow 才激活;Web Client 显式传入该参数。后台提升在开场计算完成后、交付之前启动,不承诺 Agent 已就绪。普通提升复用已有 Agent controller,并接收单独 retain 的 Observation。两种 Follow 都在开场计算完成后、交付前释放开场 Observation;激活则继续使用单独 retain 的租约,因此冷 entry 的引用计数只剩那一份。子会话不走普通提升。 + +### 状态与 Wait + +State 包含事件游标、active 与 running 标志、完整可读队列或 null,以及待输入种类。它不暴露 header 或内部 revision。Gateway 拥有未结算的 Remote 事件;State 采样独立元数据,将输入分类为 approval、question 或 other,不复制请求注册表。 + +Wait 判断完整状态,通过 `whenIdle` 确认活跃 Agent 已静止,并通过监听器局部 revision 和 Agent/Session 身份检查拒绝过时观察。Session 和 Agent 事件立即唤醒读取;等待期间每秒重查一次,检测没有产生上述事件的待输入请求。这避免新增 Gateway change 事件,代价是轮询和采样延迟,而非即时通知。`observeState` 不轮询,因此仅有 pending 变化时,需要另一项状态失效通知才会显示更新。 + +Wait 返回 idle、input-required 或 timeout,以及最后一次被接受的状态和有界 assistant 输出。输出仅包含 `(afterSeq, state.cursor]` 内从新到旧的 `assistant/message` 记录,只有存在另一条符合条件的记录时才报告截断。输出读取使用调用方的取消信号,不受已过期的等待截止信号控制。截止前没有任何状态通过校验的 wait 会失败,不构造虚假的状态。 + +## Alternatives considered + +**先读历史,再订阅并修复缺口。** 先订阅后读取已经捕获开场期间的增量。第二次读取增加资源获取和重放责任,却不改善这个顺序。 + +**给纯订阅提供 Observation 或 ready 游标。** 这会混淆订阅就绪和独立选取的读取点位。需要两者的调用方先订阅并缓冲,再读取并丢弃已覆盖增量。 + +**为各实现 Controller 保留独立 Remote Service。** 这些类划分行为,而不是外部服务身份。一个 Session Service 拥有其组合、资源策略和 Remote 入口。 + +**让 Gateway 解释待输入状态或发送 Session status。** Gateway 只拥有 Remote 事件传输和未结算事件元数据。State 拥有分类;Wait 定期采样,避免仅为 Session 等待增加传输通知。 + +**用独立异步任务合并 assistant 与历史。** 调用方拥有的单个缓冲保留跨来源到达顺序,不引入额外任务取消或队列所有权。快照构造和各绑定缓冲仍保留不同生命周期。 + +## Consequences + +标准读取和 Web 聚合共享同一 Observation 获取路径,纯订阅不读取数据。旧的合并 History controller 已删除;仍使用的分页、校验和 wire 辅助函数留在原路径。取消、消费者结束、开场读取失败和 Context 释放都会释放订阅及拥有的租约,而不取消 Agent 执行。 + +[Observation 决策](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)仍拥有精确读取点位和投影派生规则。[历史与 Remote 传输决策](2026-08-18-session-history-and-event-transport.zh.md)继续拥有 Client 重连和传输生命周期。本决策细化 Session API 组合,而不取代上述更广泛的决策。 + +迁移后的历史、冷读取和传输测试覆盖新类,包括游标重叠、assistant 到达序号截断、序号缺口、子会话校验和释放。慢消费者仍可能积累队列通知;静默丢弃不是支持的恢复策略。这些读取以及 Follow 的后台激活选项均未实现独立的、确认完成后返回的激活接口。 diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 3ca55f2f85..302aea3fdc 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -85,11 +85,11 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) const signal = new AbortController().signal const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const - const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + const childPage = await scaffold.ctx.sessionController.events({ address, throughSeq: childRows.length - 1 }, signal) expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) - await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + await expect(scaffold.ctx.sessionController.events({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) .rejects.toThrow() - await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + await expect(scaffold.ctx.sessionController.events({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) .resolves.toMatchObject({ hasMore: false }) const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') await childWriter.close() diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 5e0b163aa1..6788e3f382 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -29,7 +29,8 @@ import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. -import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' +import { HistoryController } from '../../packages/api/session-controller/src/history/controller.ts' +import { WebController } from '../../packages/api/session-controller/src/web/controller.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -151,7 +152,7 @@ class SessionBenchmarkHost { private constructor( private readonly ctx: Context, private readonly scenario: SessionOpenBenchmarkScenario, - private readonly history: SessionHistoryController | undefined, + private readonly history: WebController | undefined, ) {} static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise { @@ -165,10 +166,10 @@ class SessionBenchmarkHost { } await installProjectionSet(ctx) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) - let history: SessionHistoryController | undefined + let history: WebController | undefined if (scenario === 'first-history') { new BenchmarkSessionQuery(ctx) - history = new SessionHistoryController(ctx, (observation) => { + history = new WebController(ctx, new HistoryController(ctx), (observation) => { // First-history ends at snapshot delivery; Agent-resume owns live activation and retention. observation[Symbol.dispose]() }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eda51827f8..b29fffaa2d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,7 +203,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` · `remoteAccess` -- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:158`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -242,8 +242,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `typertGateway` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:110`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a91d43209c..76e5f4150c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,7 +205,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` - `inject`: `typert` · `remoteAccess` -- `source`: [`packages/api/gateway/src/index.ts:156`](../packages/api/gateway/src/index.ts) +- `source`: [`packages/api/gateway/src/index.ts:158`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -244,8 +244,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `typertGateway` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:110`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 147c6f397d..ea75f788bc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,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:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 7bc5d87eba..493a5fbc34 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 58ae5924b7..4ec686df63 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1350,8 +1350,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 1525c04d0ebfc56e - zh: 902d37c1de80bfe7 + en: 1e12d380c4988d40 + zh: 14a781104e00f360 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1857,8 +1857,8 @@ en: 5a0bbf4c76266ac5 zh: 0b20854f90f9d74d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 1dd9740285c43aff - zh: 093fab99146cac11 + en: e98b716228feb157 + zh: 2bb593ae9cd0ce0d /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index e35438504e..8dbd3621ac 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5600,7 +5600,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:382`](../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:386`](../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` @@ -7656,7 +7656,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:391`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4c76a104f1..e28c70c102 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5602,7 +5602,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:382`](../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:386`](../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` @@ -7658,7 +7658,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:391`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 141b5bab03..d70bd19123 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -249,7 +249,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:382","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:386","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]}, @@ -333,7 +333,7 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:391"]}, + {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:395"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index df7aa39899..7cbc91298d 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -960,11 +960,51 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ +@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable + +/** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ +@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable + +/** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ +@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise + +/** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ +@Remote({ mode: 'stream' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable + +/** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ +@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -981,7 +1021,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for the Session read. * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ -@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise +@Remote('projections') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise /** * Stream a complete live-control baseline followed by replacement frames. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index c2bc413691..1f40042967 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -964,11 +964,51 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ +@Remote({ mode: 'stream' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable + +/** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ +@Remote({ mode: 'stream' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable + +/** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ +@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise + +/** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ +@Remote({ mode: 'stream' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable + +/** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ +@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -985,7 +1025,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for the Session read. * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ -@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise +@Remote('projections') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise /** * Stream a complete live-control baseline followed by replacement frames. diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 9838c8c82d..1fe31a9918 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -444,6 +444,8 @@ interface TypertGateway { * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. @@ -664,6 +666,12 @@ Resolve strict generated definitions or conservative SRC markers against current */ hasLiveClient(): boolean +/** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ +getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 2cbdaf4a2c..840825979e 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -444,6 +444,8 @@ interface TypertGateway { * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. @@ -664,6 +666,12 @@ Resolve strict generated definitions or conservative SRC markers against current */ hasLiveClient(): boolean +/** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ +getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml new file mode 100644 index 0000000000..8e84c0dcab --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md +/: + en: 789ee06aa8932a32 + zh: d465eb26824f2b4b +/request-activation-when-following-a-session: + en: 25bd54a8216b1c71 + zh: edaa12b04713528a +/request-activation-when-following-a-session/change: + en: d4021672fedcbfb7 + zh: 35d5e76a69301e4a +/request-activation-when-following-a-session/migration: + en: aa22d72ca3f91d38 + zh: ebe991eca4bf74c2 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md new file mode 100644 index 0000000000..cb01f8e90e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session follow callers must explicitly request background Agent activation." +--- +# Request activation when following a Session + +English | [中文](guide.zh.md) + +## Change + +`session.follow` no longer automatically resumes a cold ordinary Agent when `activate` is omitted. It reads the opening snapshot and follows subsequent events without activation. This affects integrations that used opening a history stream to make an Agent available. The shipped Web Client sends `activate: true` explicitly and retains its opening behavior. + +With `activate: true`, activation starts once the opening snapshot is computed, before it is delivered; that snapshot does not acknowledge Agent readiness. Subagent Sessions still do not use ordinary Agent activation. + +## Migration + +1. Add `activate: true` to `session.follow` requests that must resume a cold ordinary Agent. Keep the existing `address` and history-window fields. +2. For passive viewers, omit `activate` or set it to `false`. +3. Verify that an activation-enabled stream still returns its opening snapshot, then check Agent availability separately. Do not treat receipt of the snapshot as completion of activation. + +See the [Session Controller reference](../../../../packages/api/session-controller/README.md) for history and state observation behavior. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md new file mode 100644 index 0000000000..5df6e523a3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/session-follow-activation/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session follow 调用方必须显式请求后台 Agent 激活。" +--- +# 跟随 Session 时显式请求激活 + +[English](guide.md) | 中文 + +## 变更 + +`session.follow` 在省略 `activate` 时不再自动恢复冷普通 Agent。它只读取开场快照并跟随后续事件,不进行激活。这会影响通过打开历史流使 Agent 可用的集成。随产品提供的 Web Client 显式发送 `activate: true`,保留原有打开行为。 + +设置 `activate: true` 时,激活在开场快照计算完成后、交付之前开始;该快照不确认 Agent 已就绪。子会话仍不走普通 Agent 激活。 + +## 迁移 + +1. 为需要恢复冷普通 Agent 的 `session.follow` 请求添加 `activate: true`,保留原有 `address` 和历史窗口字段。 +2. 被动查看器可以省略 `activate` 或设为 `false`。 +3. 确认启用激活的流仍返回开场快照,再单独检查 Agent 是否可用。不要把收到快照当作激活完成。 + +历史与状态观察行为见 [Session Controller 参考](../../../../packages/api/session-controller/README.zh.md)。 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index a9ddbc70b5..eb3352e60d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,8 +15,8 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: b35573c6707d00d9 - zh: 619bbba900b0fb8f + en: 6ee072ffebd2eb38 + zh: 6a8723d13f443d51 /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: 0916113cdbe794fb zh: 14893c2cba95bfd6 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 6518441122..fea754c891 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -34,6 +34,8 @@ Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `pee Resources are read from validated arguments: Typert-compiled codecs drop undeclared object fields, while SRC fallback checks JSON safety without stripping them; lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's declaration in `typert.resources` supplies either a default field or a synchronous resolver. A field reads a top-level argument of that name or, failing that, the first object argument. A resolver is called once with `{ args, parameters }`: named validated wire values and ordered parameter descriptions containing `name` and `wire`. Both defaults return one identity or `undefined`; arrays are invalid. An explicit reader may return several identities. A resolver or reader that throws, a reader returning an empty list, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. +`getPendingRemoteEvents()` returns detached event-name and Agent-ID metadata for currently unsettled forwarded events in arrival order. It neither exposes request bodies nor changes event lifetimes, and it does not provide a change notification. + A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that does not complete at once counts as peer activity; with the default `'deflate'`, a frame whose JSON text reaches the compression threshold is deflated asynchronously, so its completion always counts. A socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. With `websocketCompression` at its `'deflate'` default, an upgrade that offers `permessage-deflate` negotiates the extension, and the Host compresses its own outbound frames whose JSON text reaches 1,024 characters; shorter frames stay uncompressed, uplink frames are compressed at the peer's discretion, and `'none'` accepts the same upgrades without the extension. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 34759301dd..1dad623204 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -34,6 +34,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 资源从已校验参数中读取:Typert 编译 codec 会移除未声明的对象字段,SRC fallback 只检查 JSON 安全性而不移除字段;lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则使用该种类在 `typert.resources` 中登记的默认字段或同步 resolver。字段规则读取同名顶层参数,没有时读取第一个对象参数的同名字段。resolver 以 `{ args, parameters }` 调用一次:命名的已校验 wire 值,以及包含 `name`、`wire` 的有序参数描述。两种默认规则均返回单个身份或 `undefined`,数组无效。显式读取函数可以返回多个身份。resolver 或读取函数抛错、读取函数返回空列表、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 +`getPendingRemoteEvents()` 按到达顺序返回当前未结算转发事件的独立元数据,仅含事件名和 Agent ID。它不暴露请求正文,不改变事件生命周期,也不提供变化通知。 + 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或未一次性完成的下行写入,都视为对端活动;默认 `'deflate'` 下,JSON 文本达到压缩阈值的帧异步 deflate,因此其完成总是计入。连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。`websocketCompression` 保持默认的 `'deflate'` 时,携带 `permessage-deflate` 的升级会协商该扩展,Host 压缩自己发出的、JSON 文本达到 1024 个字符 的帧;更短的帧不压缩,上行帧是否压缩由对端决定,设为 `'none'` 时接受同样的升级但不带该扩展。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 878c77cf5e..4fe2652671 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -36,6 +36,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import type { InvokeRemoteRequest, + PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, @@ -70,6 +71,7 @@ import { export type { InvokeRemoteRequest, + PendingRemoteEventInfo, TypertGateway, TypertGatewayErrorCode, TypertGatewayWireStream, @@ -333,6 +335,14 @@ export class TypertGatewayService extends Service implements TypertGateway { return false } + /** + * Inspect unsettled forwarded events without changing their lifetime. + * @returns detached event names and Agent identities. + */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] { + return [...this.pendingRemoteEvents.values()].map(({ frame }) => ({ event: frame.event, agentId: frame.agentId })) + } + /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 83ae7f0730..b802aba061 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -5,7 +5,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' -import type { RemoteEventHostInfo } from './stream-protocol.ts' +import type { RemoteEventAgentId, RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { @@ -144,6 +144,12 @@ export type TypertGatewayErrorCode = | 'gateway/signature-invalid' | 'gateway/uplink-overflow' +/** Public metadata of one unsettled forwarded Remote event. */ +export interface PendingRemoteEventInfo { + readonly event: string + readonly agentId: RemoteEventAgentId +} + /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ @@ -155,6 +161,9 @@ export interface TypertGateway { */ hasLiveClient(): boolean + /** @returns detached metadata for all unsettled forwarded events in arrival order. */ + getPendingRemoteEvents(): readonly PendingRemoteEventInfo[] + /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 53727804ee..346f50a8be 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -730,6 +730,7 @@ describe('Gateway forwarded events for non-owner Peers', () => { await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) expect(ctx.typertGateway.hasLiveClient()).toBe(true) + expect(ctx.typertGateway.getPendingRemoteEvents()).toEqual([]) const agentCtx = ctx.extend({}) const subject = {} const settled = vi.fn() @@ -745,6 +746,10 @@ describe('Gateway forwarded events for non-owner Peers', () => { push({ event: 'fixture/shelf', args: ['s2'], resources }) push({ event: 'fixture/shelf', args: ['s1'], resources }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + const pending = ctx.typertGateway.getPendingRemoteEvents() + expect(pending).toEqual([{ event: 'fixture/ask', agentId: 'agent-1' }]) + expect(pending).not.toBe(ctx.typertGateway.getPendingRemoteEvents()) + expect(pending[0]).not.toBe(ctx.typertGateway.getPendingRemoteEvents()[0]) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d6dd4c810c..a9ca9616e5 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 17778cfd84635f2b - zh: 8fa20e050e607ad9 + en: 6bbd371bb1907ad1 + zh: 03a1b13862404c14 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index deb143b3d9..708479b2b3 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -35,7 +35,9 @@ The Client journal validates current Session event envelopes before publishing f Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. -Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/page` and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. + +`events` reads one chronological page from a durable cut and returns its records, `hasMore`, and an inclusive `cursor`; omitting `throughSeq` selects the observed log end, and the compatible `page` method carries the same read with a deprecated entry point. `events`, `observeEvent`, and `follow` take an Address; projection and state operations take a Session ID. Page reads and Follow openings validate the supplied Address. Pure `observeEvent` and `observeProjections` subscriptions neither acquire an Observation nor establish existence: their `ready` frame carries no snapshot cursor. List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index de28026522..b64f424d4d 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -35,7 +35,9 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 -每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/page` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 + +`events` 从一个持久切点读取一页时间正序的记录,返回记录、`hasMore` 和包含末项的 `cursor`;省略 `throughSeq` 时选取观察到的日志末尾,兼容的 `page` 方法提供同样的读取,但入口已弃用。`events`、`observeEvent` 和 `follow` 接收 Address,投影和状态操作接收 Session ID。分页读取与 Follow 开场会校验传入的 Address。纯 `observeEvent` 和 `observeProjections` 订阅既不获取 Observation,也不确认实体存在:其 `ready` 帧不携带快照游标。 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index d02c0e5ff3..3eadb51df1 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -73,6 +73,7 @@ "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", "zod": "4.4.3" diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index fc2a11a0bb..554eec515e 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -178,6 +178,7 @@ export class SessionEventStream extends RemoteJournalStream< let assistantRevision: number | undefined for await (const frame of this.remote.session.follow({ address: this.address, + activate: true, assistantStream: true, ...this.repairRequest(request), }, signal)) { diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 07fbbfe6be..91cc3508da 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -1,12 +1,8 @@ -/** Cold Session history pagination and live-event source. */ +/** Shared Session history pagination, address validation and wire conversion. */ -import type { Context } from '@deepseek-ai/cordis' -import { Deque } from '@deepseek-ai/dsh-deque' -import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import { isAppendSurfaceEvent, SessionLogOffset, - SessionSeq, } from '@deepseek-ai/dsh-session' import type { SessionEvent, @@ -15,309 +11,26 @@ import type { SessionLogOffset as SessionLogOffsetType, SessionSeqCursor, } from '@deepseek-ai/dsh-session' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionAddress, - SessionAssistantStreamFrame, SessionEventEntry, - SessionFollowRequest, - SessionFollowFrame, SessionHistoryRecord, - SessionPage, SessionPageRequest, - SessionProjectionBaseline, - SessionProjectionValues, - SessionWireHeader, SessionWireEvent, } from './types.ts' -import { SessionAssistantStreamAccumulator } from './assistant-stream.ts' -const DEFAULT_MAX_MESSAGES = 50 +/** Message budget applied when a history request omits `maxMessages`. */ +export const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Implements cold-safe history operations delegated by the Session Controller. */ -export class SessionHistoryController { - private readonly closeFollowers = new Set<() => void>() - private readonly assistantStreams = new Map() - - /** - * @param ctx - Host context carrying Session query and projection services. - * @param promote - starts ordinary Session activation after snapshot delivery. - */ - constructor( - private readonly ctx: Context, - private readonly promote: (observation: SessionObservation) => void, - ) { - ctx.on('agent/assistant-stream', ({ agent, frame }) => { - let stream = this.assistantStreams.get(agent.session.id) - if (stream === undefined) { - stream = new SessionAssistantStreamAccumulator() - this.assistantStreams.set(agent.session.id, stream) - } - stream.accept(frame, cursorBeforeNext(agent.session.seq)) - }, { global: true }) - ctx.on('agent/disposed', ({ agent }) => { - this.assistantStreams.delete(agent.session.id) - }, { global: true }) - ctx.effect(() => () => { - for (const close of this.closeFollowers) close() - this.closeFollowers.clear() - }, 'session-controller.history') - } - - /** - * Read one message-aligned history page without activating an Agent. - * @param request - durable address and backwards-page cursor. - * @param signal - caller cancellation for persistence reads. - * @returns a contiguous event page. - */ - async page(request: SessionPageRequest, signal: AbortSignal): Promise { - validatePageRequest(request) - const throughSeq: SessionSeqCursor = request.throughSeq === -1 - ? -1 - : SessionSeq(request.throughSeq) - const beforeSeq = request.beforeSeq === undefined - ? undefined - : SessionLogOffset(request.beforeSeq) - using source = await this.sourceFor(request.address, signal, false) - signal.throwIfAborted() - const sourceLog = source.events - const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1 - if (throughSeq > sourceCursor) { - throw new RemoteError( - 'gateway/bad-request', - `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, - {}, - ) - } - /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ - if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) { - throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {}) - } - const page = paginate( - sourceLog, - beforeSeq, - request.maxMessages ?? DEFAULT_MAX_MESSAGES, - throughSeq, - request.turnWindow, - ) - const records = pageRecords(page.events) - return { - records, - hasMore: page.hasMore, - } - } - - /** - * Follow events appended after an initial cursor on one durable address. - * @param request - durable address and last committed sequence already held by the caller. - * @param signal - stream cancellation owned by the Remote carrier. - * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames. - */ - async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - validateHistoryWindow(request) - const { address } = request - const target = addressId(address) - const buffered = new Deque< - | { readonly type: 'event'; readonly event: SessionEvent } - | { - readonly type: 'assistant-stream' - readonly frame: SessionAssistantStreamFrame - readonly ordinal: number - } - >() - let snapshotCursor: SessionSeqCursor | undefined - let assistantStreamOrdinal = 0 - let wake: (() => void) | undefined - const notify = (): void => { - const resume = wake - wake = undefined - resume?.() - } - const follower = { closed: false } - const close = (): void => { - follower.closed = true - notify() - } - this.closeFollowers.add(close) - const disposeEvent = this.ctx.on('session/event', (session, event) => { - if (session.id !== target) return - buffered.pushBack({ type: 'event', event }) - notify() - }, { global: true }) - const disposeCreated = this.ctx.on('session/created', (session) => { - if (session.id !== target) return - // Constructor seed events have no session/event notification. Normally - // only the end-seed suffix is new; if persistence advanced after the - // opening observation, replay everything beyond that snapshot cursor. - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const suffix = session.snapshotEvents(snapshotCursor === undefined - ? session.firstLiveSeq - : SessionLogOffset(snapshotCursor + 1)) - for (let index = suffix.length - 1; index >= 0; index -= 1) { - buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent }) - } - notify() - }, { global: true }) - const disposeAssistantStream = request.assistantStream !== true - ? undefined - : this.ctx.on('agent/assistant-stream', ({ agent, frame }) => { - if (agent.session.id !== target) return - buffered.pushBack({ - type: 'assistant-stream', - frame: wireAssistantStreamFrame(frame, cursorBeforeNext(agent.session.seq)), - ordinal: ++assistantStreamOrdinal, - }) - notify() - }, { global: true }) - const onAbort = (): void => { notify() } - signal.addEventListener('abort', onAbort, { once: true }) - try { - using source = await this.sourceFor(address, signal, true) - const events = source.events - signal.throwIfAborted() - const cursor = source.cursor - snapshotCursor = cursor - const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow) - const assistantStream = request.assistantStream === true - ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 } - : undefined - // The accumulator snapshot and this watermark are synchronous. Frames - // through the cut are represented or superseded by that baseline, - // including larger revisions from a retired Agent; later revision - // resets reach Client continuity validation. - const assistantStreamOrdinalCut = assistantStreamOrdinal - yield { - type: 'snapshot', - header: wireHeader(source.header), - cursor, - records: pageRecords(page.events), - hasMore: page.hasMore, - projections: source.projections === undefined - ? { asOfSeq: cursor, values: {} } - : projectionBlock(source.projections), - ...assistantStream === undefined ? {} : { assistantStream }, - } - if (address.kind === 'session' && source.source === 'prepared') { - const promotion = source.retain() - try { - this.promote(promotion) - } catch (error: unknown) { - promotion[Symbol.dispose]() - throw error - } - } - let nextOffset = SessionLogOffset(cursor + 1) - while (!follower.closed && !signal.aborted) { - const item = buffered.popFront() - if (item === undefined) { - await new Promise((resolve) => { wake = resolve }) - continue - } - if (item.type === 'assistant-stream') { - if (item.ordinal > assistantStreamOrdinalCut) { - yield { type: 'assistant-stream', frame: item.frame } - } - continue - } - const expectedSeq = SessionSeq(nextOffset) - if (item.event.seq < expectedSeq) continue - if (item.event.seq !== expectedSeq) { - throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {}) - } - nextOffset = SessionLogOffset(nextOffset + 1) - yield entryFor(item.event) - } - } finally { - this.closeFollowers.delete(close) - signal.removeEventListener('abort', onAbort) - disposeCreated() - disposeEvent() - disposeAssistantStream?.() - } - } - - private async sourceFor( - address: SessionAddress, - signal: AbortSignal, - withProjections: boolean, - ): Promise { - const sessionId = addressId(address) - try { - const observation = await this.ctx.sessionQuery.observeSession(sessionId, { - signal, - projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none', - }) - if (observation.header.cwd === undefined) { - observation[Symbol.dispose]() - rejectNotFound(address) - } - try { - validateAddress( - address, - observation.header, - observation.inheritedEventCount, - observation.projections, - ) - } catch (error: unknown) { - observation[Symbol.dispose]() - throw error - } - return observation - } catch (error: unknown) { - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address) - throw error - } - } - -} - -function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor { - return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1) -} - -function wireAssistantStreamFrame( - frame: AssistantStreamFrame, - durableCursor: SessionSeqCursor, -): SessionAssistantStreamFrame { - if (frame.type === 'start') return { ...frame, startedAfterSeq: durableCursor } - if (frame.type === 'end') return frame - return { - ...frame, - chunk: frame.chunk as JsonValue, - } -} - -function projectionBlock( - snapshot: NonNullable, -): SessionProjectionBaseline { - return { - asOfSeq: snapshot.asOfSeq, - // Projection definitions validate whole JSON values before snapshot publication. - values: snapshot.values as SessionProjectionValues, - } -} - -function validatePageRequest(request: SessionPageRequest): void { - if (!Number.isSafeInteger(request.throughSeq) - || request.throughSeq < -1 - || Object.is(request.throughSeq, -0)) { - throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) - } - if (request.beforeSeq !== undefined - && (!Number.isSafeInteger(request.beforeSeq) - || request.beforeSeq < 0 - || Object.is(request.beforeSeq, -0))) { - throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) - } - validateHistoryWindow(request) -} - -function validateHistoryWindow(request: Pick): void { +/** + * Reject an invalid message budget or turn window. + * @param request - history window fields. + */ +export function validateHistoryWindow(request: Pick): void { if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) @@ -334,11 +47,23 @@ function validateHistoryWindow(request: Pick 0 } } -/** Translate current logical Session metadata to the browser wire. */ -function wireHeader(header: SessionHeader): SessionWireHeader { - return { ...header } -} - -function entryFor(event: SessionEvent): SessionEventEntry { +/** + * Convert a committed event to its wire entry. + * @param event - committed Session event. + * @returns the wire event entry. + */ +export function entryFor(event: SessionEvent): SessionEventEntry { return { type: 'event', // Session.append validates and freezes event data as JSON before publication. @@ -439,7 +179,11 @@ function entryFor(event: SessionEvent): SessionEventEntry { } } -/** Encode one bounded logical page without changing its pagination cut. */ -function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { +/** + * Encode one bounded logical page without changing its pagination cut. + * @param events - page events. + * @returns the wire records. + */ +export function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { return events.map(entryFor) } diff --git a/packages/api/session-controller/src/history/assistant-stream.ts b/packages/api/session-controller/src/history/assistant-stream.ts new file mode 100644 index 0000000000..b42a0aba29 --- /dev/null +++ b/packages/api/session-controller/src/history/assistant-stream.ts @@ -0,0 +1,102 @@ +/** Assistant stream baselines and per-connection subscriptions. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { SessionAssistantStreamAccumulator } from '../assistant-stream.ts' +import type { SessionAssistantStreamBaseline, SessionAssistantStreamFrame } from '../types.ts' +import { ListenerBuffer } from './listener.ts' + +/** One accumulator per live Session, shared by every subscription bound to it. */ +export class SessionAssistantStreamsListener { + private readonly streams = new Map() + + /** @param ctx - service context owning the accumulator listeners. */ + constructor(private readonly ctx: Context) { + ctx.on('agent/assistant-stream', ({ agent, frame }) => { + let stream = this.streams.get(agent.session.id) + if (stream === undefined) { + stream = new SessionAssistantStreamAccumulator() + this.streams.set(agent.session.id, stream) + } + stream.accept(frame, agent.session.seq === 0 ? -1 : SessionSeq(agent.session.seq - 1)) + }, { global: true }) + ctx.on('agent/disposed', ({ agent }) => { this.streams.delete(agent.session.id) }, { global: true }) + ctx.effect(() => () => { this.streams.clear() }, 'session-controller.assistant-streams') + } + + /** + * Bind one connection to a Session assistant stream. + * @param sessionId - Session to subscribe. + * @param signal - cancellation owned by the caller. + * @param queue - caller-owned raw notification buffer. + * @returns a listener the caller disposes. + */ + bind(sessionId: SessionId, signal: AbortSignal, + queue: Pick, 'createInput' | 'capture'>): AssistantStreamListener { + return new AssistantStreamListener(this.ctx, sessionId, signal, () => { + return this.streams.get(sessionId)?.snapshot() ?? { revision: 0 } + }, queue) + } +} + +type AssistantEntry = { type: 'assistant-stream'; frame: SessionAssistantStreamFrame } + +/** An assistant notification and its connection-local arrival position. */ +interface AssistantItem { + readonly type: 'assistant-stream' + readonly session: Session + readonly frame: SessionAssistantStreamFrame + readonly ordinal: number +} + +/** Writes assistant notifications into the connection's shared queue. */ +class AssistantStreamListener implements Disposable { + private readonly disposes: (() => void)[] = [] + private ordinal = 0 + private cut = 0 + + constructor( + ctx: Context, + sessionId: SessionId, + signal: AbortSignal, + private _snapshot: () => SessionAssistantStreamBaseline, + output: Pick, 'createInput' | 'capture'>) { + signal.throwIfAborted() + const input = output.createInput(item => this.transform(item)) + try { + this.disposes.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => { + if (agent.session.id !== sessionId) return + output.capture(() => { + const wire: SessionAssistantStreamFrame = frame.type === 'start' + ? { ...frame, startedAfterSeq: agent.session.seq === 0 ? -1 : SessionSeq(agent.session.seq - 1) } + : frame.type === 'chunk' ? { ...frame, chunk: frame.chunk as JsonValue } : frame + input.append({ type: 'assistant-stream', session: agent.session, frame: wire, ordinal: ++this.ordinal }) + }) + }, { global: true })) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** @returns the baseline and local arrival cut captured synchronously together. */ + snapshot(): { baseline: SessionAssistantStreamBaseline; ordinal: number } { + return { baseline: this._snapshot(), ordinal: this.ordinal } + } + + /** @param ordinal - local snapshot cut set before queue consumption. */ + setOrdinalCut(ordinal: number): void { + this.cut = ordinal + } + + private *transform(item: AssistantItem): Iterable { + if (this.disposes.length === 0 || item.ordinal <= this.cut) return + yield { type: 'assistant-stream', frame: item.frame } + } + + [Symbol.dispose](): void { + for (const dispose of this.disposes.splice(0).reverse()) dispose() + } +} diff --git a/packages/api/session-controller/src/history/controller.ts b/packages/api/session-controller/src/history/controller.ts new file mode 100644 index 0000000000..d090f0a3f7 --- /dev/null +++ b/packages/api/session-controller/src/history/controller.ts @@ -0,0 +1,256 @@ +/** Independent Session snapshots and passive event subscriptions. */ + +import type { Context } from '@deepseek-ai/cordis' +import { SessionLogOffset, SessionSeq, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { + DEFAULT_MAX_MESSAGES, paginate, pageRecords, entryFor, addressId, + validateHistoryWindow, validateAddress, rejectNotFound, +} from '../history.ts' +import { EventListener, ProjectionListener } from './listener.ts' +import { SessionAssistantStreamsListener } from './assistant-stream.ts' +import type { + SessionEventFrame, SessionEventsPage, SessionPageRequest, + SessionObserveEventRequest, SessionProjectionsRequest, SessionProjectionFrame, + SessionAddress, SessionProjectionBaseline, SessionProjectionValues, SessionProjectionsValue, SessionWaitOutput, SessionHistoryRecord, +} from '../types.ts' + + +/** Session snapshots do not share lifetime with passive subscriptions. */ +export class HistoryController { + + /** Reconnect state for this controller's assistant subscriptions. */ + readonly streamsListener: SessionAssistantStreamsListener + + /** @param ctx - Host context carrying Session reads and projections. */ + constructor(private readonly ctx: Context) { + this.streamsListener = new SessionAssistantStreamsListener(this.ctx) + } + + /** + * Read a passive page of Session events at an exact cut. + * @param request - Session identity and optional cut and page window. + * @param signal - read cancellation. + * @returns a page at the supplied cut, or the observed log end when omitted. + */ + async events(request: SessionPageRequest, signal: AbortSignal): Promise { + const spec = resolveHistoryRequest(request) + try { + return await this.usingObservation({ sessionId: addressId(request.address), projection: request.address.kind === 'subagent' }, signal, (observation) => { + validateObservationAddress(request.address, observation) + return history(observation, spec) + }) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code === 'session/not-found') rejectNotFound(request.address) + throw error + } + } + + /** + * Subscribe to Session events without reading or activating the Session. + * @param request - Session address to subscribe. + * @param signal - stream cancellation. + * @returns subscription readiness followed by event frames; readiness does not establish existence. + */ + async *observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable { + using listener = new EventListener(this.ctx, addressId(request.address), signal) + yield { type: 'ready' } + yield* listener.events() + } + + /** + * Read exact projections or migration cache hints without activating the Session. + * @param request - Session identity whose exact projection baseline is required. + * @param signal - read cancellation. + * @returns exact sequenced values, migration cache hints, or null when absent. + */ + async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { + const { sessionId } = request + if (sessionId.length === 0) { + throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) + } + try { + signal.throwIfAborted() + const live = this.ctx.sessions.get(sessionId) + if (live !== undefined) { + const snapshot = this.ctx.sessionProjections.snapshot(live) + return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + } else { + const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) + signal.throwIfAborted() + if (stored?.formatStatus === 'migration-required') { + const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) + return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } + } + return await this.usingObservation({ sessionId: request.sessionId, projection: true }, signal, observation => ({ + kind: 'sequenced' as const, + ...projections(observation), + })) + } + } catch (error: unknown) { + const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined + if (code === 'SESSION_QUERY_SESSION_NOT_FOUND' || code === 'session/not-found') { + return null + } + if (signal.aborted || code === 'SESSION_QUERY_ABORTED') { + throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) + } + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error }) + } + } + + /** + * Subscribe to sparse projection replacements without reading a baseline. + * @param request - Session identity to subscribe without reading a baseline. + * @param signal - stream cancellation. + * @returns readiness followed by sparse per-key replacement values. + */ + async *observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable { + using listener = new ProjectionListener(this.ctx, request.sessionId, signal) + yield { type: 'ready' } + yield* listener.changes() + } + + /** + * Read bounded committed assistant messages through a stopping cursor. + * @param sessionId - Session whose committed assistant messages are requested. + * @param afterSeq - exclusive lower bound. + * @param throughSeq - inclusive stopping-state cursor. + * @param maxMessages - positive assistant record limit. + * @param signal - caller cancellation, independent of a completed wait deadline. + * @returns newest-first assistant messages and whether further matching records were omitted. + */ + async assistantOutput( + sessionId: SessionId, afterSeq: number, throughSeq: number, maxMessages: number, signal: AbortSignal, + ): Promise { + return this.usingObservation({ sessionId, projection: false }, signal, (observation) => { + if (throughSeq > observation.cursor) { + throw new RemoteError('gateway/internal', 'Session history no longer covers the wait cursor', {}) + } + const records: SessionHistoryRecord[] = [] + const events = observation.events + for (let index = throughSeq; index > afterSeq; index--) { + const event = events[index] as SessionEvent + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue + if (records.length === maxMessages) return { records, truncated: true } + records.push(entryFor(event)) + } + return { records, truncated: false } + }) + } + + /** + * Acquire a read without activating a Session; projections are opt-in. + * @param request - Session identity and whether to compute projections. + * @param signal - read cancellation. + * @param callback - omitted to transfer lease ownership to the caller. + * @returns a lease the caller must dispose. + */ + usingObservation( + request: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, callback?: undefined, + ): Promise + /** + * Keep the borrowed lease alive until the callback settles, then dispose it. + * @param request - Session identity and whether to compute projections. + * @param signal - read cancellation. + * @param callback - synchronous or asynchronous computation; retain a separate lease if it must escape. + * @returns the callback result; read and callback failures propagate. + */ + usingObservation( + request: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, + callback: (observation: SessionObservation) => T, + ): Promise> + async usingObservation( + { sessionId, projection }: { readonly sessionId: SessionId; readonly projection: boolean }, signal: AbortSignal, + callback?: (observation: SessionObservation) => T, + ): Promise> { + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal, projectionMode: projection ? 'all' : 'none' }) + } catch (error: unknown) { + const code = (error as { readonly code?: unknown } | null | undefined)?.code + if (code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId }) + } + if (code === 'SESSION_QUERY_ABORTED') { + // Keep the query's own message: cancellation detail was visible before this mapping. + throw new RemoteError('gateway/cancelled', + error instanceof Error ? error.message : 'Session read was cancelled', {}, { cause: error }) + } + throw error + } + try { + signal.throwIfAborted() + } catch (error: unknown) { + observation[Symbol.dispose]() + throw error + } + if (callback === undefined) return observation + using ownedObservation = observation + return await callback(ownedObservation) + } +} + +/** + * Validate an Address against the exact read used by a Controller. + * @param address - caller-supplied Session address. + * @param observation - borrowed read; subagent addresses require projections. + */ +export function validateObservationAddress(address: SessionAddress, observation: SessionObservation): void { + if (observation.header.cwd === undefined) rejectNotFound(address) + validateAddress(address, observation.header, observation.inheritedEventCount, observation.projections) +} + +/** + * Convert a borrowed observation to one page without acquiring or retaining it. + * @param observation - exact read cut owned by the caller. + * @param request - validated page window and optional inclusive cut. + * @returns a chronological page at the requested cut. + */ +export function history(observation: SessionObservation, request: Omit & { readonly maxMessages: number }): SessionEventsPage { + const cursor = request.throughSeq === undefined ? observation.cursor + : request.throughSeq === -1 ? -1 : SessionSeq(request.throughSeq) + if (cursor > observation.cursor) { + throw new RemoteError('gateway/bad-request', `session history cut ${String(cursor)} exceeds ${String(observation.cursor)}`, {}) + } + const page = paginate( + observation.events, + request.beforeSeq === undefined ? undefined : SessionLogOffset(request.beforeSeq), + request.maxMessages, + cursor, + request.turnWindow, + ) + return { cursor, records: pageRecords(page.events), hasMore: page.hasMore } +} + +/** + * Convert the projection baseline without acquiring or retaining its observation. + * @param observation - projected read cut owned by the caller. + * @returns the exact baseline; missing projection support fails explicitly. + */ +export function projections(observation: SessionObservation): SessionProjectionBaseline { + const snapshot = observation.projections + if (snapshot === undefined) throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) + return { asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } +} + +/** + * Validate and resolve the history page window. + * @param request - external history window. + * @returns the validated request with an explicit message budget. + */ +export function resolveHistoryRequest(request: SessionPageRequest): SessionPageRequest & { readonly maxMessages: number } { + if (request.throughSeq !== undefined + && (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1 || Object.is(request.throughSeq, -0))) { + throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + } + if (request.beforeSeq !== undefined + && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0 || Object.is(request.beforeSeq, -0))) { + throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) + } + validateHistoryWindow(request) + return { ...request, maxMessages: request.maxMessages ?? DEFAULT_MAX_MESSAGES } +} diff --git a/packages/api/session-controller/src/history/listener.ts b/packages/api/session-controller/src/history/listener.ts new file mode 100644 index 0000000000..a272081620 --- /dev/null +++ b/packages/api/session-controller/src/history/listener.ts @@ -0,0 +1,303 @@ +/** Passive per-Session streams; subscriptions never acquire Session observations. */ + +import type { Context } from '@deepseek-ai/cordis' +import { Deque } from '@deepseek-ai/dsh-deque' +import { SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { entryFor } from '../history.ts' +import type { SessionProjectionChange, SessionEventEntry } from '../types.ts' + +/** Raw history notifications buffered before an opening cut is known. */ +export type EventItem = + | { readonly type: 'event'; readonly session: Session; readonly event: SessionEvent } + | { readonly type: 'created'; readonly session: Session; readonly end: number } + +/** A single subscription buffers before either consumption mode starts. */ +export class EventListener implements Disposable { + private readonly buffer: ListenerBuffer | undefined + private readonly disposes: (() => void)[] = [] + private firstReceivedSeq: number | undefined + private nextExpectedSeq: number | undefined + + /** + * @param ctx - context owning the event registrations. + * @param sessionId - Session whose notifications are emitted. + * @param signal - cancellation that wakes consumers. + * @param queue - optional caller-owned buffer; only an internally created buffer is disposed here. + */ + constructor(ctx: Context, sessionId: SessionId, signal: AbortSignal, + queue?: Pick, 'createInput' | 'capture'>) { + signal.throwIfAborted() + const buffer = queue ?? new ListenerBuffer(ctx, signal) + this.buffer = queue === undefined ? buffer as ListenerBuffer : undefined + const input = buffer.createInput(item => this.transform(item)) + const target = sessionId + try { + this.disposes.push(ctx.on('session/event', (session, event) => { + if (session.id !== target) return + buffer.capture(() => { + this.recordFirst(event.seq) + input.append({ type: 'event', session, event }) + }) + }, { global: true })) + this.disposes.push(ctx.on('session/created', (session) => { + if (session.id !== target) return + buffer.capture(() => { + if (session.firstLiveSeq < session.seq) this.recordFirst(session.firstLiveSeq) + input.prepend({ type: 'created', session, end: session.seq }) + }) + }, { global: true })) + this.buffer?.own(() => { this[Symbol.dispose]() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** Consume notifications without reading history. + * @returns event frames from the first received event, without loading a baseline. */ + events(): AsyncIterable { + return this.iterate() + } + + /** Set the inclusive opening cut for buffered history notifications. + * @param cursor - snapshot cut set before consuming the queue. */ + setCursor(cursor: number): void { + this.nextExpectedSeq = cursor + 1 + } + + private *transform(item: EventItem): Iterable { + if (this.disposes.length === 0) return + this.nextExpectedSeq ??= this.firstReceivedSeq + const result = eventItem(item, this.nextExpectedSeq) + this.nextExpectedSeq = result.next + for (const event of result.events) { + if (this.disposes.length === 0) return + yield entryFor(event) + } + } + + private async *iterate(): AsyncIterable { + if (this.buffer === undefined) throw new Error('Consume the externally owned history buffer directly') + try { + yield* this.buffer.items() + } finally { + this[Symbol.dispose]() + } + } + + private recordFirst(seq: number): void { + this.firstReceivedSeq = this.firstReceivedSeq === undefined ? seq : Math.min(this.firstReceivedSeq, seq) + } + + [Symbol.dispose](): void { + for (const dispose of this.disposes.splice(0).reverse()) dispose() + this.buffer?.[Symbol.dispose]() + } +} + +/** + * Select the uncovered events represented by one notification. + * @param item - one buffered notification. + * @param next - next required seq, or undefined before the first event. + * @returns uncovered events and the next required seq without acquiring an observation. + */ +export function eventItem(item: EventItem, next?: number): { + events: readonly SessionEvent[] + next: number | undefined +} { + if (item.type === 'event') { + if (next !== undefined && item.event.seq < next) return { events: [], next } + if (next !== undefined && item.event.seq !== next) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(next)}`, {}) + } + return { events: [item.event], next: item.event.seq + 1 } + } + const start = next ?? item.session.firstLiveSeq + if (start >= item.end) return { events: [], next } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session-created history adaptation. + const events = item.session.snapshotEvents(SessionLogOffset(start), SessionLogOffset(item.end)) + for (const event of events) { + if (next !== undefined && event.seq !== next) { + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(next)}`, {}) + } + next = event.seq + 1 + } + return { events, next } +} + +/** Sparse projection changes retain every key, including keys sharing a seq. */ +export class ProjectionListener implements Disposable { + private readonly buffer: ListenerBuffer<{ session: Session; change: SessionProjectionChange }> + + /** + * @param ctx - context owning projection notifications. + * @param sessionId - Session whose notifications are emitted. + * @param signal - cancellation that removes registrations and wakes consumers. + */ + constructor(ctx: Context, sessionId: SessionId, signal: AbortSignal) { + this.buffer = new ListenerBuffer(ctx, signal) + const input = this.buffer.createInput<{ session: Session; change: SessionProjectionChange }>(item => [item]) + const target = sessionId + try { + this.buffer.own(ctx.sessionProjections.onChanged((session, key, value, seq) => { + if (session.id !== target) return + this.buffer.capture(() => { + input.append({ + session, change: { type: 'projection', key, value: value as JsonValue, seq } satisfies SessionProjectionChange, + }) + }) + })) + } catch (error: unknown) { + this.buffer[Symbol.dispose]() + throw error + } + } + + /** Consume sparse per-key projection replacements. + * @returns validated replacement frames; projection seqs need not be contiguous. */ + async *changes(): AsyncIterable { + try { + for await (const { change } of this.buffer.items()) { + yield change + } + } finally { + this[Symbol.dispose]() + } + } + + [Symbol.dispose](): void { + this.buffer[Symbol.dispose]() + } +} + +/** Owns buffering, callback failure and registration cleanup for one consumer. */ +export class ListenerBuffer implements Disposable { + private readonly values = new Deque() + private readonly transformIds = new Deque() + private readonly transforms: ((value: unknown) => Iterable)[] = [] + private readonly cleanup: (() => void)[] = [] + private wake: (() => void) | undefined + private closed = false + private failure: { readonly error: unknown } | undefined + private consumed = false + + constructor(ctx: Context, signal: AbortSignal) { + signal.throwIfAborted() + const close = (): void => { this[Symbol.dispose]() } + signal.addEventListener('abort', close, { once: true }) + this.own(() => { signal.removeEventListener('abort', close) }) + try { + const dispose = ctx.effect(() => close, 'session-controller.listener-buffer') + this.own(() => { void dispose() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** + * Register cleanup, or run it immediately when already closed. + * @param dispose - synchronous cleanup. + */ + own(dispose: () => void): void { + if (this.closed) dispose() + else this.cleanup.push(dispose) + } + + /** + * Contain producer failure and wake the consumer with that failure. + * @param action - synchronous producer operation. + */ + capture(action: () => void): void { + if (this.closed) return + try { + action() + } catch (error: unknown) { + this.failure = { error } + this[Symbol.dispose]() + } + } + + /** + * Register a dequeue transform and obtain its write-only input. + * @param transform - conversion bound once and evaluated when each input is dequeued. + * @returns a write-only input sharing this buffer's storage and lifetime. + * @throws when this buffer is already closed. + */ + createInput(transform: (item: Input) => Iterable): { append(item: Input): void; prepend(item: Input): void } { + if (this.closed) throw new Error('Cannot register a transform on a closed listener buffer') + const transformId = this.transforms.length + // Only this typed input can enqueue values with its private transform id. + this.transforms.push(value => transform(value as Input)) + return { + append: (item) => { + if (this.closed) return + this.values.pushBack(item) + this.transformIds.pushBack(transformId) + this.notify() + }, + prepend: (item) => { + if (this.closed) return + this.values.pushFront(item) + this.transformIds.pushFront(transformId) + this.notify() + }, + } + } + + /** + * Consume queued values once, releasing the buffer when iteration ends. + * @returns transformed output values. + */ + async *items(): AsyncIterable { + if (this.consumed) throw new RemoteError('gateway/internal', 'Session listener already has a consumer', {}) + this.consumed = true + try { + for (;;) { + if (this.failure !== undefined) throw this.failure.error + if (this.closed) return + if (this.transformIds.size === 0) { + await new Promise((resolve) => { this.wake = resolve }) + continue + } + const transformId = this.transformIds.popFront() as number + const value = this.values.popFront() + for (const item of (this.transforms[transformId] as (value: unknown) => Iterable)(value)) { + this.throwIfFailed() + if (this.isClosed()) return + yield item + } + } + } finally { + this[Symbol.dispose]() + } + } + + private throwIfFailed(): void { + if (this.failure !== undefined) throw this.failure.error + } + + /** @returns whether disposal ended this buffer while a transform was yielding. */ + private isClosed(): boolean { + return this.closed + } + + private notify(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } + + [Symbol.dispose](): void { + if (this.closed) return + this.closed = true + this.values.clear() + this.transformIds.clear() + this.transforms.length = 0 + this.notify() + for (const dispose of this.cleanup.splice(0).reverse()) dispose() + } +} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0df6f444d4..94d08c77e3 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection-cache' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService, principalOf, type TypertResourceResolveInput } from '@deepseek-ai/dsh-typert-protocol' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' @@ -22,7 +22,9 @@ import { } from './agent.ts' import { SessionCommandController, hasPromptContent } from './commands.ts' import { SessionControlController } from './control.ts' -import { SessionHistoryController } from './history.ts' +import { HistoryController } from './history/controller.ts' +import { StateController } from './state/controller.ts' +import { WebController } from './web/controller.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' @@ -48,8 +50,8 @@ import type { SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, - SessionPage, - SessionPageRequest, + SessionEventsPage, SessionPageRequest, SessionEventFrame, SessionObserveEventRequest, + SessionProjectionFrame, SessionStateRequest, SessionState, SessionWaitRequest, SessionWaitValue, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -60,7 +62,6 @@ import type { SessionSelectModelValue, SessionProjectionsRequest, SessionProjectionsValue, - SessionProjectionValues, SessionUpdateQueueRequest, SessionUpdateQueueValue, } from './types.ts' @@ -144,6 +145,7 @@ export class SessionController extends TypertRemoteService { 'sessionProjections', 'sessionQuery', 'typert', + 'typertGateway', 'workspaceRegistry', ] @@ -152,10 +154,13 @@ export class SessionController extends TypertRemoteService { listWorkSliceMs: z.natural().min(1).default(16), }) + private historyController!: HistoryController + private stateController!: StateController + private webController!: WebController + private agents!: ApiSessionAgentController private commands!: SessionCommandController private controlState!: SessionControlController - private history!: SessionHistoryController private listState!: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly fileApplications: typeof nativeFileApplications @@ -198,7 +203,11 @@ export class SessionController extends TypertRemoteService { ctx.effect(() => async () => { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') - this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) + // Assistant-stream observation must be live before the list read yields: an Agent's + // opening frame arriving during that await would otherwise have no listener. + this.historyController = new HistoryController(ctx) + this.stateController = new StateController(ctx, this.historyController) + this.webController = new WebController(ctx, this.historyController, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) await this.listState.initialize() ctx.plugin(SessionFileReferences) @@ -234,6 +243,10 @@ export class SessionController extends TypertRemoteService { }) } + /** + * Request background activation through the existing Session Agent controller. + * @param observation - owned lease disposed by the background task; callers retain their own lease separately. + */ private promote(observation: SessionObservation): void { const sessionId = observation.header.id const task = (async () => { @@ -507,13 +520,80 @@ export class SessionController extends TypertRemoteService { /** * Read one cold-safe, message-aligned Session history page. - * @param request - durable address, backward cursor, and page budget. + * @deprecated Use `events`; this entry point remains for existing Web callers. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. * @param signal - cancellation for persistence reads. - * @returns one chronological page. + * @returns one chronological page with its inclusive cursor; an omitted `throughSeq` selects the observed log end. */ @Remote('page') - page(request: SessionPageRequest, signal: AbortSignal): Promise { - return this.history.page(request, signal) + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.historyController.events(request, signal) + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, optional fixed cut, backward cursor, and page budget. + * @param signal - cancellation for persistence reads. + * @returns one chronological page with its inclusive cursor. + */ + @Remote + events(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.historyController.events(request, signal) + } + + /** + * Subscribe to Session events without reading a snapshot. + * @param request - Session address. + * @param signal - subscription cancellation. + * @returns readiness and subsequent events, without a snapshot. + */ + @Remote({ mode: 'stream' }) + observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable { + return this.historyController.observeEvent(request, signal) + } + + /** + * Subscribe to sparse Session projection changes. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns readiness and sparse projection changes. + */ + @Remote({ mode: 'stream' }) + observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable { + return this.historyController.observeProjections(request, signal) + } + + /** + * Read passive Session state. + * @param request - target Session. + * @param signal - read cancellation. + * @returns current passive state. + */ + @Remote + state(request: SessionStateRequest, signal: AbortSignal): Promise { + return this.stateController.state(request, signal) + } + + /** + * Subscribe to complete Session state replacements. + * @param request - target Session. + * @param signal - subscription cancellation. + * @returns complete passive state replacements. + */ + @Remote({ mode: 'stream' }) + observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable { + return this.stateController.observeState(request, signal) + } + + /** + * Wait for Session quiescence or pending input with bounded assistant output. + * @param request - Session, wait bounds and output window. + * @param signal - caller cancellation. + * @returns stopping state and bounded assistant output. + */ + @Remote + wait(request: SessionWaitRequest, signal: AbortSignal): Promise { + return this.stateController.wait(request, signal) } /** @@ -525,7 +605,7 @@ export class SessionController extends TypertRemoteService { */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - return this.history.follow(request, signal) + return this.webController.follow(request, signal) } /** @@ -535,49 +615,8 @@ export class SessionController extends TypertRemoteService { * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') - async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { - const { sessionId } = request - if (sessionId.length === 0) { - throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) - } - try { - signal.throwIfAborted() - const live = this.liveProjections(sessionId) - if (live !== undefined) return live - const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) - signal.throwIfAborted() - const attached = this.liveProjections(sessionId) - if (attached !== undefined) return attached - if (stored === undefined) return null - if (stored.formatStatus === 'migration-required') { - const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) - return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } - } - using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }) - const projections = observation.projections - if (projections === undefined) { - throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) - } - return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } - } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return this.liveProjections(sessionId) ?? null - } - if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { - throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) - } - if (error instanceof RemoteError) throw error - throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error }) - } - } - - private liveProjections( - sessionId: SessionId, - ): Extract | undefined { - const session = this.ctx.sessions.get(sessionId) - if (session === undefined) return undefined - const snapshot = this.ctx.sessionProjections.snapshot(session) - return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { + return this.historyController.projections(request, signal) } /** diff --git a/packages/api/session-controller/src/state/controller.ts b/packages/api/session-controller/src/state/controller.ts new file mode 100644 index 0000000000..60b97776d9 --- /dev/null +++ b/packages/api/session-controller/src/state/controller.ts @@ -0,0 +1,221 @@ +/** Passive Session state, coalesced observations and quiescence waits. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-api-gateway' +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { HistoryController } from '../history/controller.ts' +import { DEFAULT_MAX_MESSAGES, rejectNotFound } from '../history.ts' +import type { SessionPendingInput, SessionQueuedItem, SessionState, SessionStateRequest, SessionWaitRequest, SessionWaitValue } from '../types.ts' + +import { StateListener } from './listener.ts' +import type { VersionedSessionState } from './listener.ts' + +const DEFAULT_WAIT_TIMEOUT_MS = 30_000 + +/** Reads and waits without activating or cancelling Session execution. */ +export class StateController { + + /** + * @param ctx - live Session and pending Remote event services. + * @param historyController - passive read entry used for cold state and its bounded output. + */ + constructor(private readonly ctx: Context, private readonly historyController: HistoryController) {} + + /** + * Read passive execution and pending-input state. + * @param request - Session identity. + * @param signal - read cancellation. + * @returns current passive state. + */ + async state(request: SessionStateRequest, signal: AbortSignal): Promise { + return this.readState(request.sessionId, signal) + } + + /** + * Observe complete passive state replacements. + * @param request - Session identity. + * @param signal - observation cancellation. + * @returns complete state replacements, starting with the current state. + */ + async *observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable { + using listener = this.listen(request.sessionId, signal) + for await (const { state } of listener.observeStates()) yield state + } + + /** + * Wait for quiescence, pending input, or the deadline without cancelling execution. + * @param request - Session identity, timeout and assistant output window. + * @param signal - cancellation of this wait only, never of Agent execution. + * @returns the stopping reason and last complete state; no state before timeout is an error. + */ + async wait(request: SessionWaitRequest, signal: AbortSignal): Promise { + const timeoutMs = request.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS + const afterSeq = request.afterSeq ?? -1 + const maxMessages = request.maxMessages ?? DEFAULT_MAX_MESSAGES + if (!Number.isSafeInteger(afterSeq) || afterSeq < -1 || Object.is(afterSeq, -0)) { + throw new RemoteError('gateway/bad-request', 'afterSeq must be an integer greater than or equal to -1', {}) + } + if (!Number.isSafeInteger(maxMessages) || maxMessages <= 0) { + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) { + throw new RemoteError('gateway/bad-request', 'timeoutMs must be a positive supported timer delay', {}) + } + using timer = deadline(signal, timeoutMs, 'session/wait-timeout') + using listener = this.listen(request.sessionId, timer.signal) + const sessionId = request.sessionId + const statesGenerator = listener.observeStates(1_000) + let nextStatePromise = statesGenerator.next() + let currentState: SessionState | undefined + let reason: SessionWaitValue['reason'] = 'timeout' + try { + for (;;) { + const observedState = await nextStatePromise + if (observedState.done) { + timer.signal.throwIfAborted() + throw new RemoteError('session/observation-ended', 'Session observation ended before a stopping state', {}) + } + nextStatePromise = statesGenerator.next() + // Observe rejection on immediate exits without replacing the original demand. + void nextStatePromise.catch(() => undefined) + // Only a state the listener still confirms may drive this wait; an invalidated + // sample never becomes the state a timeout reports. + if (!listener.isCurrent(observedState.value.revision)) continue + currentState = observedState.value.state + const action = computeWaitAction(currentState) + if (action === 'continue') continue + if (action === 'input-required' || action === 'idle') { + reason = action + break + } + + const agent = this.ctx.agents.get(sessionId) + if (agent === undefined || this.ctx.sessions.get(sessionId) !== agent.session) continue + if (await waitForIdleOrStateChange(agent, nextStatePromise) === 'state-changed') continue + if (!listener.isCurrent(observedState.value.revision) || this.ctx.agents.get(sessionId) !== agent + || this.ctx.sessions.get(sessionId) !== agent.session) continue + reason = 'idle' + break + } + } catch (error: unknown) { + // A caller cancellation is never a wait outcome; the deadline absorbs only the read + // it cancelled, and any other failure keeps its own error. + if (signal.aborted) throw signal.reason + if (!expired(timer.signal, error)) throw error + } finally { + // Wake/cancel a pending read before asking the generator to return. + listener[Symbol.dispose]() + await nextStatePromise.catch(() => undefined) + await statesGenerator.return(undefined) + } + signal.throwIfAborted() + if (currentState === undefined) throw new RemoteError('session/wait-timeout', 'No Session state arrived before the wait deadline', {}) + const output = await this.historyController.assistantOutput( + sessionId, afterSeq, currentState.cursor, maxMessages, signal, + ) + return { reason, state: currentState, output } + } + + private listen(sessionId: SessionId, signal: AbortSignal): StateListener { + return new StateListener(this.ctx, sessionId, signal, readSignal => this.readState(sessionId, readSignal)) + } + + /** + * @param sessionId - Session to sample. + * @param signal - read cancellation. + * @param retried - whether a live source already redirected this read once. + * @returns one passive state sample. + */ + private async readState(sessionId: SessionId, signal: AbortSignal, retried = false): Promise { + signal.throwIfAborted() + const agent = this.ctx.agents.get(sessionId) + if (agent !== undefined) { + if (agent.session.header.cwd === undefined) rejectNotFound({ kind: 'session', sessionId }) + return { + cursor: agent.session.seq - 1, + active: true, + running: agent.status === 'running', + queue: queueItems(agent), + pendingInput: this.pendingInput(agent.session.id), + } + } + const state = await this.historyController.usingObservation({ sessionId, projection: false }, signal, (observation) => { + // A live source means the Session attached during this read; report its live state + // rather than a live cursor paired with an inactive sample. + if (!retried && observation.source === 'live') return undefined + if (observation.header.cwd === undefined) rejectNotFound({ kind: 'session', sessionId }) + return { + cursor: observation.cursor, + active: false, + running: false, + queue: null, + pendingInput: this.pendingInput(observation.header.id), + } + }) + signal.throwIfAborted() + return state ?? this.readState(sessionId, signal, true) + } + + private pendingInput(sessionId: SessionId): readonly SessionPendingInput[] { + const kinds = new Set() + for (const { event, agentId } of this.ctx.typertGateway.getPendingRemoteEvents()) { + if (String(agentId) !== sessionId) continue + kinds.add(event === 'approval/request' ? 'approval' : event === 'user-questions/request' ? 'question' : 'other') + } + return [...kinds] + } +} + +/** + * @param signal - wait deadline signal. + * @param error - failure raised by the in-flight read. + * @returns whether the deadline expired and cancelled that read. + */ +function expired(signal: AbortSignal, error: unknown): boolean { + const reason = timeoutOf(signal, 'session/wait-timeout') + if (reason === undefined) return false + // Cancellation carries the deadline reason along its cause chain; any other failure does not. + let current: unknown = error + for (let depth = 0; depth < 4 && current instanceof Error; depth++) { + if (current === reason || current.cause === reason) return true + current = current.cause + } + return error === reason +} + +/** Choose the next wait action; check-idle requires the caller's whenIdle and identity checks. */ +function computeWaitAction(state: SessionState): 'input-required' | 'continue' | 'idle' | 'check-idle' { + if (state.pendingInput.length > 0) return 'input-required' + if (state.running) return 'continue' + if (!state.active) return 'idle' + return 'check-idle' +} + +/** Wait for either driver quiescence or the already-armed next state; preserve rejections from both. */ +function waitForIdleOrStateChange( + agent: Agent, + nextStatePromise: Promise>, +): Promise<'idle' | 'state-changed'> { + return Promise.race([ + agent.whenIdle().then(() => 'idle' as const), + nextStatePromise.then(() => 'state-changed' as const), + ]) +} + +function queueItems(agent: Agent): SessionQueuedItem[] { + const item = (message: UserMessage, placement: SessionQueuedItem['placement']): SessionQueuedItem => ({ + id: message.id, + placement, + ...message.source.kind === 'user' && 'rpcId' in message.source ? { rpcId: message.source.rpcId } : {}, + content: message.content as typeof message.content & readonly JsonValue[], + }) + return [ + ...agent.inbox.nextTurn.map(message => item(message, 'queued')), + ...agent.inbox.nextStep.map(message => item(message, message.source.kind === 'user' ? 'steering' : 'context')), + ] +} diff --git a/packages/api/session-controller/src/state/listener.ts b/packages/api/session-controller/src/state/listener.ts new file mode 100644 index 0000000000..15633a7cd8 --- /dev/null +++ b/packages/api/session-controller/src/state/listener.ts @@ -0,0 +1,116 @@ +/** Coalesced Session state observations with Host-local revisions. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionState } from '../types.ts' + +/** One complete state with its listener-local revision. */ +export interface VersionedSessionState { state: SessionState; revision: number } + +/** Coalesces Session/Agent invalidations with local revisions; pending events are sampled on reads. */ +export class StateListener implements Disposable { + private readonly disposes: (() => void)[] = [] + private readonly cancellation = new AbortController() + private readonly signal: AbortSignal + private revision = 0 + private resolver = createDeferredSignal() + private closed = false + + /** + * @param ctx - lifecycle notification owner. + * @param target - Session to observe. + * @param signal - caller cancellation. + * @param readState - passive complete-state reader. + */ + constructor( + ctx: Context, target: SessionId, signal: AbortSignal, + private readonly readState: (signal: AbortSignal) => Promise, + ) { + signal.throwIfAborted() + this.signal = AbortSignal.any([signal, this.cancellation.signal]) + const notify = (id: string | undefined): void => { + if (id !== target || this.closed) return + this.revision++ + const resolver = this.resolver + this.resolver = createDeferredSignal() + resolver.resolve() + } + const close = (): void => { this[Symbol.dispose]() } + signal.addEventListener('abort', close, { once: true }) + this.disposes.push(() => { signal.removeEventListener('abort', close) }) + try { + this.disposes.push(ctx.on('session/event', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('session/created', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('session/disposed', (session) => { notify(session.id) }, { global: true })) + this.disposes.push(ctx.on('agent/status', ({ agent }) => { notify(agent.id) }, { global: true })) + this.disposes.push(ctx.on('agent/created', ({ agent }) => { notify(agent.id) }, { global: true })) + this.disposes.push(ctx.on('agent/disposed', ({ agent }) => { notify(agent.id) }, { global: true })) + const dispose = ctx.effect(() => close, 'session-controller.state-listener') + this.disposes.push(() => { void dispose() }) + } catch (error: unknown) { + this[Symbol.dispose]() + throw error + } + } + + /** + * Check whether a completed read still represents the latest invalidation. + * @param revision - revision captured with a completed read. + * @returns whether no intervening invalidation occurred. + */ + isCurrent(revision: number): boolean { return !this.closed && revision === this.revision } + + /** + * Read coalesced state replacements with optional periodic refresh. + * @param refreshMs - optional idle recheck interval; omitted for event-only observation. + * @returns complete state replacements with internal consistency revisions. + */ + async *observeStates(refreshMs?: number): AsyncGenerator { + let lastRevision = -1 + try { + while (!this.closed) { + if (lastRevision === this.revision) { + if (refreshMs === undefined) { + await this.resolver.promise + } else { + let timer: ReturnType | undefined + try { + await Promise.race([ + this.resolver.promise, + new Promise((resolve) => { timer = setTimeout(resolve, refreshMs) }), + ]) + } finally { + clearTimeout(timer) + } + if (this.isCurrent(lastRevision)) { + this.revision++ + const resolver = this.resolver + this.resolver = createDeferredSignal() + resolver.resolve() + } + } + } + if (!this.isCurrent(this.revision)) return + lastRevision = this.revision + const state = await this.readState(this.signal) + if (this.isCurrent(lastRevision)) yield { state, revision: lastRevision } + } + } finally { + this[Symbol.dispose]() + } + } + + [Symbol.dispose](): void { + if (this.closed) return + this.closed = true + this.cancellation.abort() + this.resolver.resolve() + for (const dispose of this.disposes.splice(0).reverse()) dispose() + } +} + +function createDeferredSignal(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c180a45579..e3e2ea375a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -175,6 +175,10 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + /** A wait observation ended before a stopping state; the caller should reread. */ + 'session/observation-ended': Record + /** No state arrived before the deadline; a timeout after a state returns its value instead. */ + 'session/wait-timeout': Record 'session/provider-credentials-unavailable': Record 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } @@ -461,8 +465,11 @@ export interface SessionWireEvent { /** One message-aligned backwards-history request. */ export interface SessionPageRequest { readonly address: SessionAddress - /** Inclusive log cut obtained from the corresponding follow opening frame. */ - readonly throughSeq: number + /** + * Inclusive log cut; omission selects the observed log end. A follow opening + * reports the cut it covered so a caller can pin the same point. + */ + readonly throughSeq?: number readonly beforeSeq?: number readonly maxMessages?: number /** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */ @@ -477,6 +484,8 @@ export interface SessionPageRequest { /** One live event request for a durable Session address. */ export interface SessionFollowRequest extends Pick { readonly address: SessionAddress + /** Request background activation; omission leaves the read passive. */ + readonly activate?: boolean /** Include process-local assistant presentation frames for the Web client. */ readonly assistantStream?: true } @@ -571,6 +580,84 @@ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } | ({ readonly type: 'projection' } & SessionProjectionUpdate) +/** One pending live-Agent inbox item, preserving placement and content order. */ +export interface SessionQueuedItem { + readonly id: MessageId + readonly placement: 'queued' | 'steering' | 'context' + readonly rpcId?: SessionRequestId + readonly content: readonly JsonValue[] +} + +/** State-owned classification of pending forwarded Remote events. */ +export type SessionPendingInput = 'approval' | 'question' | 'other' + +/** Passive Session state; a missing live Agent has no readable queue. */ +export interface SessionState { + readonly cursor: number + readonly active: boolean + readonly running: boolean + readonly queue: readonly SessionQueuedItem[] | null + /** + * Pending Remote requests sampled when this state was read. A pending change alone does not + * trigger `observeState`; `wait` refreshes on its own interval. + */ + readonly pendingInput: readonly SessionPendingInput[] +} + +/** Session identity for a passive state read or subscription. */ +export interface SessionStateRequest { + readonly sessionId: SessionId +} + +/** Wait bounds and assistant output window; timeout omission uses the fixed 30-second default. */ +export interface SessionWaitRequest extends SessionStateRequest { + readonly timeoutMs?: number + /** Exclusive output cursor; omission includes all available assistant records. */ + readonly afterSeq?: number + /** Positive assistant-record limit; omission uses the history message budget. */ + readonly maxMessages?: number +} + +/** Assistant messages after afterSeq through the stopping state's cursor, newest first. */ +export interface SessionWaitOutput { + readonly records: readonly SessionHistoryRecord[] + /** More eligible assistant messages exist in the selected interval. */ + readonly truncated: boolean +} + +/** A stopping condition, the last complete passive state and its bounded assistant output. */ +export interface SessionWaitValue { + readonly reason: 'idle' | 'input-required' | 'timeout' + readonly state: SessionState + readonly output: SessionWaitOutput +} + +/** + * One `session.events` page (or its deprecated `page` alias) with its inclusive cut: + * the supplied `throughSeq`, otherwise the log end it was read at. + */ +export interface SessionEventsPage extends SessionPage { + readonly cursor: number +} + +/** Address for a passive event subscription. */ +export interface SessionObserveEventRequest { + readonly address: SessionAddress +} + +/** Readiness confirms subscription only; event continuity starts with the first delivered event. */ +export type SessionEventFrame = + | { readonly type: 'ready' } + | SessionEventEntry + +/** One sparse per-key replacement; different keys can share a seq. */ +export type SessionProjectionChange = { readonly type: 'projection' } & Omit + +/** Subscription readiness without a baseline, followed by per-key changes. */ +export type SessionProjectionFrame = + | { readonly type: 'ready' } + | SessionProjectionChange + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/api/session-controller/src/web/controller.ts b/packages/api/session-controller/src/web/controller.ts new file mode 100644 index 0000000000..7e849e4cca --- /dev/null +++ b/packages/api/session-controller/src/web/controller.ts @@ -0,0 +1,78 @@ +/** Web opening aggregation over one observation and a shared transforming buffer. */ + +import type { Context } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import type { HistoryController } from '../history/controller.ts' +import { addressId, rejectNotFound } from '../history.ts' +import { EventListener, ListenerBuffer } from '../history/listener.ts' +import { history, projections, resolveHistoryRequest, validateObservationAddress } from '../history/controller.ts' +import type { SessionFollowFrame, SessionFollowRequest } from '../types.ts' + +/** Combines opening data and consumes transformed listener output from one queue. */ +export class WebController { + + /** + * @param ctx - Host context carrying Session queries and identity projections. + * @param historyController - passive reading and listener construction. + * @param promote - background activation requested before an opening snapshot is delivered. + */ + constructor( + private readonly ctx: Context, + private readonly historyController: HistoryController, + private readonly promote: (observation: SessionObservation) => void, + ) {} + + /** + * Open a history snapshot and follow uncovered history and assistant notifications. + * @param request - Session identity, page budget, assistant output and optional activation (default false). + * @param signal - cancellation for opening and streaming. + * @returns an opening snapshot followed by uncovered queued increments. + */ + async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + const sessionId = addressId(request.address) + const spec = resolveHistoryRequest(request) + using queue = new ListenerBuffer(this.ctx, signal) + using listener = new EventListener(this.ctx, sessionId, signal, queue) + const streams = this.historyController.streamsListener + using assistantListener = request.assistantStream === true + ? streams.bind(sessionId, signal, queue) + : undefined + using observation = await this.historyController.usingObservation({ sessionId, projection: true }, signal) + .catch((error: unknown) => { + if (remoteErrorOf(error)?.code === 'session/not-found') rejectNotFound(request.address) + throw error + }) + validateObservationAddress(request.address, observation) + let opening: SessionFollowFrame = { + type: 'snapshot', + header: { ...observation.header }, + projections: projections(observation), + ...history(observation, spec), + } + listener.setCursor(opening.cursor) + if (assistantListener !== undefined) { + const { baseline: assistantStream, ordinal } = assistantListener.snapshot() + opening = { ...opening, assistantStream } + assistantListener.setOrdinalCut(ordinal) + } + // Increments come from the queue, so the opening ends this lease in every branch; + // activation continues on its own retained lease. Promotion starts before the opening is + // delivered, so a consumer that stops after the snapshot still gets the activation it asked for. + if (request.activate === true && !signal.aborted + && observation.header.origin !== 'subagent' && observation.source === 'prepared') { + const promotion = observation.retain() + observation[Symbol.dispose]() + try { + this.promote(promotion) + } catch (error: unknown) { + promotion[Symbol.dispose]() + throw error + } + } else { + observation[Symbol.dispose]() + } + yield opening + yield* queue.items() + } +} diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index b2abdc2dc2..20a0f08065 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types' -import { SessionSeq, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import { MutableSessionEventSource, type SessionAssistantSettlementEntry, type SessionLiveEventEntry, type SessionTransientEventEntry, @@ -11,6 +11,7 @@ import type { ProjectionsBaseline } from '../src/client/sessions/projection-stor import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import type { PromptContentPart as SessionPromptContentPart, SessionPageRequest } from '../src/types.ts' import { ev, plainTurn } from './event-script.client.ts' +import { history, pageRule } from './remote/session.client.ts' type RenameSuccess = Extract>, { readonly ok: true }> @@ -70,7 +71,21 @@ describe('Client Session contracts', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().parameter(2).toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('returns the current page cursor when throughSeq is omitted and preserves explicit cuts', async () => { + const address = { kind: 'session', sessionId: 'page-fixture' as SessionId } as const + const rule = pageRule(history(plainTurn(SessionSeq(0), 1, 'question', 'answer'))) + const current = await rule({ address }) + expect(current).toMatchObject({ ok: true, value: { cursor: 5, hasMore: false } }) + const cut = await rule({ address, throughSeq: 2 }) + expect(cut).toMatchObject({ ok: true, value: { cursor: 2, hasMore: false } }) + if (!cut.ok) throw new Error('expected a successful history page') + expect(cut.value.records.map(record => record.event.seq)).toEqual([0, 1, 2]) + await expect(rule({ address, throughSeq: -1 })).resolves.toEqual({ + ok: true, value: { cursor: -1, records: [], hasMore: false }, + }) }) it('keeps its text and image prompt parts identical to attachment intake', () => { diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index a91b9a15c0..664c4f9244 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -3,7 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -22,6 +23,75 @@ describe('SessionController facade', () => { expect(SessionController.inject).not.toContain('tools') }) + it('keeps assistant stream observation live while list initialization awaits', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + const gateway: Partial = { getPendingRemoteEvents: () => [] } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const session = ctx.sessions.create(SessionId('assistant-interleave'), { meta: { cwd: '/workspace' } }) + const partial: Partial = { id: session.id, session, status: 'running', ctx } + const agent = partial as Agent + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + const original = ctx.sessionProjections.register.bind(ctx.sessionProjections) + let first = true + vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (...args: Parameters) => { + if (first) { + first = false + entered.resolve(undefined) + await gate.promise + } + return original(...args) + }) + const mounting = createSessionTestController(ctx, defaults) + await entered.promise + // An Agent start racing the list read must not be lost before the controller publishes. + ctx.emit('agent/assistant-stream', { + agent, + frame: { type: 'start', attemptId: LlmAttemptId('boot-attempt'), revision: 1, turn: 1, step: 1 }, + }) + gate.resolve(undefined) + const controller = await mounting + const frames = controller.follow({ + address: { kind: 'session', sessionId: session.id }, assistantStream: true, + }, new AbortController().signal)[Symbol.asyncIterator]() + await expect(frames.next()).resolves.toMatchObject({ + value: { type: 'snapshot', assistantStream: { revision: 1 } }, + }) + await frames.return?.(undefined) + }) + + it('serves passive reads, subscriptions and waits through its plain controllers', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) + const gateway: Partial = { getPendingRemoteEvents: () => [] } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const controller = await createSessionTestController(ctx, defaults) + const sessionId = SessionId('passive-facade') + ctx.sessions.create(sessionId, { meta: { cwd: '/workspace' } }) + const address = { kind: 'session' as const, sessionId } + const signal = new AbortController().signal + + await expect(controller.events({ address }, signal)).resolves.toMatchObject({ records: [], hasMore: false }) + const historyFrames = controller.observeEvent({ address }, signal)[Symbol.asyncIterator]() + await expect(historyFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } }) + await historyFrames.return?.(undefined) + const projectionFrames = controller.observeProjections({ sessionId }, signal)[Symbol.asyncIterator]() + await expect(projectionFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } }) + await projectionFrames.return?.(undefined) + await expect(controller.state({ sessionId }, signal)) + .resolves.toMatchObject({ active: false, running: false, queue: null }) + const stateFrames = controller.observeState({ sessionId }, signal)[Symbol.asyncIterator]() + await expect(stateFrames.next()).resolves.toMatchObject({ value: { active: false } }) + await stateFrames.return?.(undefined) + await expect(controller.wait({ sessionId, afterSeq: -1, maxMessages: 10, timeoutMs: 50 }, signal)) + .resolves.toMatchObject({ reason: 'idle' }) + }) + it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -164,6 +234,7 @@ describe('SessionController facade', () => { const abort = new AbortController() const iterator = controller.follow({ address: { kind: 'session', sessionId }, + activate: true, }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) @@ -216,6 +287,7 @@ describe('SessionController facade', () => { }) const iterator = controller.follow({ address: { kind: 'session', sessionId }, + activate: true, }, new AbortController().signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 0e7eaed7d1..0d9eea291b 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEventEntry, - SessionPage, + SessionEventsPage, SessionWireEvent, } from '../src/types.ts' @@ -185,8 +185,9 @@ export function entries(events: readonly SessionEvent[]): SessionEventEntry[] { } /** Build one view-less history response value. */ -export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage { +export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionEventsPage { return { + cursor: events.at(-1)?.seq ?? -1, records: entries(events), hasMore, } diff --git a/packages/api/session-controller/tests/history-controller.host.spec.ts b/packages/api/session-controller/tests/history-controller.host.spec.ts new file mode 100644 index 0000000000..e3592ddbad --- /dev/null +++ b/packages/api/session-controller/tests/history-controller.host.spec.ts @@ -0,0 +1,171 @@ +import { Context } from '@deepseek-ai/cordis' +import { createMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborters: AbortController[] = [] +afterEach(async () => { + for (const abort of aborters.splice(0)) abort.abort() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() +}) +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const history = new HistoryController(ctx) + const web = new WebController(ctx, history, (observation) => { observation[Symbol.dispose]() }) + const session = ctx.sessions.create(SessionId('history-target'), { meta: { cwd: '/history-test' } }) + const abort = new AbortController() + aborters.push(abort) + const address = { kind: 'session' as const, sessionId: session.id } + return { ctx, history, web, session, abort, address } +} + +describe('HistoryController passive reads', () => { + it('subscribes without reading an Observation and emits later durable events', async () => { + const f = await fixture() + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + const stream = f.history.observeEvent({ address: f.address }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toEqual({ done: false, value: { type: 'ready' } }) + expect(observe).not.toHaveBeenCalled() + f.session.append('turn/start', { turn: 1 }) + expect(await stream.next()).toMatchObject({ value: { type: 'event', event: { seq: 0 } } }) + f.abort.abort() + expect(await stream.next()).toMatchObject({ done: true }) + }) + + it('subscribes to sparse projection changes without an Observation', async () => { + const f = await fixture() + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + let receive: Parameters[0] | undefined + const dispose = vi.fn() + vi.spyOn(f.ctx.sessionProjections, 'onChanged').mockImplementation((callback) => { + receive = callback + return dispose + }) + const stream = f.history.observeProjections({ sessionId: f.session.id }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toMatchObject({ value: { type: 'ready' } }) + receive?.(f.session, 'title', 'new title', SessionSeq(0)) + expect(await stream.next()).toMatchObject({ value: { type: 'projection', key: 'title', value: 'new title', seq: 0 } }) + expect(observe).not.toHaveBeenCalled() + f.abort.abort() + expect(await stream.next()).toMatchObject({ done: true }) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('reads newest assistant messages within the exclusive/inclusive interval and detects actual truncation', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + const first = f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'first' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + const second = f.session.append('assistant/message', { + turn: 1, step: 2, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'second' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + f.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const observe = vi.spyOn(f.ctx.sessionQuery, 'observeSession') + const all = await f.history.assistantOutput(f.session.id, -1, 3, 2, f.abort.signal) + expect(all.records.map(record => record.event.seq)).toEqual([second.seq, first.seq]) + expect(all.truncated).toBe(false) + const limited = await f.history.assistantOutput(f.session.id, -1, 3, 1, f.abort.signal) + expect(limited.records.map(record => record.event.seq)).toEqual([second.seq]) + expect(limited.truncated).toBe(true) + const interval = await f.history.assistantOutput(f.session.id, first.seq, second.seq, 2, f.abort.signal) + expect(interval.records.map(record => record.event.seq)).toEqual([second.seq]) + expect(observe).toHaveBeenCalledWith(f.session.id, { signal: f.abort.signal, projectionMode: 'none' }) + await expect(f.history.assistantOutput(f.session.id, -1, 9, 2, f.abort.signal)).rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('reads at the observed log end when throughSeq is omitted', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'latest' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + const page = await f.history.events({ address: f.address }, f.abort.signal) + expect(page.cursor).toBe(f.session.seq - 1) + expect(page.records.at(-1)?.event.seq).toBe(f.session.seq - 1) + }) + + it('keeps the append copy a model-facing replacement shadowed', async () => { + const f = await fixture() + f.session.append('turn/start', { turn: 1 }) + const replaced = f.session.append('assistant/message', { + turn: 1, step: 1, + message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'visible' }], source: { kind: 'model', provider: 'p', model: 'm' } }), + stream: [], + }, { surfaceOp: 'append' }) + // Compaction rewrites the model-facing surface with a replacement that shadows the original; + // the human-readable transcript keeps the shadowed append copy. + const replacement = f.session.append('user/message', createMessage({ + role: 'user', content: [{ type: 'text', text: 'compacted' }], source: { kind: 'user' }, + }), { surfaceOp: { op: 'replace', startSeq: replaced.seq, endSeq: replaced.seq }, sourceEventSeqs: [replaced.seq] }) + const output = await f.history.assistantOutput(f.session.id, -1, replacement.seq, 5, f.abort.signal) + expect(output.records.map(record => record.event.seq)).toEqual([replaced.seq]) + }) + + it('releases the opening Observation when activation has nothing to promote', async () => { + const f = await fixture() + const source = await f.ctx.sessionQuery.observeSession(f.session.id) + const dispose = vi.fn(() => { source[Symbol.dispose]() }) + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockResolvedValue({ ...source, [Symbol.dispose]: dispose }) + const stream = f.web.follow({ address: f.address, activate: true }, f.abort.signal)[Symbol.asyncIterator]() + await stream.next() + f.abort.abort() + await stream.next() + expect(dispose).toHaveBeenCalled() + }) + + it('releases an acquired Observation when caller cancellation wins its return', async () => { + const f = await fixture() + const source = await f.ctx.sessionQuery.observeSession(f.session.id) + const dispose = vi.fn(() => { source[Symbol.dispose]() }) + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockImplementation(async () => { + f.abort.abort() + return { ...source, [Symbol.dispose]: dispose } + }) + await expect(f.history.usingObservation({ sessionId: f.session.id, projection: false }, f.abort.signal)).rejects.toBeDefined() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('maps a non-Error query cancellation to a cancelled Remote error', async () => { + const f = await fixture() + // A cross-realm or opaque source may reject with the code alone; the message falls back. + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockRejectedValue({ code: 'SESSION_QUERY_ABORTED' }) + await expect(f.history.events({ address: f.address }, f.abort.signal)).rejects.toMatchObject({ + code: 'gateway/cancelled', message: 'Session read was cancelled', + }) + }) + + it('maps missing subagent Follow reads through the Address error', async () => { + const f = await fixture() + const stream = f.web.follow({ address: { + kind: 'subagent', parentSessionId: f.session.id, childSessionId: SessionId('missing-child'), mode: 'continuable', + } }, f.abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).rejects.toMatchObject({ code: 'subagent/not-found' }) + }) + + it('does not promote a live Session when activation was requested', async () => { + const f = await fixture() + const promote = vi.fn() + const web = new WebController(f.ctx, f.history, promote) + const stream = web.follow({ address: f.address, activate: true }, f.abort.signal)[Symbol.asyncIterator]() + expect(await stream.next()).toMatchObject({ value: { type: 'snapshot' } }) + const pending = stream.next() + f.abort.abort() + expect(await pending).toMatchObject({ done: true }) + expect(promote).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/listeners.host.spec.ts b/packages/api/session-controller/tests/listeners.host.spec.ts new file mode 100644 index 0000000000..34207efb3c --- /dev/null +++ b/packages/api/session-controller/tests/listeners.host.spec.ts @@ -0,0 +1,263 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import { LlmAttemptId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventListener, ListenerBuffer, ProjectionListener, eventItem } from '../src/history/listener.ts' +import { SessionAssistantStreamsListener } from '../src/history/assistant-stream.ts' +import type { SessionFollowFrame, SessionEventEntry } from '../src/types.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborts: AbortController[] = [] +function cancellation() { + const abort = new AbortController() + aborts.push(abort) + return abort +} +afterEach(async () => { + for (const abort of aborts.splice(0)) abort.abort() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() +}) +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const session = ctx.sessions.create(SessionId('listener-target'), { meta: { cwd: '/listeners' } }) + const abort = cancellation() + const partial: Partial = { id: session.id, session } + const agent = partial as Agent + const emit = (frame: AssistantStreamFrame) => { ctx.emit('agent/assistant-stream', { agent, frame }) } + return { ctx, session, abort, emit, agent } +} +const start = (revision = 1): AssistantStreamFrame => ({ type: 'start', attemptId: LlmAttemptId('attempt'), revision, turn: 1, step: 1 }) + +describe('ListenerBuffer', () => { + it('orders heterogeneous inputs lazily, supports prepend, and admits only one consumer', async () => { + const { ctx, abort } = await fixture() + using buffer = new ListenerBuffer(ctx, abort.signal) + const convert = vi.fn((value: number) => [`n:${value}`]) + const numbers = buffer.createInput(convert) + const words = buffer.createInput(value => [value]) + numbers.append(2) + words.prepend('first') + expect(convert).not.toHaveBeenCalled() + const values = buffer.items()[Symbol.asyncIterator]() + expect(await values.next()).toEqual({ done: false, value: 'first' }) + expect(await values.next()).toEqual({ done: false, value: 'n:2' }) + await expect(buffer.items()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ code: 'gateway/internal' }) + const waiting = values.next() + numbers.append(3) + expect(await waiting).toMatchObject({ value: 'n:3' }) + await values.return?.() + const action = vi.fn() + buffer.capture(action) + numbers.append(4) + words.prepend('closed') + expect(action).not.toHaveBeenCalled() + buffer.own(action) + expect(action).toHaveBeenCalledOnce() + expect(() => buffer.createInput(value => [value])).toThrow('closed') + }) + + it('propagates captured failure both before and during transformation', async () => { + const { ctx, abort } = await fixture() + using first = new ListenerBuffer(ctx, abort.signal) + first.capture(() => { throw new Error('captured') }) + await expect(first.items()[Symbol.asyncIterator]().next()).rejects.toThrow('captured') + using second = new ListenerBuffer(ctx, abort.signal) + const input = second.createInput(function* (value) { + second.capture(() => { throw new Error('transform failure') }) + yield value + }) + input.append(1) + await expect(second.items()[Symbol.asyncIterator]().next()).rejects.toThrow('transform failure') + }) + + it('does not emit after transformation closes its own buffer', async () => { + const { ctx, abort } = await fixture() + using buffer = new ListenerBuffer(ctx, abort.signal) + buffer.createInput(function* (value) { buffer[Symbol.dispose](); yield value }).append(1) + expect(await buffer.items()[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('releases abort registration when effect installation fails', async () => { + const { ctx, abort } = await fixture() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(ctx, 'effect').mockImplementation(() => { throw new Error('effect failure') }) + expect(() => new ListenerBuffer(ctx, abort.signal)).toThrow('effect failure') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) +}) + +describe('EventListener', () => { + it('starts at the first notification without acquiring a snapshot', async () => { + const { ctx, session, abort } = await fixture() + using listener = new EventListener(ctx, session.id, abort.signal) + const event = session.append('turn/start', { turn: 1 }) + const values = listener.events()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { type: 'event', event } }) + abort.abort() + expect(await values.next()).toMatchObject({ done: true }) + }) + + it('subscribes before an empty Session is created and follows its first later event', async () => { + const { ctx, abort } = await fixture() + const id = SessionId('created-after-bind') + using listener = new EventListener(ctx, id, abort.signal) + const session = ctx.sessions.create(id) + const values = listener.events()[Symbol.asyncIterator]() + const waiting = values.next() + const event = session.append('turn/start', { turn: 1 }) + expect(await waiting).toMatchObject({ value: { event } }) + await values.return?.() + }) + + it('filters buffered events through a supplied cursor', async () => { + const { ctx, session, abort } = await fixture() + using listener = new EventListener(ctx, session.id, abort.signal) + session.append('turn/start', { turn: 1 }) + const event = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + listener.setCursor(0) + const values = listener.events()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { event } }) + await values.return?.() + }) + + it('leaves an external buffer open and drops notifications after listener disposal', async () => { + const { ctx, session, abort } = await fixture() + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = new EventListener(ctx, session.id, abort.signal, queue) + session.append('turn/start', { turn: 1 }) + await expect(listener.events()[Symbol.asyncIterator]().next()).rejects.toThrow('externally owned') + listener[Symbol.dispose]() + const values = queue.items()[Symbol.asyncIterator]() + const next = values.next() + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) + + it('stops within a creation batch when its binding is disposed', async () => { + const { ctx, session, abort } = await fixture() + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = new EventListener(ctx, session.id, abort.signal, queue) + listener.setCursor(-1) + ctx.emit('session/created', session) + const values = queue.items()[Symbol.asyncIterator]() + expect(await values.next()).toMatchObject({ value: { event: { seq: 0 } } }) + listener[Symbol.dispose]() + const waiting = values.next() + abort.abort() + expect(await waiting).toMatchObject({ done: true }) + }) + + it('cleans a partially installed registration when a later one fails', async () => { + const { ctx, session, abort } = await fixture() + const on = ctx.on.bind(ctx) + let count = 0 + vi.spyOn(ctx, 'on').mockImplementation((...args) => { + if (++count === 2) throw new Error('registration failure') + return on(...args) + }) + expect(() => new EventListener(ctx, session.id, abort.signal)).toThrow('registration failure') + }) + + it('handles raw events and rejects a noncontiguous supplied creation prefix', async () => { + const { session } = await fixture() + const event = session.append('turn/start', { turn: 1 }) + expect(eventItem({ type: 'event', session, event })).toEqual({ events: [event], next: 1 }) + expect(eventItem({ type: 'created', session, end: 0 })).toEqual({ events: [], next: undefined }) + vi.spyOn(session, 'snapshotEvents').mockReturnValue([{ ...event, seq: SessionSeq(1) }]) + expect(() => eventItem({ type: 'created', session, end: 2 }, 0)).toThrow('skipped seq') + expect(eventItem({ type: 'created', session, end: 2 })).toMatchObject({ next: 2 }) + }) +}) + +describe('ProjectionListener', () => { + it('emits sparse changes only for the selected Session', async () => { + const { ctx, session, abort } = await fixture() + let receive!: Parameters[0] + const dispose = vi.fn() + vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation((listener) => { receive = listener; return dispose }) + using listener = new ProjectionListener(ctx, session.id, abort.signal) + const other = ctx.sessions.create(SessionId('other')) + receive(other, 'subagent', null, SessionSeq(0)) + receive(session, 'subagent', null, SessionSeq(1)) + const changes = listener.changes()[Symbol.asyncIterator]() + expect(await changes.next()).toMatchObject({ value: { type: 'projection', key: 'subagent', value: null, seq: 1 } }) + abort.abort() + expect(await changes.next()).toMatchObject({ done: true }) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('releases its buffer when projection subscription fails', async () => { + const { ctx, session, abort } = await fixture() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(ctx.sessionProjections, 'onChanged').mockImplementation(() => { throw new Error('projection subscribe failed') }) + expect(() => new ProjectionListener(ctx, session.id, abort.signal)).toThrow('projection subscribe failed') + expect(remove).toHaveBeenCalled() + }) +}) + +describe('AssistantStreamListener', () => { + it('cuts baseline-covered arrivals and clears reconnect state on Agent disposal', async () => { + const { ctx, session, abort, emit, agent } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + using listener = streams.bind(session.id, abort.signal, queue) + expect(listener.snapshot()).toEqual({ baseline: { revision: 0 }, ordinal: 0 }) + emit(start()) + expect(listener.snapshot()).toMatchObject({ baseline: { revision: 1 }, ordinal: 1 }) + listener.setOrdinalCut(1) + const frames = queue.items()[Symbol.asyncIterator]() + const end: AssistantStreamFrame = { type: 'end', attemptId: LlmAttemptId('attempt'), revision: 2, index: 0, outcome: { kind: 'abandoned' } } + emit(end) + expect(await frames.next()).toMatchObject({ value: { type: 'assistant-stream', frame: end } }) + ctx.emit('agent/disposed', { agent }) + expect(listener.snapshot()).toEqual({ baseline: { revision: 0 }, ordinal: 2 }) + abort.abort() + expect(await frames.next()).toMatchObject({ done: true }) + }) + + it('records the durable cursor for an attempt started after a committed event', async () => { + const { ctx, session, abort, emit } = await fixture() + session.append('turn/start', { turn: 1 }) + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + using listener = streams.bind(session.id, abort.signal, queue) + emit(start()) + listener.setOrdinalCut(0) + const frames = queue.items()[Symbol.asyncIterator]() + expect(await frames.next()).toMatchObject({ value: { frame: { type: 'start', startedAfterSeq: 0 } } }) + await frames.return?.() + }) + + it('leaves queue ownership intact and drops notifications after unbinding', async () => { + const { ctx, session, abort, emit } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + const listener = streams.bind(session.id, abort.signal, queue) + emit(start()) + listener[Symbol.dispose]() + const next = queue.items()[Symbol.asyncIterator]().next() + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) + + it('propagates registration failure without closing the shared queue', async () => { + const { ctx, session, abort } = await fixture() + const streams = new SessionAssistantStreamsListener(ctx) + using queue = new ListenerBuffer(ctx, abort.signal) + vi.spyOn(ctx, 'on').mockImplementation(() => { throw new Error('assistant subscribe failed') }) + expect(() => streams.bind(session.id, abort.signal, queue)).toThrow('assistant subscribe failed') + // The failed binding leaves the shared queue usable for the rest of the connection. + const input = queue.createInput(value => [value]) + const event = session.append('turn/start', { turn: 1 }) + input.append({ type: 'event', event }) + await expect(queue.items()[Symbol.asyncIterator]().next()).resolves.toMatchObject({ value: { type: 'event', event } }) + }) +}) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 57eb9ecbfa..9661f66a01 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -862,7 +862,7 @@ describe('subagent catalogs', () => { childSessionId: S2, mode: 'continuable', }, - assistantStream: true, + activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }, @@ -958,7 +958,7 @@ describe('subagent catalogs', () => { if (source === 'control') { manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) } else { - mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + mock.stream(FOLLOW, followScript(ok({ cursor: baseline.asOfSeq, records: [], hasMore: false, projections: baseline }))) await session.open() } @@ -983,7 +983,7 @@ describe('subagent catalogs', () => { if (source === 'control') { manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) } else { - mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + mock.stream(FOLLOW, followScript(ok({ cursor: baseline.asOfSeq, records: [], hasMore: false, projections: baseline }))) await manager.get(S1).open() } response.resolve(ok({ kind: 'migration-required', values: { @@ -1545,6 +1545,7 @@ describe('connected generation', () => { it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => { mock.stream(FOLLOW, followScript(ok({ + cursor: 5, records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 150b82d415..539962a622 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -239,7 +239,7 @@ describe('Session projection discovery', () => { kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] }, }) - expect(observe).toHaveBeenCalledWith(parent.header.id, { signal }) + expect(observe).toHaveBeenCalledWith(parent.header.id, { signal, projectionMode: 'all' }) expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal }) expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined() @@ -310,7 +310,7 @@ describe('Session projection discovery', () => { .rejects.toMatchObject({ code: 'session/projections-unavailable' }) }) - it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => { + it.each([false, true])('returns the observation not-found result without a second live lookup (attached: %s)', async (attached) => { const fixture = await harness() const parent = await stored(fixture, SessionId('missing-after-preflight')) const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) @@ -322,9 +322,8 @@ describe('Session projection discovery', () => { }) const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) - expect(result).toEqual(attached - ? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) } - : null) + expect(result).toBeNull() + expect(fixture.ctx.sessions.get(parent.header.id) !== undefined).toBe(attached) }) it('serves the live registry cut instead of a conflicting cached hint', async () => { @@ -342,7 +341,7 @@ describe('Session projection discovery', () => { expect(cached).not.toHaveBeenCalled() }) - it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => { + it.each([true, false])('uses migration preflight before observing a Session attached during stat (stored header present: %s)', async (present) => { const fixture = await harness() const parent = await stored(fixture, SessionId('stat-race'), {}, 3) const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) @@ -358,7 +357,9 @@ describe('Session projection discovery', () => { const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) live.append('turn/start', { turn: 1 }) finish.resolve(present ? snapshot : undefined) - await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) + await expect(operation).resolves.toEqual(present + ? { kind: 'migration-required', values: {} } + : { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) } finally { finish.resolve(undefined) await Promise.allSettled([operation]) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 40e1f3c95f..ab1e185744 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -214,7 +214,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) + mock.stream(FOLLOW, followScript(ok({ cursor: 5, records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9)) await session.resync() diff --git a/packages/api/session-controller/tests/reference-ownership.client.spec.ts b/packages/api/session-controller/tests/reference-ownership.client.spec.ts index 16de92ef02..b0563eb879 100644 --- a/packages/api/session-controller/tests/reference-ownership.client.spec.ts +++ b/packages/api/session-controller/tests/reference-ownership.client.spec.ts @@ -21,7 +21,7 @@ declare module '@deepseek-ai/dsh-api-session-controller/client' { const viewSource: SessionReferenceSource = 'referenceTestView' const workSource: SessionReferenceSource = 'referenceTestWork' const ID = SessionId('reference-session') -const EMPTY_HISTORY = ok({ records: [], hasMore: false }) +const EMPTY_HISTORY = ok({ cursor: -1, records: [], hasMore: false }) const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) }) async function bench(mock: RemoteMock, start: () => Promise, listed = true) { diff --git a/packages/api/session-controller/tests/remote/history.client.ts b/packages/api/session-controller/tests/remote/history.client.ts index e954f89a2b..080571b09d 100644 --- a/packages/api/session-controller/tests/remote/history.client.ts +++ b/packages/api/session-controller/tests/remote/history.client.ts @@ -1,7 +1,7 @@ /** Pure history response builders for assembled Session tests. */ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import type { - SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline, + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionEventsPage, SessionProjectionBaseline, } from '../../src/types.ts' import { historyRecordLastSeq } from '../../src/client/sessions/history-records.ts' @@ -11,8 +11,8 @@ import { historyRecordLastSeq } from '../../src/client/sessions/history-records. * @param throughSeq - inclusive final sequence. * @returns the page without records beyond the cursor. */ -export function pageThrough(page: SessionPage, throughSeq: number): SessionPage { - return { ...page, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } +export function pageThrough(page: SessionPage, throughSeq: number): SessionEventsPage { + return { ...page, cursor: throughSeq, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } } /** diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index 591dd60985..f920d1592e 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -12,7 +12,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, - SessionPage, SessionPageRequest, + SessionEventsPage, SessionPageRequest, } from '../../src/types.ts' import { entries, historyValue } from '../event-script.client.ts' import { followSnapshot, pageThrough } from './history.client.ts' @@ -24,7 +24,7 @@ export const FOLLOW = 'session/follow' export const PAGE = 'session/page' /** A history answer as the Host returns it, possibly still pending. */ -export type HistoryAnswer = RemoteResult | Promise> +export type HistoryAnswer = RemoteResult | Promise> /** A history answer, or a function of the request that produces one. */ export type HistorySource = HistoryAnswer | ((request: Request) => HistoryAnswer) @@ -43,7 +43,7 @@ export function err(error: RemoteFailure): RemoteResult { * @param hasMore - whether older history exists. * @returns the success result. */ -export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { +export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { return ok(historyValue(events, hasMore)) } @@ -102,11 +102,11 @@ export function followScript( */ export function pageRule( history: HistorySource, -): UnaryRuleFn>> { +): UnaryRuleFn>> { return async (page) => { const result = await answer(history, page) if (!result.ok) return result - return ok(pageThrough(result.value, page.throughSeq)) + return ok(pageThrough(result.value, page.throughSeq ?? result.value.cursor)) } } @@ -144,12 +144,12 @@ export const sessionWorld: RemoteTable = { attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==', }), - 'session/page': pageRule(ok({ records: [], hasMore: false })), + 'session/page': pageRule(ok({ cursor: -1, records: [], hasMore: false })), 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), 'subagents/interruptByParent': ok({ accepted: true }), }, stream: { - 'session/follow': followScript(ok({ records: [], hasMore: false })), + 'session/follow': followScript(ok({ cursor: -1, records: [], hasMore: false })), }, } diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 1bc9297103..560c05514f 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -11,7 +11,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' @@ -574,10 +573,7 @@ describe('subagent ownership fence', () => { const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const history = await new SessionHistoryController( - ctx, - (observation) => { observation[Symbol.dispose]() }, - ).page({ + const history = await ctx.sessionController.events({ address: { kind: 'subagent', parentSessionId: meta.parentSession as SessionId, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index f1954ed935..328e797677 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -8,7 +8,8 @@ import SessionStore from '@deepseek-ai/dsh-session' import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' import type { SessionFollowFrame, SessionPage, SessionWireEvent } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' @@ -57,6 +58,12 @@ async function harness(): Promise<{ ctx: Context }> { return { ctx } } +/** Initialize the independent snapshot owner and Web aggregation for direct journal tests. */ +async function controllers(ctx: Context): Promise<{ history: HistoryController; web: WebController }> { + const history = new HistoryController(ctx) + return { history, web: new WebController(ctx, history, (observation) => { observation[Symbol.dispose]() }) } +} + /** Drain one Session follow until `count` event frames arrive. */ async function collect( iterable: AsyncIterable, @@ -73,11 +80,11 @@ async function collect( /** Open follow and wait until its cursor is fixed before appending fixtures. */ async function openFollow( - history: SessionHistoryController, + web: WebController, sessionId: SessionId, signal: AbortSignal, ): Promise> { - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId }, }, signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ @@ -107,9 +114,9 @@ describe('Session history raw journal', () => { it('opens an empty opted-in Assistant baseline before any live attempt exists', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -127,7 +134,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -137,7 +144,7 @@ describe('Session history raw journal', () => { return originalObserve(...args) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -182,7 +189,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('live-follow-attempt') const emit = (frame: AssistantStreamFrame): void => { ctx.emit('agent/assistant-stream', { agent, frame }) @@ -197,7 +204,7 @@ describe('Session history raw journal', () => { time: 1, chunk: firstChunk, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -249,7 +256,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId(`${session.id}:1`) ctx.emit('agent/assistant-stream', { agent, @@ -267,7 +274,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -311,7 +318,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('revision-gap-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -329,7 +336,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -351,7 +358,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('dense-index-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -369,7 +376,7 @@ describe('Session history raw journal', () => { }, }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -391,7 +398,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId('cached-baseline-attempt') ctx.emit('agent/assistant-stream', { agent, @@ -402,12 +409,12 @@ describe('Session history raw journal', () => { }) const firstAbort = new AbortController() - const firstIterator = history.follow({ + const firstIterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, firstAbort.signal)[Symbol.asyncIterator]() const secondAbort = new AbortController() - const secondIterator = history.follow({ + const secondIterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, secondAbort.signal)[Symbol.asyncIterator]() @@ -431,9 +438,9 @@ describe('Session history raw journal', () => { it('opens an empty Assistant baseline before the target Agent emits frames', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -456,9 +463,9 @@ describe('Session history raw journal', () => { const target = ctx.sessions.create(undefined, { meta: { cwd: '/target' } }) const other = ctx.sessions.create(undefined, { meta: { cwd: '/other' } }) const otherAgent = { id: other.id, session: other, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: target.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -489,7 +496,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const observationStarted = Promise.withResolvers() const releaseObservation = Promise.withResolvers() const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) @@ -499,7 +506,7 @@ describe('Session history raw journal', () => { return await originalObserve(sessionId, options) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -539,7 +546,7 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const attemptId = LlmAttemptId(`${session.id}:1`) ctx.emit('agent/assistant-stream', { agent, @@ -557,7 +564,7 @@ describe('Session history raw journal', () => { return await originalObserve(sessionId, options) }) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, assistantStream: true, }, abort.signal)[Symbol.asyncIterator]() @@ -613,9 +620,9 @@ describe('Session history raw journal', () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const agent = { id: session.id, session, status: 'running', ctx } as Agent - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const iterator = history.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() const opening = await iterator.next() @@ -655,9 +662,9 @@ describe('Session history raw journal', () => { it('follows raw tool events and preserves result metadata without a Tools service', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const collected = collect(stream, 2, abort) const call = session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('raw-call'), name: 'custom', arguments: '{malformed', @@ -684,9 +691,9 @@ describe('Session history raw journal', () => { it('follows live results without rescanning Session history', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() session.append('tool/call', { @@ -957,9 +964,9 @@ describe('Session history raw journal', () => { it('follows a result after turn/end without reading the addressed Session log', async () => { const { ctx } = await harness() const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const { web } = await controllers(ctx) const abort = new AbortController() - const stream = await openFollow(history, session.id, abort.signal) + const stream = await openFollow(web, session.id, abort.signal) const iterator = stream[Symbol.asyncIterator]() session.append('turn/start', { turn: 1 }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index e20a0840fd..75f81b9d01 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -15,7 +15,7 @@ import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionEventStream } from '../src/client/transport.ts' -import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts' +import type { SessionFollowRequest, SessionEventsPage, SessionPageRequest } from '../src/types.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' import { sessionBench } from './remote/bench.client.ts' import { @@ -122,7 +122,7 @@ describe('Session open', () => { it('stitches live frames landing right behind the opening snapshot, dropping the page overlap', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const gate = Promise.withResolvers() + const gate = Promise.withResolvers() const page = plainTurn(SessionSeq(10), 0, '早', '安') mock.stream(FOLLOW, async ([request], stream) => { stream.push(followSnapshot(await gate.promise, request as SessionFollowRequest)) @@ -243,7 +243,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(request => (request.beforeSeq === 12 ? history(middle, true) : history(oldest)))) mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) @@ -279,7 +279,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() const secondRequested = Promise.withResolvers() - const lastPage = Promise.withResolvers>() + const lastPage = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule((request) => { if (request.beforeSeq === 12) return history(middle, true) secondRequested.resolve(undefined) @@ -334,7 +334,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() const secondRequested = Promise.withResolvers() - const lastPage = Promise.withResolvers>() + const lastPage = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule((request) => { if (request.beforeSeq === 12) return history(plainTurn(SessionSeq(6), 1, 'middle', 'answer'), true) secondRequested.resolve(undefined) @@ -360,7 +360,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(history(oldest))) mock.remote.session.page.mockReturnValueOnce(gate.promise) const first = session.loadThrough(SessionSeq(6)) @@ -381,7 +381,7 @@ describe('paging', () => { await session.open() // A plain single-page pull holds the busy flag while the jump is refused. - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockReturnValueOnce(gate.promise) const older = session.loadOlder() await session.loadThrough(SessionSeq(0)) // refused: must not park seq 0 anywhere @@ -409,7 +409,7 @@ describe('paging', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) // The address is rebuilt while the first page is in flight; the same follow script serves the new generation. @@ -459,7 +459,7 @@ describe('paging', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(gate.promise)) const first = session.loadOlder() const second = session.loadOlder() @@ -484,7 +484,7 @@ describe('prompt and cancel errors', () => { expect(steered).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: { kind: 'subagent', ...CHILD }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) // The prompt mode crosses the wire as the request's delivery. @@ -593,7 +593,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) expect(mock.log.requests('session/cancel')).toEqual([]) @@ -878,7 +878,7 @@ describe('remaining branches', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const gate = Promise.withResolvers>() + const gate = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(gate.promise)) await pushEvent(mock, ev.user(SessionSeq(9), '洞一')) await pushEvent(mock, ev.user(SessionSeq(10), '洞二')) @@ -891,7 +891,7 @@ describe('remaining branches', () => { it('drops a stale opening without replacing the new generation error state', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) @@ -903,7 +903,7 @@ describe('remaining branches', () => { it('drops a stale doOpen whose history resolved successfully after resync superseded it', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) @@ -917,7 +917,7 @@ describe('remaining branches', () => { const session = await sessionBench(mock, start, SID) mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const repairPull = Promise.withResolvers>() + const repairPull = Promise.withResolvers>() mock.remote.session.page.mockImplementation(pageRule(repairPull.promise)) await pushEvent(mock, ev.user(SessionSeq(9), '洞')) await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) }) @@ -959,7 +959,7 @@ describe('resync', () => { mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '旧', '窗')))) await session.open() const oldWindow = session.eventSource.getSnapshot() - const replacement = Promise.withResolvers() + const replacement = Promise.withResolvers() // The reconnect generation: its snapshot is gated, then two live frames land out of order right behind it. mock.stream(FOLLOW, async ([request], stream) => { stream.push(followSnapshot(await replacement.promise, request as SessionFollowRequest)) @@ -1018,7 +1018,7 @@ describe('resync', () => { it('drops a stale in-flight open superseded by resync (generation guard)', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) - const stale = Promise.withResolvers>() + const stale = Promise.withResolvers>() mock.stream(FOLLOW, followScript(() => stale.promise)) const firstOpen = session.open() mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index f43e32e64e..212320fdf6 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -12,7 +12,7 @@ import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/s import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' import type { - SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionHistoryRecord, } from '../src/types.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -174,7 +174,7 @@ describe('scope tree', () => { it('opens a conversation from follow projections without a second projection request', async ({ bench }) => { const b = bench() b.mock.stream(FOLLOW, followScript(ok({ - records: [], hasMore: false, + cursor: 0, records: [], hasMore: false, projections: { asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'one-shot', }] } }, @@ -277,7 +277,7 @@ describe('scope tree', () => { it('replaces an active assistant baseline on reconnect without duplicate chunks', async ({ bench }) => { const b = bench() const attemptId = LlmAttemptId('reconnect-attempt') - let records: never[] = [] + let records: SessionHistoryRecord[] = [] let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { @@ -287,7 +287,7 @@ describe('scope tree', () => { }, } b.mock.stream(FOLLOW, followScript( - () => ok({ records, hasMore: false }), + () => ok({ cursor: records.at(-1)?.event.seq ?? -1, records, hasMore: false }), { assistantStream: () => assistantStreamBaseline }, )) await feedList(b, [{ id: 's1' }]) @@ -361,6 +361,7 @@ describe('scope tree', () => { }, } const history = ok({ + cursor: priorMessage.event.seq, records: [priorMessage] as never[], hasMore: false, }) @@ -437,7 +438,7 @@ describe('scope tree', () => { surfaceOp: 'append' as const, }, } - let records = [prior] as never[] + let records: SessionHistoryRecord[] = [prior] let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { @@ -450,7 +451,7 @@ describe('scope tree', () => { }, } b.mock.stream(FOLLOW, followScript( - () => ok({ records, hasMore: false }), + () => ok({ cursor: records.at(-1)?.event.seq ?? -1, records, hasMore: false }), { assistantStream: () => assistantStreamBaseline }, )) await feedList(b, [{ id: 's1' }]) diff --git a/packages/api/session-controller/tests/state-controller.host.spec.ts b/packages/api/session-controller/tests/state-controller.host.spec.ts new file mode 100644 index 0000000000..5fc74006ff --- /dev/null +++ b/packages/api/session-controller/tests/state-controller.host.spec.ts @@ -0,0 +1,480 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { brandString } from '@deepseek-ai/dsh-brand' +import type AgentRegistry from '@deepseek-ai/dsh-agent' +import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { PendingRemoteEventInfo } from '@deepseek-ai/dsh-api-gateway' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { StateController } from '../src/state/controller.ts' +import { StateListener } from '../src/state/listener.ts' +import type { SessionState, SessionRequestId } from '../src/types.ts' +import { installSessionReadTestServices } from './test-remote.ts' + +const roots: Context[] = [] +const aborters: AbortController[] = [] +const operations: Promise[] = [] +function cancellation(): AbortController { + const abort = new AbortController() + aborters.push(abort) + return abort +} +function tracked(promise: Promise): Promise { + operations.push(promise) + void promise.catch(() => undefined) + return promise +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} +afterEach(async () => { + for (const abort of aborters.splice(0)) abort.abort() + await Promise.allSettled(operations.splice(0)) + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + vi.restoreAllMocks() + vi.useRealTimers() +}) + +async function fixture() { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const session = ctx.sessions.create(SessionId('state-target'), { meta: { cwd: '/state-test' } }) + let agent: Agent | undefined + const get = vi.fn(() => agent) + const agents: Partial = { get } + ctx.provide('agents', agents as AgentRegistry) + const pending: PendingRemoteEventInfo[] = [] + const pendingRead = vi.fn(() => pending) + const gateway: Partial = { getPendingRemoteEvents: pendingRead } + ctx.provide('typertGateway', gateway as TypertGatewayService) + const history = new HistoryController(ctx) + const output = vi.spyOn(history, 'assistantOutput').mockResolvedValue({ records: [], truncated: false }) + const controller = new StateController(ctx, history) + const abort = cancellation() + const request = { sessionId: session.id } + const live = (status: Agent['status'] = 'running', idle = Promise.resolve(undefined)) => { + const value = { + id: session.id, session, status, + inbox: createInboxStub(), + whenIdle: vi.fn(() => idle), + } + const partial: Partial = value + agent = partial as Agent + return value + } + const addPending = (event: string, id = session.id) => { + pending.push({ event, agentId: brandString(id) }) + } + return { + ctx, session, controller, history, output, abort, request, live, get, pending, pendingRead, addPending, + setAgent: (value: Agent | undefined) => { agent = value }, + } +} +const emptyState: SessionState = { cursor: -1, active: false, running: false, queue: null, pendingInput: [] } + +describe('StateController', () => { + it('re-reads a state whose cold read turned live', async () => { + const f = await fixture() + const agent = f.live('running') + // The first lookup misses; the Session is a live source by the time the cold read runs. + let lookups = 0 + f.get.mockImplementation(() => (lookups++ === 0 ? undefined : agent as never)) + const state = await f.controller.state(f.request, new AbortController().signal) + expect(state).toMatchObject({ active: true, running: true, queue: [] }) + expect(state.cursor).toBe(f.session.seq - 1) + expect(lookups).toBeGreaterThan(1) + }) + + it('rejects a live state read whose Session records no cwd', async () => { + const f = await fixture() + const session = f.ctx.sessions.create(SessionId('no-cwd-live')) + const partial: Partial = { + id: session.id, session, status: 'idle', inbox: createInboxStub(), whenIdle: vi.fn(() => Promise.resolve(undefined)), + } + f.setAgent(partial as Agent) + await expect(f.controller.state({ sessionId: session.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects a cold state read whose stored header records no cwd', async () => { + const f = await fixture() + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: f.session.id, createdAt: 1, isSeeded: false, + } + const observation: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => observation, + [Symbol.dispose]: () => {}, + } + vi.spyOn(f.history, 'usingObservation').mockImplementation(async (_request, _signal, callback) => + callback?.(observation)) + await expect(f.controller.state(f.request, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('reads cold state without projections and reports classified, deduplicated pending input', async () => { + const f = await fixture() + const read = vi.spyOn(f.history, 'usingObservation') + f.addPending('approval/request') + f.addPending('user-questions/request') + f.addPending('fixture/request') + f.addPending('approval/request') + f.addPending('ignored', SessionId('other')) + expect(await f.controller.state(f.request, f.abort.signal)).toEqual({ ...emptyState, pendingInput: ['approval', 'question', 'other'] }) + expect(read).toHaveBeenCalledWith({ sessionId: f.session.id, projection: false }, f.abort.signal, expect.any(Function)) + }) + + it('preserves live queues, RPC identities and steering versus context placement', async () => { + const f = await fixture() + const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + const steering = createUserMessage({ content: [{ type: 'text', text: 'steer' }], source: { kind: 'user', rpcId: 'rpc' as SessionRequestId } }) + const context = createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'user-approval' } }) + const agent = f.live() + agent.inbox.append('next-turn', queued) + agent.inbox.append('next-step', steering) + agent.inbox.append('next-step', context) + expect(await f.controller.state(f.request, f.abort.signal)).toEqual({ + cursor: -1, active: true, running: true, pendingInput: [], + queue: [ + { id: queued.id, placement: 'queued', content: queued.content }, + { id: steering.id, placement: 'steering', content: steering.content, rpcId: 'rpc' }, + { id: context.id, placement: 'context', content: context.content }, + ], + }) + }) + + it('rejects already cancelled state reads', async () => { + const f = await fixture() + f.abort.abort(new Error('cancelled')) + await expect(f.controller.state(f.request, f.abort.signal)).rejects.toThrow('cancelled') + }) + + it('observes complete replacements and closes a pending read on cancellation', async () => { + const f = await fixture() + const states = f.controller.observeState(f.request, f.abort.signal)[Symbol.asyncIterator]() + expect(await states.next()).toMatchObject({ value: emptyState }) + f.live() + const next = tracked(states.next()) + f.session.append('turn/start', { turn: 1 }) + expect(await next).toMatchObject({ value: { active: true, running: true, cursor: 0 } }) + const end = tracked(states.next()) + f.abort.abort() + expect(await end).toEqual({ done: true, value: undefined }) + await states.return?.() + }) + + it('returns inactive idle state and delegates the exact output window with caller signal', async () => { + const f = await fixture() + const result = await f.controller.wait({ ...f.request, afterSeq: 3, maxMessages: 2 }, f.abort.signal) + expect(result).toEqual({ reason: 'idle', state: emptyState, output: { records: [], truncated: false } }) + expect(f.output).toHaveBeenCalledWith(f.session.id, 3, -1, 2, f.abort.signal) + }) + + it('stops immediately for pending input even while the Agent runs', async () => { + const f = await fixture() + f.live() + f.addPending('approval/request') + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'input-required' }) + }) + + it('confirms an active idle Agent with whenIdle', async () => { + const f = await fixture() + const agent = f.live('idle') + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: { active: true } }) + expect(agent.whenIdle).toHaveBeenCalledOnce() + }) + + it('polls pending input after one second without Gateway notifications', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + expect(f.pendingRead).toHaveBeenCalledTimes(1) + f.addPending('user-questions/request') + await vi.advanceTimersByTimeAsync(999) + expect(f.pendingRead).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(await result).toMatchObject({ reason: 'input-required', state: { pendingInput: ['question'] } }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('uses the latest accepted state on timeout and still reads output with the caller signal', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 1500 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(1500) + expect(await result).toMatchObject({ reason: 'timeout', state: { running: true } }) + expect(f.output).toHaveBeenCalledWith(f.session.id, -1, -1, 50, f.abort.signal) + expect(f.abort.signal.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('responds to a standard event before the polling tick and races idle against new state', async () => { + const f = await fixture() + vi.useFakeTimers() + const idle = deferred() + f.live('idle', idle.promise) + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + f.addPending('approval/request') + f.session.append('turn/start', { turn: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(await result).toMatchObject({ reason: 'input-required', state: { cursor: 0 } }) + idle.resolve(undefined) + expect(vi.getTimerCount()).toBe(0) + }) + + it('propagates cancellation while waiting and cleans periodic reads', async () => { + const f = await fixture() + vi.useFakeTimers() + f.live() + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + await vi.advanceTimersByTimeAsync(0) + f.abort.abort(new Error('caller cancelled')) + await expect(result).rejects.toThrow('caller cancelled') + expect(f.output).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('propagates an idle-driver failure without treating it as a deadline', async () => { + const f = await fixture() + const idle = deferred() + const agent = f.live('idle', idle.promise) + agent.whenIdle.mockImplementation(() => Promise.reject(new Error('driver failed'))) + await expect(f.controller.wait(f.request, f.abort.signal)).rejects.toThrow('driver failed') + idle.resolve(undefined) + }) + + it('propagates a structured read failure raised before the deadline', async () => { + const f = await fixture() + const failure = deferred() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + await failure.promise + throw new RemoteError('gateway/internal', 'persistent read failed', {}) + }) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 5_000 }, f.abort.signal)) + failure.resolve(undefined) + await expect(result).rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('keeps a structured read failure raised after the deadline', async () => { + const f = await fixture() + vi.useFakeTimers() + let rejectRead!: (error: Error) => void + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + await new Promise((_resolve, reject) => { rejectRead = reject }) + yield* [] + }) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + rejectRead(new RemoteError('gateway/internal', 'persistent read failed', {})) + await expect(result).rejects.toMatchObject({ code: 'gateway/internal' }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('reports timeout when its first cold read never yields a state', async () => { + const f = await fixture() + vi.useFakeTimers() + vi.spyOn(f.ctx.sessionQuery, 'observeSession').mockImplementation((_id, options) => new Promise((_resolve, reject) => { + const abort = options?.signal + abort?.addEventListener('abort', () => { + const reason: unknown = abort.reason + reject(Object.assign(new Error('session observation was aborted'), { + code: 'SESSION_QUERY_ABORTED', cause: reason, + })) + }, { once: true }) + })) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + await expect(result).rejects.toMatchObject({ code: 'session/wait-timeout' }) + expect(f.output).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects a state stream that ends before a stopping observation', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () {}) + await expect(f.controller.wait(f.request, f.abort.signal)).rejects.toMatchObject({ code: 'session/observation-ended' }) + }) + + it('settles an already-armed failed read when the preceding observation stops the wait', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: emptyState, revision: 0 } + throw new Error('next read failed') + }) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle' }) + }) + + it('ignores a yielded state invalidated before wait accepts it', async () => { + const f = await fixture() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, running: true }, revision: 0 } + yield { state: emptyState, revision: 1 } + }) + vi.spyOn(StateListener.prototype, 'isCurrent').mockReturnValueOnce(false).mockReturnValue(true) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: emptyState }) + }) + + it('times out with the last accepted state, not a later invalidated sample', async () => { + const f = await fixture() + vi.useFakeTimers() + const accepted = { ...emptyState, running: true } + const stale = { ...emptyState, running: true, cursor: 7 } + const never = deferred() + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: accepted, revision: 0 } + yield { state: stale, revision: 0 } + await never.promise + yield* [] + }) + vi.spyOn(StateListener.prototype, 'isCurrent').mockReturnValueOnce(true).mockReturnValue(false) + const result = tracked(f.controller.wait({ ...f.request, timeoutMs: 20 }, f.abort.signal)) + await vi.advanceTimersByTimeAsync(20) + never.resolve(undefined) + await expect(result).resolves.toMatchObject({ reason: 'timeout', state: { cursor: -1 } }) + }) + + it.each(['missing-agent', 'different-session'] as const)('retries active state when its owner is %s', async (condition) => { + const f = await fixture() + const agent = f.live('idle') + if (condition === 'missing-agent') f.setAgent(undefined) + else Object.assign(agent, { session: f.ctx.sessions.create(SessionId('replacement')) }) + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, active: true, queue: [] }, revision: 0 } + yield { state: emptyState, revision: 0 } + }) + expect(await f.controller.wait(f.request, f.abort.signal)).toMatchObject({ reason: 'idle', state: emptyState }) + expect(agent.whenIdle).not.toHaveBeenCalled() + }) + + it.each(['revision', 'agent', 'session'] as const)('rechecks %s after the idle promise settles', async (changed) => { + const f = await fixture() + const idle = deferred() + const entered = deferred() + const next = deferred() + const agent = f.live('idle', idle.promise) + agent.whenIdle.mockImplementation(() => { entered.resolve(undefined); return idle.promise }) + vi.spyOn(StateListener.prototype, 'observeStates').mockImplementation(async function* () { + yield { state: { ...emptyState, active: true, queue: [] }, revision: 0 } + await next.promise + yield { state: emptyState, revision: 0 } + }) + const rechecked = deferred() + let checks = 0 + vi.spyOn(StateListener.prototype, 'isCurrent').mockImplementation(() => { + checks++ + if (checks === 2) rechecked.resolve(undefined) + return !(changed === 'revision' && checks === 2) + }) + const result = tracked(f.controller.wait(f.request, f.abort.signal)) + try { + await entered.promise + if (changed === 'agent') f.setAgent(undefined) + if (changed === 'session') Object.assign(agent, { session: f.ctx.sessions.create(SessionId('new-session')) }) + idle.resolve(undefined) + await rechecked.promise + next.resolve(undefined) + expect(await result).toMatchObject({ reason: 'idle', state: emptyState }) + } finally { + idle.resolve(undefined) + next.resolve(undefined) + f.abort.abort() + await Promise.allSettled([result]) + } + }) + + it.each([ + { afterSeq: -2 }, { afterSeq: -0 }, { afterSeq: 1.5 }, + { maxMessages: 0 }, { maxMessages: 1.5 }, + { timeoutMs: 0 }, { timeoutMs: 2147483648 }, { timeoutMs: 1.5 }, + ])('rejects unsupported wait bounds %j', async (bounds) => { + const f = await fixture() + await expect(f.controller.wait({ ...f.request, ...bounds }, f.abort.signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + }) +}) + +describe('StateListener', () => { + it('discards an async stale read and coalesces repeated invalidations', async () => { + const f = await fixture() + const first = deferred() + const reading = deferred() + const read = vi.fn() + .mockImplementationOnce(() => { reading.resolve(undefined); return first.promise }) + .mockResolvedValue({ ...emptyState, cursor: 1 }) + using listener = new StateListener(f.ctx, f.session.id, f.abort.signal, read) + const states = listener.observeStates() + const pending = tracked(states.next()) + await reading.promise + f.session.append('turn/start', { turn: 1 }) + f.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + first.resolve(emptyState) + expect(await pending).toEqual({ done: false, value: { state: { ...emptyState, cursor: 1 }, revision: 2 } }) + expect(read).toHaveBeenCalledTimes(2) + expect(listener.isCurrent(0)).toBe(false) + expect(listener.isCurrent(2)).toBe(true) + await states.return(undefined) + expect(listener.isCurrent(2)).toBe(false) + }) + + it('ignores other Sessions and ends when its Context is disposed', async () => { + const f = await fixture() + const read = vi.fn(async () => emptyState) + const listener = new StateListener(f.ctx, f.session.id, f.abort.signal, read) + const states = listener.observeStates() + await states.next() + const pending = tracked(states.next()) + f.ctx.sessions.create(SessionId('unrelated')) + expect(read).toHaveBeenCalledOnce() + await f.ctx.fiber.dispose() + expect(await pending).toEqual({ done: true, value: undefined }) + listener[Symbol.dispose]() + }) + + it('invalidates for each Agent lifecycle notification', async () => { + const f = await fixture() + f.live('idle') + const agent = f.get()! + using listener = new StateListener(f.ctx, f.session.id, f.abort.signal, async () => emptyState) + const states = listener.observeStates() + await states.next() + for (const emit of [ + () => { f.ctx.emit('agent/status', { agent, status: 'running' }) }, + () => { f.ctx.emit('agent/created', { agent, source: 'startup' }) }, + () => { f.ctx.emit('agent/disposed', { agent }) }, + ]) { + const pending = tracked(states.next()) + emit() + expect(await pending).toMatchObject({ done: false, value: { state: emptyState } }) + } + await states.return(undefined) + }) + + it('rejects cancelled construction and contains setup failure by removing abort listeners', async () => { + const f = await fixture() + f.abort.abort() + expect(() => new StateListener(f.ctx, f.session.id, f.abort.signal, async () => emptyState)).toThrow() + const abort = cancellation() + const remove = vi.spyOn(abort.signal, 'removeEventListener') + vi.spyOn(f.ctx, 'on').mockImplementation(() => { throw new Error('registration failed') }) + expect(() => new StateListener(f.ctx, f.session.id, abort.signal, async () => emptyState)).toThrow('registration failed') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) +}) diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index 9534b8b66d..36216a1f1c 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -105,7 +105,7 @@ describe('SessionController subagent catalog', () => { kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] }, }) expect(stat).toHaveBeenCalledWith(PARENT, { signal }) - expect(observe).toHaveBeenCalledWith(PARENT, { signal }) + expect(observe).toHaveBeenCalledWith(PARENT, { signal, projectionMode: 'all' }) expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal }) expect(resume).not.toHaveBeenCalled() expect(ctx.sessions.get(PARENT)).toBeUndefined() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 114e15734d..145b48f5c3 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -51,7 +51,7 @@ import type { SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionWorkspacePathApplication, - SessionPage, + SessionEventsPage, SessionPageRequest, SessionPromptRequest, SessionPromptValue, @@ -66,7 +66,7 @@ import type { SessionUpdateQueueValue, } from '../src/types.ts' -/** Direct test face matching the generated `ctx.remote.session` unary methods. */ +/** Direct test face for the generated `ctx.remote.session` unary methods these tests exercise. */ export interface TestSessionRemote { workspacePathApplications( request: { readonly path: string }, signal?: AbortSignal, @@ -87,7 +87,7 @@ export interface TestSessionRemote { request: SessionOpenWorkspacePathRequest, signal?: AbortSignal, ): Promise> - page(request: SessionPageRequest, signal?: AbortSignal): Promise> + page(request: SessionPageRequest, signal?: AbortSignal): Promise> projections(request: SessionProjectionsRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable @@ -388,6 +388,7 @@ export async function createSessionTestRemote( signal, ), page: (request, signal = new AbortController().signal) => remoteResult( + // oxlint-disable-next-line typescript/no-deprecated -- Test face keeps the legacy page entry covered. () => direct.page(request, signal), signal, ), diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 87d8fd7479..2c60701f80 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -25,7 +25,7 @@ import type { SessionFollowFrame, SessionFollowRequest, SessionHistoryRecord, - SessionPage, + SessionEventsPage, SessionPageRequest, SessionWireEvent, } from '../src/types.ts' @@ -44,8 +44,8 @@ function entry(seq: number): SessionEventEntry { return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } } } -function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage { - return { records, hasMore } +function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionEventsPage { + return { cursor: records.at(-1)?.event?.seq ?? -1, records, hasMore } } function snapshot( @@ -102,7 +102,7 @@ class ScriptedSessionRemote implements SessionTransportRemote { constructor( private readonly generations: FollowGeneration[], - private readonly pages: RemoteResult[], + private readonly pages: RemoteResult[], private readonly controlFrames: readonly SessionControlFrame[] = [], private readonly holdControl = true, ) {} @@ -126,7 +126,7 @@ class ScriptedSessionRemote implements SessionTransportRemote { } } - page(request: SessionPageRequest): Promise> { + page(request: SessionPageRequest): Promise> { this.pageRequests.push(request) const result = this.pages.shift() if (result === undefined) throw new Error('no scripted Session page') @@ -326,7 +326,7 @@ describe('Session Client stream adapters', () => { await stream.open({}) await vi.waitFor(() => { expect(changes).toHaveLength(2) }) - expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, activate: true, assistantStream: true }]) expect(changes).toMatchObject([ { type: 'replace', page: { assistantStream: baseline } }, { type: 'assistant-stream', frame }, @@ -382,7 +382,7 @@ describe('Session Client stream adapters', () => { code: 'gateway/internal', message: 'session event stream emitted an entry before its opening cursor', }) - expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }]) + expect(remote.followRequests).toEqual([{ address: ADDRESS, activate: true, assistantStream: true }]) } finally { await stream.dispose() } @@ -547,7 +547,7 @@ describe('Session Client stream adapters', () => { await stream.prepend({ beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) expect(remote.followRequests).toEqual([{ - address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, + address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }]) expect(remote.pageRequests).toEqual([ { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, @@ -585,8 +585,8 @@ describe('Session Client stream adapters', () => { await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, - { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(remote.pageRequests).toEqual([]) expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) @@ -616,8 +616,8 @@ describe('Session Client stream adapters', () => { finish.resolve(undefined) await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true }, - { address: ADDRESS, assistantStream: true }, + { address: ADDRESS, activate: true, assistantStream: true }, + { address: ADDRESS, activate: true, assistantStream: true }, ]) expect(remote.pageRequests).toEqual([]) await stream.dispose() diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index e48b114c91..8a3df2ae43 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -5,8 +5,9 @@ import type { Session, SessionEvent, SessionHeader, SurfaceIntent } from '@deeps import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' -import { describe, expect, it, vi } from 'vitest' -import { SessionHistoryController } from '../src/history.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryController } from '../src/history/controller.ts' +import { WebController } from '../src/web/controller.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const signal = (): AbortSignal => new AbortController().signal @@ -72,22 +73,39 @@ function deferred(): Deferred { return { promise, resolve } } -async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> { +const roots = new Set() +afterEach(async () => { + await Promise.all([...roots].map(ctx => ctx.fiber.dispose())) + roots.clear() +}) + +function controllers( + ctx: Context, promote: (observation: SessionObservation) => void = (observation) => { observation[Symbol.dispose]() }, +): { + history: HistoryController + web: WebController +} { + roots.add(ctx.root) + installSessionReadTestServices(ctx) + const history = new HistoryController(ctx) + return { history, web: new WebController(ctx, history, promote) } +} + +async function setup(): Promise<{ ctx: Context; history: HistoryController; web: WebController }> { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) - const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) - return { ctx, transport } + return { ctx, ...controllers(ctx) } } -describe('SessionHistoryController', () => { +describe('Session history and Web follow', () => { it('opens at the current cursor and follows later events from an ordinary Session', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const abort = new AbortController() - const iterator = transport.follow( + const iterator = web.follow( { address: { kind: 'session', sessionId: session.id } }, abort.signal, )[Symbol.asyncIterator]() @@ -99,7 +117,7 @@ describe('SessionHistoryController', () => { value: { type: 'event', event: { type: 'turn/end', seq: 1 } }, }) - const page = await transport.page( + const page = await history.events( { address: { kind: 'session', sessionId: session.id }, throughSeq: 1 }, new AbortController().signal, ) @@ -113,16 +131,16 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - let transport!: SessionHistoryController + let web!: WebController const owner = ctx.plugin(Object.assign( (inner: Context) => { - transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + web = controllers(inner).web }, { inject: ['sessions', 'sessionQuery'] }, )) await owner.await() const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } }) - const iterator = transport.follow( + const iterator = web.follow( { address: { kind: 'session', sessionId: session.id } }, new AbortController().signal, )[Symbol.asyncIterator]() @@ -138,13 +156,13 @@ describe('SessionHistoryController', () => { }) it('reconnects with a complete replacement snapshot before later live events', async () => { - const { ctx, transport } = await setup() + const { ctx, web } = await setup() const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2 }) const abort = new AbortController() - const iterator = transport.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() @@ -168,7 +186,7 @@ describe('SessionHistoryController', () => { }) it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => { - const { ctx, transport } = await setup() + const { ctx, web } = await setup() const sessionId = SessionId('cold-race') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, @@ -187,7 +205,7 @@ describe('SessionHistoryController', () => { inspect: () => inspected.promise, }) as never) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() @@ -220,9 +238,9 @@ describe('SessionHistoryController', () => { } const observed = deferred() ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never) - const transport = new SessionHistoryController(ctx, vi.fn()) + const { web } = controllers(ctx, vi.fn()) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() const opening = iterator.next() @@ -256,11 +274,11 @@ describe('SessionHistoryController', () => { const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - let transport!: SessionHistoryController + let web!: WebController let agentCtx!: Context await ctx.plugin(Object.assign( (inner: Context) => { - transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + web = controllers(inner).web }, { inject: ['sessions', 'sessionQuery'] }, )) @@ -279,7 +297,7 @@ describe('SessionHistoryController', () => { const seed = [event('fixture/start', SessionSeq(0))] cold(ctx, header, seed) const abort = new AbortController() - const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -322,7 +340,7 @@ describe('SessionHistoryController', () => { isSeeded: false, } cold(replay.ctx, replayHeader, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))]) - const replayed = replay.transport.follow({ + const replayed = replay.web.follow({ address: { kind: 'session', sessionId: replayId }, }, signal())[Symbol.asyncIterator]() await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) @@ -331,7 +349,7 @@ describe('SessionHistoryController', () => { const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } }) append(session, 'fixture/start', {}) live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never) - const followed = live.transport.follow({ + const followed = live.web.follow({ address: { kind: 'session', sessionId: session.id }, }, signal())[Symbol.asyncIterator]() await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -345,21 +363,21 @@ describe('SessionHistoryController', () => { }) it('opens an empty source at cursor -1', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } }) const abort = new AbortController() - const iterator = transport.follow({ + const iterator = web.follow({ address: { kind: 'session', sessionId: session.id }, }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: -1, }, signal())).resolves.toMatchObject({ records: [], hasMore: false }) abort.abort() await expect(iterator.next()).resolves.toMatchObject({ done: true }) }) - it('publishes an empty projection baseline when the query has no registry', async () => { + it('fails explicitly when a projected observation omits its baseline', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const sessionId = SessionId('projectionless-follow') @@ -380,19 +398,83 @@ describe('SessionHistoryController', () => { retain: vi.fn(), [Symbol.dispose]: vi.fn(), } satisfies SessionObservation), } as never) - const history = new SessionHistoryController(ctx, vi.fn()) + const { web } = controllers(ctx, vi.fn()) const abort = new AbortController() - const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal) + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ - value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } }, - }) + await expect(iterator.next()).rejects.toMatchObject({ code: 'session/projections-unavailable' }) abort.abort() await expect(iterator.next()).resolves.toMatchObject({ done: true }) await ctx.fiber.dispose() }) + it('does not activate a prepared Session when the request omits activate', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('passive-activation-default') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, + } + const disposeSource = vi.fn() + const source: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: () => source, + [Symbol.dispose]: disposeSource, + } + ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source) } as never) + const promote = vi.fn() + const { web } = controllers(ctx, promote) + const abort = new AbortController() + const iterator = web.follow({ address: { kind: 'session', sessionId } }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + expect(promote).not.toHaveBeenCalled() + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + expect(disposeSource).toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('starts activation for a consumer that stops after the opening snapshot', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('activation-after-opening') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false, + } + const disposed: string[] = [] + const promotion: SessionObservation = { + source: 'prepared', + header, + inheritedEventCount: SessionLogOffset(0), + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: () => { disposed.push('promotion') }, + } + const source: SessionObservation = { + ...promotion, + retain: () => promotion, + [Symbol.dispose]: () => { disposed.push('source') }, + } + ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source) } as never) + const promote = vi.fn((observation: SessionObservation) => { observation[Symbol.dispose]() }) + const { web } = controllers(ctx, promote) + const iterator = web.follow({ address: { kind: 'session', sessionId }, activate: true }, signal()) + [Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + await iterator.return?.(undefined) + expect(promote).toHaveBeenCalledOnce() + expect(disposed).toContain('promotion') + await ctx.fiber.dispose() + }) + it('disposes a retained promotion when background activation rejects synchronously', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -412,18 +494,17 @@ describe('SessionHistoryController', () => { ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(source), } as never) - const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') }) - const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal()) + const { web } = controllers(ctx, () => { throw new Error('activation failed') }) + const iterator = web.follow({ address: { kind: 'session', sessionId }, activate: true }, signal()) [Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) await expect(iterator.next()).rejects.toThrow('activation failed') expect(disposePromotion).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) it('requires the durable parent and mode for a direct subagent address', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const parentSessionId = SessionId('parent') const childSessionId = SessionId('child') ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } }) @@ -437,7 +518,7 @@ describe('SessionHistoryController', () => { })) const signal = new AbortController().signal - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -448,17 +529,17 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, throughSeq: 0, }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), @@ -467,7 +548,7 @@ describe('SessionHistoryController', () => { }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -476,14 +557,14 @@ describe('SessionHistoryController', () => { }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId: childSessionId }, throughSeq: 0, }, signal)).rejects.toMatchObject({ code: 'session/agent-busy' }) }) it('preserves a cold inspection failure for the Gateway error branch', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const sessionId = SessionId('corrupt-cold') const failure = new Error('cold log is corrupt') const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, isSeeded: false, cwd: '/workspace' } @@ -492,7 +573,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.reject(failure), }) as never) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'session', sessionId }, throughSeq: -1, }, new AbortController().signal)).rejects.toMatchObject({ @@ -502,7 +583,7 @@ describe('SessionHistoryController', () => { }) it('rejects malformed page and follow cursors at the service boundary', async () => { - const { ctx, transport } = await setup() + const { ctx, history, web } = await setup() const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } }) const address = { kind: 'session' as const, sessionId: session.id } for (const request of [ @@ -521,9 +602,9 @@ describe('SessionHistoryController', () => { { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 0 } }, { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 1.5 } }, ]) { - await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) + await expect(history.events(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) } - await expect(transport.page({ address, throughSeq: 0 }, signal())) + await expect(history.events({ address, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'gateway/bad-request' }) const corrupt = await setup() @@ -533,11 +614,11 @@ describe('SessionHistoryController', () => { { version: SESSION_FORMAT_VERSION, id: corruptId, createdAt: 1, cwd: '/workspace', isSeeded: false }, [event('fixture/start', SessionSeq(0)), event('fixture/gap', SessionSeq(2))], ) - await expect(corrupt.transport.page({ + await expect(corrupt.history.events({ address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) for (const maxMessages of [0, 0.5]) { - const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() + const iterator = web.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } for (const turnWindow of [ @@ -547,15 +628,15 @@ describe('SessionHistoryController', () => { { minMessages: 50, minTurns: 0 }, { minMessages: 50, minTurns: 1.5 }, ]) { - const iterator = transport.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() + const iterator = web.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } }) it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } - await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + await expect(history.events({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const inspect = vi.fn(() => Promise.resolve(undefined)) @@ -565,9 +646,9 @@ describe('SessionHistoryController', () => { stat, inspect, }) as never) - await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + await expect(history.events({ address: ordinary, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) - await expect(transport.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId: SessionId('parent'), @@ -594,7 +675,7 @@ describe('SessionHistoryController', () => { events: [], }), }) as never) - await expect(first.transport.page({ address, throughSeq: -1 }, signal())) + await expect(first.history.events({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) const second = await setup() @@ -608,7 +689,7 @@ describe('SessionHistoryController', () => { events: [], }), }) as never) - await expect(second.transport.page({ address, throughSeq: -1 }, signal())) + await expect(second.history.events({ address, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'session/not-found' }) }) @@ -623,7 +704,7 @@ describe('SessionHistoryController', () => { isSeeded: false, } cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', SessionSeq(0), { turn: 1 })]) - await expect(ordinaryBench.transport.page({ + await expect(ordinaryBench.history.events({ address: { kind: 'session', sessionId: ordinaryId }, throughSeq: 0, }, signal())).resolves.toMatchObject({ @@ -649,18 +730,18 @@ describe('SessionHistoryController', () => { } const missing = await setup() cold(missing.ctx, childHeader, []) - await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + await expect(missing.history.events({ address: childAddress, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const corrupt = await setup() cold(corrupt.ctx, childHeader, [event('subagent/descriptor', SessionSeq(0), { version: 'bad' })]) - await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) + await expect(corrupt.history.events({ address: childAddress, throughSeq: 0 }, signal())) .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const ordinaryChild = await setup() const { origin: _origin, ...ordinaryChildHeader } = childHeader cold(ordinaryChild.ctx, ordinaryChildHeader, []) - await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + await expect(ordinaryChild.history.events({ address: childAddress, throughSeq: -1 }, signal())) .rejects.toMatchObject({ code: 'subagent/unauthorized' }) }) @@ -689,9 +770,9 @@ describe('SessionHistoryController', () => { retain: vi.fn(), [Symbol.dispose]: vi.fn(), } as unknown as SessionObservation), } as never) - const history = new SessionHistoryController(ctx, vi.fn()) + const { history } = controllers(ctx, vi.fn()) - await expect(history.page({ + await expect(history.events({ address: { kind: 'subagent', parentSessionId, @@ -710,7 +791,7 @@ describe('SessionHistoryController', () => { const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot') - const ordinaryPage = await ordinary.transport.page({ + const ordinaryPage = await ordinary.history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: 0, }, signal()) @@ -727,7 +808,7 @@ describe('SessionHistoryController', () => { mode: 'continuable', provider: 'test', label: 'child', })) const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot') - const page = await child.transport.page({ + const page = await child.history.events({ address: { kind: 'subagent', parentSessionId, @@ -741,7 +822,7 @@ describe('SessionHistoryController', () => { }) it('keeps message-aligned pagination contiguous across replacement source-event references', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -754,27 +835,27 @@ describe('SessionHistoryController', () => { sourceEventSeqs: [SessionSeq(1), firstReply.seq, SessionSeq(3), SessionSeq(4), summary.seq], }) - const page = await transport.page({ + const page = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2, }, signal()) expect(page.records.map(entry => entry.event.seq)) .toEqual([3, 4, 5, replacement.seq]) expect(page.hasMore).toBe(true) - const before = await transport.page({ + const before = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1, }, signal()) expect(before.records.map(entry => entry.event.seq)).toEqual([2]) }) it('keeps cited source events in the page that owns their appended message', async () => { - const { ctx, transport } = await setup() + const { ctx, history } = await setup() const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } }) const source = append(session, 'fixture/source', {}) append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { surfaceOp: 'append', sourceEventSeqs: [source.seq], }) - const page = await transport.page({ + const page = await history.events({ address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1, }, signal()) expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1]) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 8a5fa75166..acadf82099 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -16,12 +16,24 @@ "src/control.ts", "src/file-references.ts", "src/history.ts", + "src/history/controller.ts", + "src/history/listener.ts", + "src/history/assistant-stream.ts", + "src/state/controller.ts", + "src/state/listener.ts", + "src/web/controller.ts", "src/list.ts", "src/media-references.ts", "src/archived-session-gate.ts", "src/skill-catalog.ts" ], "references": [ + { + "path": "../gateway/tsconfig.host.json" + }, + { + "path": "../../util/timeout" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-plan/src/client/failure-line.ts b/packages/client/ui-plan/src/client/failure-line.ts index f0dd9f74a7..ef288ff1f4 100644 --- a/packages/client/ui-plan/src/client/failure-line.ts +++ b/packages/client/ui-plan/src/client/failure-line.ts @@ -11,7 +11,6 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' export function planFailureLine(t: TranslateNS<'plan'>, failure: RemoteFailure): string { switch (failure.code) { case 'plan/invalid-address': return t('preview.invalidAddress') - case 'plan/unavailable': return t('preview.historyUnavailable') case 'plan/not-found': return t('preview.notFound') // Transport failures, including plan/read-failed, retain the carrier's diagnostic. default: return failure.message diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 5940519fa2..7dbf87ea84 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -12,7 +12,6 @@ export const zh = { 'preview.loading': '正在读取计划…', 'preview.failed': '无法读取计划', 'preview.invalidAddress': '计划地址无效', - 'preview.historyUnavailable': '无法读取会话历史', 'preview.notFound': '未找到这份计划', 'preview.unavailable': '计划预览不可用', 'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。', @@ -36,7 +35,6 @@ export const en = { 'preview.loading': 'Loading plan…', 'preview.failed': 'Could not load plan', 'preview.invalidAddress': 'Invalid plan address', - 'preview.historyUnavailable': 'Session history is unavailable', 'preview.notFound': 'This plan was not found', 'preview.unavailable': 'Plan preview is unavailable', 'preview.expired': 'This temporary plan preview has expired. Reopen it from the pending review card.', @@ -57,7 +55,6 @@ export const ja = { 'preview.loading': '計画を読み込み中…', 'preview.failed': '計画を読み込めませんでした', 'preview.invalidAddress': '計画のアドレスが無効です', - 'preview.historyUnavailable': 'セッション履歴を利用できません', 'preview.notFound': 'この計画は見つかりませんでした', 'preview.unavailable': '計画のプレビューを利用できません', 'preview.expired': 'この一時的な計画プレビューは期限切れです。保留中のレビューカードから再度開いてください。', @@ -78,7 +75,6 @@ export const es = { 'preview.loading': 'Cargando plan…', 'preview.failed': 'No se pudo cargar el plan', 'preview.invalidAddress': 'Dirección del plan no válida', - 'preview.historyUnavailable': 'El historial de sesiones no está disponible', 'preview.notFound': 'Este plan no fue encontrado.', 'preview.unavailable': 'La vista previa del plan no está disponible', 'preview.expired': 'Esta vista previa del plan temporal ha caducado. Vuelva a abrirlo desde la tarjeta de revisión pendiente.', @@ -99,7 +95,6 @@ export const fr = { 'preview.loading': 'Plan de chargement…', 'preview.failed': 'Impossible de charger le plan', 'preview.invalidAddress': 'Adresse du forfait invalide', - 'preview.historyUnavailable': "L'historique des sessions n'est pas disponible", 'preview.notFound': "Ce plan n'a pas été trouvé", 'preview.unavailable': "L'aperçu du plan n'est pas disponible", 'preview.expired': 'Cet aperçu du plan temporaire a expiré. Rouvrez-le à partir de la carte de révision en attente.', @@ -120,7 +115,6 @@ export const de = { 'preview.loading': 'Ladeplan…', 'preview.failed': 'Plan konnte nicht geladen werden', 'preview.invalidAddress': 'Ungültige Planadresse', - 'preview.historyUnavailable': 'Der Sitzungsverlauf ist nicht verfügbar', 'preview.notFound': 'Dieser Plan wurde nicht gefunden', 'preview.unavailable': 'Die Planvorschau ist nicht verfügbar', 'preview.expired': 'Diese temporäre Planvorschau ist abgelaufen. Öffnen Sie es erneut über die ausstehende Überprüfungskarte.', @@ -141,7 +135,6 @@ export const ru = { 'preview.loading': 'План загрузки…', 'preview.failed': 'Не удалось загрузить план', 'preview.invalidAddress': 'Неверный адрес плана', - 'preview.historyUnavailable': 'История сеансов недоступна', 'preview.notFound': 'Этот план не найден', 'preview.unavailable': 'Предварительный просмотр плана недоступен.', 'preview.expired': 'Срок действия этой временной предварительной версии плана истек. Откройте его снова из карточки ожидающего рассмотрения.', @@ -162,7 +155,6 @@ export const pt = { 'preview.loading': 'Carregando plano…', 'preview.failed': 'Não foi possível carregar o plano', 'preview.invalidAddress': 'Endereço do plano inválido', - 'preview.historyUnavailable': 'O histórico da sessão não está disponível', 'preview.notFound': 'Este plano não foi encontrado', 'preview.unavailable': 'A visualização do plano não está disponível', 'preview.expired': 'Esta visualização temporária do plano expirou. Reabra-o no cartão de revisão pendente.', diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts index 95ec5dff30..8a3a82a204 100644 --- a/packages/client/ui-plan/src/client/plan-resource.ts +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -1,6 +1,5 @@ -/** Read immutable plan arguments from a Session snapshot and earlier history pages. */ +/** Read immutable plan arguments from a Session history page and earlier pages. */ import type { Context } from '@deepseek-ai/cordis' -import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -10,8 +9,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** The resource URL does not identify a plan invocation. */ 'plan/invalid-address': Record - /** Session history ended before an opening snapshot. */ - 'plan/unavailable': Record /** The Session has no readable plan for this invocation. */ 'plan/not-found': Record /** The Session history read failed. */ @@ -28,13 +25,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** * Bind plan reads to the generated Session Remote face. - * Opening a follow reads projections and may activate a prepared Session on the Host. - * Generated Remote streams can throw carrier failures; the provider reports failed - * reads as resource failure frames and preserves Remote error codes. + * A plan is a logged tool-call argument, so the provider reads one page at the current log + * end and pages backwards from its cut. It neither activates nor subscribes. + * Remote failures are reported as resource failure frames with their original codes. * @param remote - Existing Session history API. * @returns a provider whose reads stop after finding the exact invocation. */ -export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { +export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { return { protocol: 'plan', async *open(address, { signal }): AsyncIterable> { @@ -47,14 +44,12 @@ export function planResourceProvider(remote: Pick | undefined - for await (const frame of remote.follow({ address: sessionAddress }, signal)) { - if (frame.type === 'snapshot') { snapshot = frame; break } - } + // One page fixes the cut the later pages page back from. + const first = await remote.events({ address: sessionAddress }, signal) if (aborted()) return - if (snapshot === undefined) throw new RemoteError('plan/unavailable', 'Session history ended before the plan could be read.', {}) - let page = { records: snapshot.records, hasMore: snapshot.hasMore } + if (!first.ok) { yield { ok: false, error: first.error }; return } + const throughSeq = first.value.cursor + let page = { records: first.value.records, hasMore: first.value.hasMore } while (true) { for (const entry of page.records) { const plan = submittedPlan(entry.event) @@ -65,7 +60,7 @@ export function planResourceProvider(remote: Pick { expect(screen.getByRole('status').textContent).toBe(dictionary['preview.unavailable']) for (const [code, message] of [ ['plan/invalid-address', dictionary['preview.invalidAddress']], - ['plan/unavailable', dictionary['preview.historyUnavailable']], ['plan/not-found', dictionary['preview.notFound']], ['plan/read-failed', 'connection lost'], ['session/not-found', 'connection lost'], diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index 57be9acd36..de33d17d29 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -1,20 +1,28 @@ import { describe, expect, it, vi } from 'vitest' +import type { SessionEventEntry, SessionEventsPage } from '@deepseek-ai/dsh-api-session-controller/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { planResourceProvider } from '../src/client/plan-resource.ts' const address = 'dsh-resource://plan/session/call' -const event = { type: 'tool/call', seq: 1, data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } } -const entry = { type: 'event', event } +const event: SessionEventEntry['event'] = { + type: 'tool/call', seq: 1, time: 1, + data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' }, +} +const entry: SessionEventEntry = { type: 'event', event } +const olderUserEntry: SessionEventEntry = { + type: 'event', event: { type: 'user/message', seq: 80, time: 1, data: {} }, +} -function setup(records: unknown[], hasMore = false) { - const closed = vi.fn() - const follow = vi.fn(async function* () { - try { yield { type: 'snapshot', cursor: 100, records, hasMore } } - finally { closed() } - }) - const page = vi.fn(async () => ({ ok: true, value: { records: [entry], hasMore: false } })) - const provider = planResourceProvider({ follow, page } as unknown as Parameters[0]) - return { provider, follow, page, closed } +type SessionRemote = Parameters[0] + +function setup(records: SessionEventsPage['records'], hasMore = false) { + const opening: SessionEventsPage = { cursor: 100, records, hasMore } + const events = vi.fn(async () => ({ ok: true, value: opening })) + const page = vi.fn(async () => ({ + ok: true, value: { cursor: 100, records: [entry], hasMore: false }, + })) + const provider = planResourceProvider({ events, page }) + return { provider, events, page } } async function read(provider: ReturnType, url = address, signal = new AbortController().signal) { @@ -24,82 +32,95 @@ async function read(provider: ReturnType, url = add } describe('plan history resource', () => { - it('restores the exact invocation and releases its snapshot stream', async () => { + it('restores the exact invocation from one page', async () => { const b = setup([entry]) expect(await read(b.provider)).toEqual([{ ok: true, value: { callId: 'call', markdown: '# Saved plan', title: 'Saved plan' } }]) - expect(b.closed).toHaveBeenCalledOnce() + expect(b.events).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' } }, expect.any(AbortSignal)) expect(b.page).not.toHaveBeenCalled() }) it('pages older history with the fixed opening cursor without changing the chat window', async () => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + const b = setup([olderUserEntry], true) expect((await read(b.provider))[0]).toMatchObject({ ok: true }) - expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) - expect(b.closed).toHaveBeenCalledOnce() + expect(b.events).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' } }, expect.any(AbortSignal)) + expect(b.page).toHaveBeenLastCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) }) it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) - const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` + const b = setup([olderUserEntry], true) + const url = `dsh-resource://plan/subagent/parent/child/${mode}/call` const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } - expect(await read(b.provider, address)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }]) - expect(b.follow).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal)) - expect(b.page).toHaveBeenCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) - expect(b.closed).toHaveBeenCalledOnce() + expect(await read(b.provider, url)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }]) + expect(b.events).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal)) + expect(b.page).toHaveBeenLastCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) }) it('reports missing plans and invalid saved addresses', async () => { const b = setup([]) expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }]) expect(await read(b.provider, 'dsh-resource://plan/s/%')).toMatchObject([{ ok: false, error: { code: 'plan/invalid-address' } }]) - expect(b.follow).toHaveBeenCalledOnce() + expect(b.events).toHaveBeenCalledOnce() }) it('does not publish a response after cancellation', async () => { const b = setup([entry]) const controller = new AbortController() controller.abort() expect(await read(b.provider, address, controller.signal)).toEqual([]) - expect(b.follow).not.toHaveBeenCalled() + expect(b.events).not.toHaveBeenCalled() }) - it('reports stream failures and closes an empty stream', async () => { + it('reports an opening events failure with its Remote code', async () => { const b = setup([]) - b.follow.mockImplementationOnce(async function* () { throw new Error('connection lost') }) - expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }]) - b.follow.mockImplementationOnce(async function* () {}) - expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/unavailable' } }]) + const error = new RemoteError('plan/read-failed', 'connection lost', {}) + b.events.mockResolvedValueOnce({ ok: false, error }) + expect(await read(b.provider)).toEqual([{ ok: false, error }]) }) - it('preserves Remote failures from paging and stream reads', async () => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) - const error = new RemoteError('plan/unavailable', 'history unavailable', {}) - b.page.mockResolvedValueOnce({ ok: false, error } as never) + it('preserves Remote failures from paging', async () => { + const b = setup([olderUserEntry], true) + const error = new RemoteError('plan/read-failed', 'history unavailable', {}) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockResolvedValueOnce({ ok: false, error }) expect(await read(b.provider)).toEqual([{ ok: false, error }]) - b.follow.mockImplementationOnce(async function* () { throw error }) - expect(await read(b.provider)).toEqual([{ ok: false, error }]) - b.follow.mockImplementationOnce(async function* () { throw 'disconnected' }) + }) + it('reports a thrown page failure as plan/read-failed', async () => { + const b = setup([olderUserEntry], true) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockRejectedValueOnce(new Error('connection lost')) + expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }]) + }) + it('stringifies a non-Error page failure', async () => { + const b = setup([olderUserEntry], true) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockRejectedValueOnce('disconnected') expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'disconnected' } }]) }) - it('waits for a snapshot and discards it when cancellation occurs during stream closure', async () => { + it('preserves a Remote failure thrown by page', async () => { + const b = setup([olderUserEntry], true) + const error = new RemoteError('plan/read-failed', 'history unavailable', {}) + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) + b.page.mockRejectedValueOnce(error) + expect(await read(b.provider)).toEqual([{ ok: false, error }]) + }) + it('discards an opening page that settles after cancellation', async () => { const b = setup([entry]) const controller = new AbortController() - b.follow.mockImplementationOnce(async function* () { - try { - yield entry as never - yield { type: 'snapshot', cursor: 100, records: [entry], hasMore: false } - } finally { b.closed(); controller.abort() } - }) - expect(await read(b.provider, address, controller.signal)).toEqual([]) - expect(b.closed).toHaveBeenCalledOnce() - expect(b.page).not.toHaveBeenCalled() + const started = Promise.withResolvers() + const pending = Promise.withResolvers>>() + b.events.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) + const result = read(b.provider, address, controller.signal) + await started.promise + controller.abort() + pending.resolve({ ok: true, value: { cursor: 100, records: [entry], hasMore: false } }) + expect(await result).toEqual([]) }) - it.each(['resolve', 'reject'] as const)('discards a page that settles after cancellation: %s', async (settlement) => { - const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + it.each(['resolve', 'reject'] as const)('discards an older page that settles after cancellation: %s', async (settlement) => { + const b = setup([olderUserEntry], true) const controller = new AbortController() const started = Promise.withResolvers() const pending = Promise.withResolvers>>() + b.page.mockResolvedValueOnce({ ok: true, value: { cursor: 100, records: [olderUserEntry], hasMore: true } }) b.page.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) const result = read(b.provider, address, controller.signal) await started.promise controller.abort() - if (settlement === 'resolve') pending.resolve({ ok: true, value: { records: [entry], hasMore: false } }) + if (settlement === 'resolve') pending.resolve({ ok: true, value: { cursor: 100, records: [entry], hasMore: false } }) else pending.reject(new Error('cancelled')) expect(await result).toEqual([]) - expect(b.closed).toHaveBeenCalledOnce() }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 68749fac82..15eac764b2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2361,10 +2361,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'acknowledgement that cancellation was requested.', }, { - signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', + signature: '@Remote events(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', - parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], - returns: 'one chronological page.', + parameters: [{ name: 'request', description: 'durable address, optional fixed cut, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], + returns: 'one chronological page with its inclusive cursor.', + }, + { + signature: '@Remote({ mode: \'stream\' }) observeEvent(request: SessionObserveEventRequest, signal: AbortSignal): AsyncIterable', + description: 'Subscribe to Session events without reading a snapshot.', + parameters: [{ name: 'request', description: 'Session address.' }, { name: 'signal', description: 'subscription cancellation.' }], + returns: 'readiness and subsequent events, without a snapshot.', + }, + { + signature: '@Remote({ mode: \'stream\' }) observeProjections(request: SessionProjectionsRequest, signal: AbortSignal): AsyncIterable', + description: 'Subscribe to sparse Session projection changes.', + parameters: [{ name: 'request', description: 'target Session.' }, { name: 'signal', description: 'subscription cancellation.' }], + returns: 'readiness and sparse projection changes.', + }, + { + signature: '@Remote state(request: SessionStateRequest, signal: AbortSignal): Promise', + description: 'Read passive Session state.', + parameters: [{ name: 'request', description: 'target Session.' }, { name: 'signal', description: 'read cancellation.' }], + returns: 'current passive state.', + }, + { + signature: '@Remote({ mode: \'stream\' }) observeState(request: SessionStateRequest, signal: AbortSignal): AsyncIterable', + description: 'Subscribe to complete Session state replacements.', + parameters: [{ name: 'request', description: 'target Session.' }, { name: 'signal', description: 'subscription cancellation.' }], + returns: 'complete passive state replacements.', + }, + { + signature: '@Remote wait(request: SessionWaitRequest, signal: AbortSignal): Promise', + description: 'Wait for Session quiescence or pending input with bounded assistant output.', + parameters: [{ name: 'request', description: 'Session, wait bounds and output window.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'stopping state and bounded assistant output.', }, { signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', @@ -2373,7 +2403,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a complete opening snapshot followed by gap-free durable event frames and optional cursorless assistant-stream frames.', }, { - signature: '@Remote(\'projections\') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise', + signature: '@Remote(\'projections\') projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise', description: 'Read exact projections, returning cached hints only when migration is required.', parameters: [{ name: 'request', description: 'Session whose current values are required.' }, { name: 'signal', description: 'cancellation for the Session read.' }], returns: 'a sequenced baseline, cached hints when migration is deferred, or null when absent.', @@ -3766,6 +3796,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'whether an owner stream is open and has not been cancelled.', }, + { + signature: 'getPendingRemoteEvents(): readonly PendingRemoteEventInfo[]', + description: 'Inspect unsettled forwarded events without changing their lifetime.', + parameters: [], + returns: 'detached event names and Agent identities.', + }, { signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise', description: 'Register the sole application-selected forwarded-event source.', @@ -6688,6 +6724,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PendingModule', declaration: 'export interface PendingModule {\n url: string;\n reason: \'host-module\' | \'unmanaged-plugin\' | \'module-comparison\';\n}', }, + { + name: 'PendingRemoteEventInfo', + declaration: 'export interface PendingRemoteEventInfo {\n readonly event: string;\n readonly agentId: RemoteEventAgentId;\n}', + }, { name: 'PermissionCatalog', declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n defaultOptions: PresetOption[];\n defaultPreset: string;\n}', @@ -7108,6 +7148,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RemoteErrorDetailsMap', declaration: 'export interface RemoteErrorDetailsMap {\n \'gateway/bad-request\': {\n readonly issues?: readonly object[];\n };\n \'gateway/cancelled\': {};\n \'gateway/internal\': {};\n}', }, + { + name: 'RemoteEventAgentId', + declaration: 'export type RemoteEventAgentId = Branded<\'RemoteEventAgentId\'>;', + }, { name: 'RemoteEventHostInfo', declaration: 'export interface RemoteEventHostInfo {\n readonly home: string;\n}', @@ -7456,6 +7500,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventEntry', declaration: 'export interface SessionEventEntry {\n readonly type: \'event\';\n readonly event: SessionWireEvent;\n}', }, + { + name: 'SessionEventFrame', + declaration: 'export type SessionEventFrame = {\n readonly type: \'ready\';\n} | SessionEventEntry;', + }, { name: 'SessionEventMap', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'developer/message\': {\n turn: number;\n step: number;\n message: DeveloperMessage;\n headerSeq?: SessionSeq;\n };\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n reason?: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n /* …truncated — full shape in source */', @@ -7492,6 +7540,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, + { + name: 'SessionEventsPage', + declaration: 'export interface SessionEventsPage extends SessionPage {\n readonly cursor: number;\n}', + }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', @@ -7538,7 +7590,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionFollowRequest', - declaration: 'export interface SessionFollowRequest extends Pick {\n readonly address: SessionAddress;\n readonly assistantStream?: true;\n}', + declaration: 'export interface SessionFollowRequest extends Pick {\n readonly address: SessionAddress;\n readonly activate?: boolean;\n readonly assistantStream?: true;\n}', }, { name: 'SessionForkRequest', @@ -7652,6 +7704,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, + { + name: 'SessionObserveEventRequest', + declaration: 'export interface SessionObserveEventRequest {\n readonly address: SessionAddress;\n}', + }, { name: 'SessionOpenWorkspacePathRequest', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly application?: string;\n readonly path: string;\n}', @@ -7666,7 +7722,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionPageRequest', - declaration: 'export interface SessionPageRequest {\n readonly address: SessionAddress;\n readonly throughSeq: number;\n readonly beforeSeq?: number;\n readonly maxMessages?: number;\n readonly turnWindow?: {\n readonly minMessages: number;\n readonly minTurns: number;\n };\n}', + declaration: 'export interface SessionPageRequest {\n readonly address: SessionAddress;\n readonly throughSeq?: number;\n readonly beforeSeq?: number;\n readonly maxMessages?: number;\n readonly turnWindow?: {\n readonly minMessages: number;\n readonly minTurns: number;\n };\n}', + }, + { + name: 'SessionPendingInput', + declaration: 'export type SessionPendingInput = \'approval\' | \'question\' | \'other\';', }, { name: 'SessionPersistenceCreateOptions', @@ -7696,6 +7756,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionBaseline', declaration: 'export interface SessionProjectionBaseline {\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', }, + { + name: 'SessionProjectionChange', + declaration: 'export type SessionProjectionChange = {\n readonly type: \'projection\';\n} & Omit;', + }, + { + name: 'SessionProjectionFrame', + declaration: 'export type SessionProjectionFrame = {\n readonly type: \'ready\';\n} | SessionProjectionChange;', + }, { name: 'SessionProjectionHints', declaration: 'export interface SessionProjectionHints {\n readonly kind: \'cached\' | \'sequenced\';\n readonly asOfSeq: number;\n readonly values: Partial;\n}', @@ -7752,6 +7820,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionPromptValue', declaration: 'export interface SessionPromptValue {\n readonly accepted: true;\n}', }, + { + name: 'SessionQueuedItem', + declaration: 'export interface SessionQueuedItem {\n readonly id: MessageId;\n readonly placement: \'queued\' | \'steering\' | \'context\';\n readonly rpcId?: SessionRequestId;\n readonly content: readonly JsonValue[];\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n formatStatus?: \'current\' | \'migration-required\';\n}', @@ -7844,6 +7916,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionStartSource', declaration: 'export type SessionStartSource = \'startup\' | \'resume\' | \'clear\' | \'compact\';', }, + { + name: 'SessionState', + declaration: 'export interface SessionState {\n readonly cursor: number;\n readonly active: boolean;\n readonly running: boolean;\n readonly queue: readonly SessionQueuedItem[] | null;\n readonly pendingInput: readonly SessionPendingInput[];\n}', + }, + { + name: 'SessionStateRequest', + declaration: 'export interface SessionStateRequest {\n readonly sessionId: SessionId;\n}', + }, { name: 'SessionStorageMetadata', declaration: 'export interface SessionStorageMetadata {\n readonly meta: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n}', @@ -7924,6 +8004,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionUpdateQueueValue', declaration: 'export interface SessionUpdateQueueValue {\n readonly accepted: true;\n}', }, + { + name: 'SessionWaitOutput', + declaration: 'export interface SessionWaitOutput {\n readonly records: readonly SessionHistoryRecord[];\n readonly truncated: boolean;\n}', + }, + { + name: 'SessionWaitRequest', + declaration: 'export interface SessionWaitRequest extends SessionStateRequest {\n readonly timeoutMs?: number;\n readonly afterSeq?: number;\n readonly maxMessages?: number;\n}', + }, + { + name: 'SessionWaitValue', + declaration: 'export interface SessionWaitValue {\n readonly reason: \'idle\' | \'input-required\' | \'timeout\';\n readonly state: SessionState;\n readonly output: SessionWaitOutput;\n}', + }, { name: 'SessionWireEvent', declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: JsonValue;\n readonly surfaceOp?: JsonValue;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d4b18aeac..a499ea0058 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1574,6 +1574,9 @@ importers: '@deepseek-ai/dsh-deque': specifier: workspace:* version: link:../../util/deque + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9a0ecfcc1b..73a4123579 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -451,6 +451,16 @@ export const LINK_MAP: Readonly> = { SessionModelsRequest: 'session.md', SessionPage: 'session.md', SessionPageRequest: 'session.md', + SessionEventsPage: 'session.md', + SessionEventFrame: 'session.md', + SessionObserveEventRequest: 'session.md', + SessionProjectionFrame: 'session.md', + SessionProjectionChange: 'session.md', + SessionState: 'session.md', + SessionStateRequest: 'session.md', + SessionWaitRequest: 'session.md', + SessionWaitValue: 'session.md', + PendingRemoteEventInfo: 'typert.md', SessionPromptRequest: 'session.md', SessionPromptValue: 'session.md', SessionRenameRequest: 'session.md', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 13c3115328..bef0f27ca0 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -555,9 +555,6 @@ "e01c2c40d53d557b621628de47a0a6677e11b4f151fbefd865c97be99a7b6c48": 1, "e69e0ec4368790661e1f7cae5c2059f757c213f0ebb0bd40ec23af982bc73d20": 1 }, - "packages/client/ui-plan/tests/plan-resource.client.spec.ts": { - "fa53d5cd55d87b522c83a98968e3a2bf933b1cc1b84d2f009e79d6c702099786": 1 - }, "packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx": { "6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1 },