@deepseek-ai/dsh-api-session-controller 0.1.7-alpha.1 → 0.1.7-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
5
- README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a
6
- README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec
5
+ README.md: 728d98b90aa96829a878b071c03af3ec961c839e
6
+ README.zh.md: 9a2c54b6865302e69daa5f67ec9baa17255f8100
package/README.md CHANGED
@@ -43,12 +43,12 @@ Projection reads retain loading and failure state independently of their values.
43
43
 
44
44
  Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling.
45
45
 
46
- The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
46
+ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index releases the durable entry. A successful message retains its transient rows until the owning `step/end`, so pending tool identities remain available for dispatch. Failed or interrupted settlements retire their rows immediately; earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
47
47
 
48
- The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
48
+ The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
49
49
 
50
50
 
51
- During uninterrupted following, steering echoes stay mounted through Inbox acceptance and claim until their durable messages arrive. A replacement follow baseline withdraws earlier receipt-confirmed steering echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
51
+ During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a local Chat message is admitted, its identity remains until the Inbox watermark reaches its next-turn or next-step claim seq: Chat already renders the durable Node, while Chat and QueueDock exclude matching stale Inbox rows. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
52
52
 
53
53
  The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
54
54
 
@@ -56,7 +56,9 @@ Fork copies the exact inclusive event prefix selected by `atSeq`, including a cu
56
56
 
57
57
  A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
58
58
 
59
- `loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly.
59
+ `loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` publishes one Host-selected page directly.
60
+
61
+ The Client's initial `follow`, reconnect openings, and `loadOlder()` request at least 50 append-origin `user/message` and `assistant/message` events and cross at least two `turn/start` events, including the partial Turn at the loaded window's beginning. The Host stops at the first Turn start satisfying both minima, or at 500 counted messages or history exhaustion, whichever comes first. Steering does not add a Turn boundary. Intervening events accompany the page without counting toward the message budget. The optional `turnWindow` on page and follow requests supplies these minima under `maxMessages`. `loadThrough()` uses the same rules with a 200-message minimum per page; the 500-message cap does not limit the whole jump. Requests without `turnWindow` retain message-aligned paging.
60
62
 
61
63
  Queue edits replace pending content with non-empty text only.
62
64
 
package/README.zh.md CHANGED
@@ -43,12 +43,12 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
43
43
 
44
44
  已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。
45
45
 
46
- Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
46
+ Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布持久条目。成功消息的瞬态 row 保留到所属 `step/end`,让待派发工具的身份持续可用;失败或中断的 settlement 立即移除对应 row,同一步骤中更早的 retry 仍然保留。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
47
47
 
48
- Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
48
+ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
49
49
 
50
50
 
51
- 正常在线时,steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达。新的 follow 基线会将此前已有接收回执的 steering 回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
51
+ 正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。本地 Chat 消息入档后,其身份保留到 Inbox 水位覆盖 next-turn 或 next-step 领取序号:Chat 已显示真实 Node,而 Chat 与 QueueDock 排除匹配的旧 Inbox 行。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
52
52
 
53
53
  面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
54
54
 
@@ -56,7 +56,9 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
56
56
 
57
57
  恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
58
58
 
59
- `loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。
59
+ `loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 直接发布 Host 选取的一页结果。
60
+
61
+ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 append 方式追加的 `user/message` 和 `assistant/message` 事件,并向前跨过至少两个 `turn/start`,其中包含已加载窗口开头尚未补齐的轮次。Host 在首次同时满足两个下限的轮次开头停止;若先达到 500 条计数消息或历史已耗尽,则立即返回。Steering 不增加轮次分界。区间内的其他事件随页返回,但不计入消息预算。分页和 follow 请求可选的 `turnWindow` 在 `maxMessages` 上限内指定这两个下限。`loadThrough()` 复用相同规则,仅将每页消息数下限设为 200;500 条上限不限制整次跳转。不带 `turnWindow` 的请求保留按消息对齐的分页方式。
60
62
 
61
63
  队列编辑仅允许用非空文本替换待处理内容。
62
64
 
package/lib/client.js CHANGED
@@ -428,7 +428,7 @@ window.__ModuleLoader__.load({
428
428
  for await (const frame of this.remote.session.follow({
429
429
  address: this.address,
430
430
  assistantStream: true,
431
- ...request.maxMessages === void 0 ? {} : { maxMessages: request.maxMessages }
431
+ ...this.repairRequest(request)
432
432
  }, signal)) {
433
433
  if (frame.type === "snapshot") {
434
434
  for (const record of frame.records) assertSessionWireEvent(record.event);
@@ -476,7 +476,10 @@ window.__ModuleLoader__.load({
476
476
  }
477
477
  /** @inheritdoc */
478
478
  repairRequest(request) {
479
- return request.maxMessages === void 0 ? {} : { maxMessages: request.maxMessages };
479
+ return {
480
+ ...request.maxMessages === void 0 ? {} : { maxMessages: request.maxMessages },
481
+ ...request.turnWindow === void 0 ? {} : { turnWindow: request.turnWindow }
482
+ };
480
483
  }
481
484
  };
482
485
  //#endregion
@@ -949,6 +952,15 @@ window.__ModuleLoader__.load({
949
952
  return this.rows.get(key)?.value;
950
953
  }
951
954
  /**
955
+ * Read the accepted Host watermark without subscribing or copying a value.
956
+ * @param key - projection key.
957
+ * @returns the current sequence, or undefined for absent and cached values.
958
+ */
959
+ seqOf(key) {
960
+ const row = this.rows.get(key);
961
+ return row?.kind === "sequenced" ? row.seq : void 0;
962
+ }
963
+ /**
952
964
  * Read every current projection value as one reference-stable snapshot.
953
965
  * @returns The same frozen value map until a row changes.
954
966
  */
@@ -1366,6 +1378,7 @@ window.__ModuleLoader__.load({
1366
1378
  /** Keeps transient Assistant presentation behind one settlement-aware interface. */
1367
1379
  var ClientAssistantStream = class {
1368
1380
  activeAttempt;
1381
+ retainedAttempt;
1369
1382
  pending = /* @__PURE__ */ new Map();
1370
1383
  publishedSeqs = /* @__PURE__ */ new Set();
1371
1384
  durableCursor = -1;
@@ -1380,6 +1393,7 @@ window.__ModuleLoader__.load({
1380
1393
  this.pending.clear();
1381
1394
  this.transientInGap = 0;
1382
1395
  this.activeAttempt = void 0;
1396
+ this.retainedAttempt = void 0;
1383
1397
  const opening = baseline?.activeAttempt;
1384
1398
  if (opening !== void 0) this.activeAttempt = {
1385
1399
  attemptId: opening.attemptId,
@@ -1430,13 +1444,15 @@ window.__ModuleLoader__.load({
1430
1444
  }
1431
1445
  /**
1432
1446
  * Fold one dense transient frame and release its named durable settlement.
1447
+ * Successful messages retain their transient rows until the owning Step ends;
1448
+ * interrupted messages, failed attempts, and abandonment retire them immediately.
1433
1449
  * @param frame - next Assistant stream frame received by the follow connection.
1434
1450
  * @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible.
1435
1451
  */
1436
1452
  acceptFrame(frame) {
1437
1453
  switch (frame.type) {
1438
1454
  case "start":
1439
- if (this.activeAttempt !== void 0 || this.pending.size > 0) return { type: "rebaseline" };
1455
+ if (this.activeAttempt !== void 0 || this.retainedAttempt !== void 0 || this.pending.size > 0) return { type: "rebaseline" };
1440
1456
  this.pending.clear();
1441
1457
  this.activeAttempt = {
1442
1458
  attemptId: frame.attemptId,
@@ -1483,6 +1499,14 @@ window.__ModuleLoader__.load({
1483
1499
  const entry = this.pending.get(frame.outcome.seq);
1484
1500
  if (entry === void 0 || entry.event.type !== frame.outcome.eventType) return { type: "rebaseline" };
1485
1501
  this.pending.delete(frame.outcome.seq);
1502
+ if (entry.event.type === "assistant/message" && entry.event.data.interrupted !== true) {
1503
+ this.retainedAttempt = {
1504
+ attemptId: attempt.attemptId,
1505
+ turn: attempt.turn,
1506
+ step: attempt.step
1507
+ };
1508
+ return this.publish(entry);
1509
+ }
1486
1510
  this.publishedSeqs.add(entry.event.seq);
1487
1511
  return {
1488
1512
  type: "settlement",
@@ -1499,6 +1523,15 @@ window.__ModuleLoader__.load({
1499
1523
  }
1500
1524
  publish(entry) {
1501
1525
  this.publishedSeqs.add(entry.event.seq);
1526
+ const retained = this.retainedAttempt;
1527
+ if (retained !== void 0 && entry.event.type === "step/end" && entry.event.data.turn === retained.turn && entry.event.data.step === retained.step) {
1528
+ this.retainedAttempt = void 0;
1529
+ return {
1530
+ type: "publish",
1531
+ entry,
1532
+ retireAttemptId: retained.attemptId
1533
+ };
1534
+ }
1502
1535
  return {
1503
1536
  type: "publish",
1504
1537
  entry
@@ -1516,6 +1549,20 @@ window.__ModuleLoader__.load({
1516
1549
  asOfSeq: value.asOfSeq === -1 ? -1 : SessionSeq(value.asOfSeq)
1517
1550
  };
1518
1551
  }
1552
+ const HISTORY_PAGE_OPTIONS = {
1553
+ maxMessages: 500,
1554
+ turnWindow: {
1555
+ minMessages: 50,
1556
+ minTurns: 2
1557
+ }
1558
+ };
1559
+ const JUMP_PAGE_OPTIONS = {
1560
+ ...HISTORY_PAGE_OPTIONS,
1561
+ turnWindow: {
1562
+ ...HISTORY_PAGE_OPTIONS.turnWindow,
1563
+ minMessages: 200
1564
+ }
1565
+ };
1519
1566
  /**
1520
1567
  * Owns a session's event window, lifecycle state, and observable
1521
1568
  * snapshot. React bindings remain outside this data layer. Features see only
@@ -1816,7 +1863,7 @@ window.__ModuleLoader__.load({
1816
1863
  this.openPromise = promise;
1817
1864
  return promise;
1818
1865
  }
1819
- /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
1866
+ /** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */
1820
1867
  async loadOlder() {
1821
1868
  if (this.openState !== "open" || !this.hasMore || this.loadingOlder) return;
1822
1869
  const events = this.events;
@@ -1826,7 +1873,7 @@ window.__ModuleLoader__.load({
1826
1873
  try {
1827
1874
  await events.prepend({
1828
1875
  beforeSeq: this.baseSeq,
1829
- maxMessages: 50
1876
+ ...HISTORY_PAGE_OPTIONS
1830
1877
  });
1831
1878
  } catch (error) {
1832
1879
  if (!(0, _deepseek_ai_dsh_api_gateway_client.isRemoteFailure)(error)) console.error("[session-controller] loadOlder failed:", error);
@@ -1862,7 +1909,7 @@ window.__ModuleLoader__.load({
1862
1909
  const before = pending.beforeSeq;
1863
1910
  await events.prepend({
1864
1911
  beforeSeq: before,
1865
- maxMessages: 200
1912
+ ...JUMP_PAGE_OPTIONS
1866
1913
  });
1867
1914
  if (pending.beforeSeq >= before) return;
1868
1915
  }
@@ -1981,7 +2028,8 @@ window.__ModuleLoader__.load({
1981
2028
  */
1982
2029
  async dispose() {
1983
2030
  this.stopObservingInbox();
1984
- for (const requestId of [...this.submissionSettlements.keys()]) this.retireFailedSubmission(requestId);
2031
+ for (const [requestId, settlement] of [...this.submissionSettlements]) if (settlement.admitted !== void 0) this.scheduleObservedRetirement(requestId, settlement.admitted);
2032
+ else this.retireFailedSubmission(requestId);
1985
2033
  this.openGeneration++;
1986
2034
  const events = this.events;
1987
2035
  this.events = void 0;
@@ -2003,7 +2051,7 @@ window.__ModuleLoader__.load({
2003
2051
  });
2004
2052
  this.events = events;
2005
2053
  try {
2006
- await events.open({ maxMessages: 50 });
2054
+ await events.open(HISTORY_PAGE_OPTIONS);
2007
2055
  if (generation !== this.openGeneration || this.events !== events) return;
2008
2056
  this.openState = "open";
2009
2057
  } catch (error) {
@@ -2050,7 +2098,7 @@ window.__ModuleLoader__.load({
2050
2098
  for (const entry of visible) this.observeSubmissionEvent(entry.event);
2051
2099
  if (projections !== void 0) {
2052
2100
  const inbox = projections.values.inbox;
2053
- this.observeSteeringInsertions(inbox?.["next-step"] ?? [], 0, projections.asOfSeq);
2101
+ for (const target of ["next-turn", "next-step"]) this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq);
2054
2102
  }
2055
2103
  this.notifier.markDirty();
2056
2104
  }
@@ -2073,8 +2121,11 @@ window.__ModuleLoader__.load({
2073
2121
  this.notifier.markDirty();
2074
2122
  return;
2075
2123
  }
2076
- if (result?.type === "publish" && this.appendLive(result.entry)) this.notifier.markDirty();
2077
- else if (result?.type === "transient") {
2124
+ if (result?.type === "publish") {
2125
+ const changed = this.appendLive(result.entry);
2126
+ if (result.retireAttemptId !== void 0) this.eventSource.settleAssistant(result.retireAttemptId);
2127
+ if (changed || result.retireAttemptId !== void 0) this.notifier.markDirty();
2128
+ } else if (result?.type === "transient") {
2078
2129
  this.eventSource.append(result.entry);
2079
2130
  this.notifier.markDirty();
2080
2131
  }
@@ -2106,36 +2157,35 @@ window.__ModuleLoader__.load({
2106
2157
  if (this.submissionSettlements.size === 0) return;
2107
2158
  if (event.type === "agent/inbox/spliced") {
2108
2159
  const { target, start, removedCount = 0, inserted, outcome } = event.data;
2109
- if (target === "next-step") {
2110
- for (const [requestId, settlement] of this.submissionSettlements) {
2111
- const receipt = settlement.receipt;
2112
- if (receipt === void 0 || receipt.index === null || receipt.seq >= event.seq) continue;
2113
- const removed = receipt.index >= start && receipt.index < start + removedCount;
2114
- if (removed && outcome === "canceled") this.retireFailedSubmission(requestId);
2115
- else settlement.receipt = {
2116
- ...receipt,
2117
- seq: event.seq,
2118
- index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount
2119
- };
2120
- }
2121
- this.observeSteeringInsertions(inserted, start, event.seq);
2160
+ for (const [requestId, settlement] of this.submissionSettlements) {
2161
+ const receipt = settlement.receipt;
2162
+ if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq) continue;
2163
+ const removed = receipt.index >= start && receipt.index < start + removedCount;
2164
+ if (removed && outcome === "canceled") this.retireFailedSubmission(requestId);
2165
+ else settlement.receipt = {
2166
+ ...receipt,
2167
+ seq: event.seq,
2168
+ index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount
2169
+ };
2122
2170
  }
2171
+ this.observeSubmissionInsertions(target, inserted, start, event.seq);
2123
2172
  for (const message of inserted) this.observeSubmissionMessage(message, false);
2124
2173
  return;
2125
2174
  }
2126
2175
  if (event.type === "request/context" || event.type === "turn/end") {
2127
- for (const [requestId, settlement] of this.submissionSettlements) if (settlement.receipt?.index === null && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId);
2176
+ for (const [requestId, settlement] of this.submissionSettlements) if (settlement.admitted === void 0 && settlement.receipt?.index === null && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId);
2128
2177
  return;
2129
2178
  }
2130
2179
  if (event.type === "user/message") this.observeSubmissionMessage(event.data, true);
2131
2180
  }
2132
- observeSteeringInsertions(messages, start, seq) {
2181
+ observeSubmissionInsertions(target, messages, start, seq) {
2133
2182
  for (const [index, message] of messages.entries()) {
2134
2183
  const source = message.source;
2135
2184
  if (source.kind !== "user" || !("rpcId" in source)) continue;
2136
2185
  const settlement = this.submissionSettlements.get(source.rpcId);
2137
- if (settlement?.placement !== "steering" || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue;
2186
+ if (settlement === void 0 || settlement.placement === "queued" || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue;
2138
2187
  settlement.receipt = {
2188
+ target,
2139
2189
  seq,
2140
2190
  index: start + index,
2141
2191
  attachments: attachmentRefsIn(message.content)
@@ -2145,15 +2195,34 @@ window.__ModuleLoader__.load({
2145
2195
  observeSubmissionMessage(message, admitted) {
2146
2196
  const source = message.source;
2147
2197
  if (source.kind !== "user" || !("rpcId" in source)) return;
2148
- if (!admitted && this.submissionSettlements.get(source.rpcId)?.placement === "steering") return;
2149
- this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content));
2198
+ const settlement = this.submissionSettlements.get(source.rpcId);
2199
+ if (settlement === void 0 || settlement.retiring) return;
2200
+ if (!admitted) {
2201
+ if (settlement.placement === "queued") this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content));
2202
+ return;
2203
+ }
2204
+ settlement.admitted = attachmentRefsIn(message.content);
2205
+ this.retireAdmittedSubmission(source.rpcId);
2206
+ }
2207
+ /** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */
2208
+ retireAdmittedSubmission(requestId) {
2209
+ const settlement = this.submissionSettlements.get(requestId);
2210
+ if (settlement?.admitted === void 0) return;
2211
+ const receipt = settlement.receipt;
2212
+ if (receipt?.index === null && (this.projections.seqOf("inbox") ?? -1) < receipt.seq) return;
2213
+ this.scheduleObservedRetirement(requestId, settlement.admitted);
2150
2214
  }
2151
- /** Retire non-steering echoes when the Inbox accepts their queue occurrences. */
2215
+ /** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */
2152
2216
  observeSubmissionInbox() {
2153
2217
  if (this.submissionSettlements.size === 0) return;
2154
2218
  const inbox = this.projections.get("inbox");
2155
2219
  if (inbox === void 0) return;
2156
- for (const message of [...inbox["next-turn"], ...inbox["next-step"]]) this.observeSubmissionMessage(message, false);
2220
+ const seq = this.projections.seqOf("inbox");
2221
+ for (const target of ["next-turn", "next-step"]) {
2222
+ if (seq !== void 0) this.observeSubmissionInsertions(target, inbox[target], 0, seq);
2223
+ for (const message of inbox[target]) this.observeSubmissionMessage(message, false);
2224
+ }
2225
+ for (const requestId of this.submissionSettlements.keys()) this.retireAdmittedSubmission(requestId);
2157
2226
  }
2158
2227
  /**
2159
2228
  * Latch one observed settlement and remove the echo an animation frame
@@ -2175,7 +2244,7 @@ window.__ModuleLoader__.load({
2175
2244
  /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
2176
2245
  retireFailedSubmission(requestId) {
2177
2246
  const settlement = this.submissionSettlements.get(requestId);
2178
- if (settlement === void 0 || settlement.retiring) return;
2247
+ if (settlement === void 0 || settlement.retiring || settlement.admitted !== void 0) return;
2179
2248
  settlement.retiring = true;
2180
2249
  this.finishSubmission(requestId, { reason: "failed" });
2181
2250
  }
package/lib/index.js CHANGED
@@ -1329,7 +1329,7 @@ var SessionHistoryController = class {
1329
1329
  if (throughSeq > sourceCursor) throw new RemoteError("gateway/bad-request", `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, {});
1330
1330
  /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
1331
1331
  if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) throw new RemoteError("gateway/internal", `session log does not contain through seq ${String(throughSeq)}`, {});
1332
- const page = paginate(sourceLog, beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES, throughSeq);
1332
+ const page = paginate(sourceLog, beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES, throughSeq, request.turnWindow);
1333
1333
  return {
1334
1334
  records: pageRecords(page.events),
1335
1335
  hasMore: page.hasMore
@@ -1348,7 +1348,7 @@ var SessionHistoryController = class {
1348
1348
  * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
1349
1349
  */
1350
1350
  async *follow(request, signal) {
1351
- validateFollowRequest(request);
1351
+ validateHistoryWindow(request);
1352
1352
  const { address } = request;
1353
1353
  const target = addressId(address);
1354
1354
  const buffered = new Deque();
@@ -1408,7 +1408,7 @@ var SessionHistoryController = class {
1408
1408
  signal.throwIfAborted();
1409
1409
  const cursor = source.cursor;
1410
1410
  snapshotCursor = cursor;
1411
- const page = paginate(events, void 0, request.maxMessages ?? DEFAULT_MAX_MESSAGES);
1411
+ const page = paginate(events, void 0, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow);
1412
1412
  const assistantStream = request.assistantStream === true ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 } : void 0;
1413
1413
  const assistantStreamOrdinalCut = assistantStreamOrdinal;
1414
1414
  yield {
@@ -1515,10 +1515,15 @@ function projectionBlock(snapshot) {
1515
1515
  function validatePageRequest(request) {
1516
1516
  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", {});
1517
1517
  if (request.beforeSeq !== void 0 && (!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", {});
1518
- if (request.maxMessages !== void 0 && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) throw new RemoteError("gateway/bad-request", "maxMessages must be a positive safe integer", {});
1518
+ validateHistoryWindow(request);
1519
1519
  }
1520
- function validateFollowRequest(request) {
1520
+ function validateHistoryWindow(request) {
1521
1521
  if (request.maxMessages !== void 0 && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) throw new RemoteError("gateway/bad-request", "maxMessages must be a positive safe integer", {});
1522
+ const window = request.turnWindow;
1523
+ if (window !== void 0) {
1524
+ if (!Number.isSafeInteger(window.minMessages) || window.minMessages <= 0 || window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES)) throw new RemoteError("gateway/bad-request", "turnWindow.minMessages must be a positive safe integer no greater than maxMessages", {});
1525
+ if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) throw new RemoteError("gateway/bad-request", "turnWindow.minTurns must be a positive safe integer", {});
1526
+ }
1522
1527
  }
1523
1528
  function addressId(address) {
1524
1529
  return address.kind === "session" ? address.sessionId : address.childSessionId;
@@ -1549,12 +1554,20 @@ function rejectNotFound(address) {
1549
1554
  childSessionId: address.childSessionId
1550
1555
  });
1551
1556
  }
1552
- function paginate(events, beforeSeq, maxMessages, throughSeq = events.at(-1)?.seq ?? -1) {
1557
+ function paginate(events, beforeSeq, maxMessages, throughSeq, turnWindow) {
1553
1558
  const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1));
1554
1559
  let count = 0;
1560
+ let turns = 0;
1555
1561
  let cut = SessionLogOffset(0);
1556
1562
  for (let index = end - 1; index >= 0; index--) {
1557
1563
  const event = events[index];
1564
+ if (turnWindow !== void 0 && event.type === "turn/start") {
1565
+ turns++;
1566
+ if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) {
1567
+ cut = SessionLogOffset(index);
1568
+ break;
1569
+ }
1570
+ }
1558
1571
  if (!MESSAGE_TYPES$1.has(event.type) || !isAppendSurfaceEvent(event)) continue;
1559
1572
  count++;
1560
1573
  const sources = event.sourceEventSeqs;