feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-05 19:00:02 +08:00
committed by Tianyi Cui
parent a229b42e24
commit f7e7851e3f
102 changed files with 2619 additions and 122 deletions
@@ -20,7 +20,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/
**Three domains, one job each, with a single boundary rule.**
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
- **`session/*` — the durable, replayable FACT log and its checkpoint signals.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit follows each append. The parallel `session/flush` checkpoint and contained `session/flushed` success observer are runtime signals rather than log entries; `session/flushed` carries the exclusive prefix proven durable by a listener's explicit acknowledgement. `session/event` is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as are the token stream (`assistant/chunk`) and mid-turn steering (a `user/message`).
- **`tools/*` — the tool registry and execution pipeline.**
@@ -79,7 +79,7 @@ Cold resume cannot depend on an optional method of `SubagentRun`, because that r
The internal continuation manager's resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved `SubagentProviderResumeRequest`, including the Task-owned cancellation signal, through a private service closure whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentService.followup()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither private provider dispatch nor a provider enumerates durable children or associates Tasks.
The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final `SessionStore.flush()` after the child becomes idle and before reading the result; `true` confirms at least one durability listener participated, `false` is a required-checkpoint failure, and rejection carries a listener failure. This retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the continuation manager disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children.
The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final `SessionStore.flush()` after the child becomes idle and before reading the result; `true` confirms that at least one listener completed durability work, `false` is a required-checkpoint failure, and rejection carries a listener failure. This retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the continuation manager disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children.
TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog.
@@ -79,7 +79,7 @@ durable child Session
内部继续执行管理器的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它通过私有服务闭包传递完全解析的 `SubagentProviderResumeRequest`,其中包含由 Task 持有的取消信号;该闭包只负责在检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentService.followup()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。私有的提供方分发与提供方本身都不会枚举持久化 child 或关联 Task。
后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终的 `SessionStore.flush()`;返回 `true` 表示至少一个持久性监听器参与,返回 `false` 表示必需的检查点失败,而拒绝则携带监听器失败。此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,继续执行管理器会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。
后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终的 `SessionStore.flush()`;返回 `true` 表示至少一个 listener 已完成持久化工作,返回 `false` 表示必需的检查点失败,而拒绝则携带 listener 失败。此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,继续执行管理器会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。
TODOACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。
@@ -103,7 +103,7 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`.
When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph.
Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` but does not interpret its participation boolean: an arbitrary listener cannot prove that the selected persistence backend stored the state. A rejection is logged without preventing handle disposal or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order.
Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` but does not require its durability-acknowledgement boolean: final lifecycle cleanup remains best-effort and cannot retain a child indefinitely when no backend acknowledges. A rejection is logged without preventing handle disposal or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order.
Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add.
@@ -135,7 +135,7 @@ Without Tasks there is no `task_output`, `task_kill`, Task status, or per-messag
Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions.
Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier. The manager deliberately ignores the boolean result because listener participation cannot identify a persistence backend. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume.
Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier. The manager deliberately ignores the durability-acknowledgement boolean because lifecycle cleanup must still finish when no backend acknowledges. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume.
Only messages written to the child Session log are reconstructable with the source that supplied them; inbox acceptance alone provides no restart guarantee.
@@ -191,7 +191,7 @@ The implementation pins these behaviors:
- An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained.
- A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.
- Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph.
- Final Activation settlement awaits `ctx.sessions.flush(child.session)` as a best-effort barrier, logs rejection without interpreting listener participation as durability proof, then disposes the child handle and releases parent ownership so a flush failure cannot leak a `waiting` Activation.
- Final Activation settlement awaits `ctx.sessions.flush(child.session)` as a best-effort barrier, ignores a missing durability acknowledgement and logs rejection, then disposes the child handle and releases parent ownership so a flush failure cannot leak a `waiting` Activation.
- Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope.
- The base lifecycle has no implicit report behavior; the optional report package contributes an explicit child-scoped tool through the setup hook.
- Session logs reconstruct only messages that were actually written, with the source that supplied each message; inbox-accepted but unlogged messages have no restart guarantee.
@@ -103,7 +103,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。
只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会等待 `ctx.sessions.flush(child.session)`,但不解释其参与布尔值:任意 listener 都无法证明所选持久化后端已存储该状态。rejection 会被记录,但不会阻止 handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。
只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会等待 `ctx.sessions.flush(child.session)`,但不要求其持久化确认布尔值:最终生命周期清理保持 best-effort,不能因为没有后端确认就无限保留 child。rejection 会被记录,但不会阻止 handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。
系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。
@@ -135,7 +135,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。
每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器特意忽略布尔结果,因为 listener 是否参与无法标识持久化后端。rejection 会被记录,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。
每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器特意忽略持久化确认布尔值,因为没有后端确认时生命周期清理仍必须完成。rejection 会被记录,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。
只有实际写入 child 会话日志的消息,才能在重建时保留提供它的来源;仅被 inbox 接受并不提供重启保证。
@@ -191,7 +191,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
- 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。
-`waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。
- 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。
- Activation 最终结算会等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会记录 rejection但不会把 listener 参与解释为持久性证明,然后 dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。
- Activation 最终结算会等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会忽略缺失的持久化确认并记录 rejection,然后 dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。
- 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。
- 基础生命周期不暴露隐式报告行为;可选的 report 包通过 setup 钩子贡献一个显式的 child 作用域工具。
- 会话日志只会重建实际写入的消息,并保留每条消息的提供来源;已被 inbox 接受但未写入日志的消息没有重启保证。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md
2026-08-05-durable-web-schedule.md: 584d7be639b5611a5ea3279f59dfd05f0c746a62
2026-08-05-durable-web-schedule.zh.md: 2bfeb81cac0c1bc8df84d065bdae278a345b5358
@@ -0,0 +1,100 @@
# Agent Note: Durable Session-local Web reminders
Status: implemented
English | [中文](2026-08-05-durable-web-schedule.zh.md)
## Problem
A reminder created inside a conversation needs to survive a process restart and remain attributable to that exact Session. A process-local timer or model inbox item cannot provide that durability, while a global scheduler or private database would introduce a second identity, persistence, and lifecycle system. The user also needs a visible receipt even when the best-effort model turn later fails, without seeing a reminder whose dispatch never reached storage.
Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, and browser history races make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and merge a presentation sidecar that can arrive after the underlying event.
## Decision
The [`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay explicitly loads `@deepseek-ai/dsh-tool-schedule` and the separate `@deepseek-ai/dsh-client-ui-schedule` renderer. The default Web tree remains unchanged. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate it.
The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while it is live, does no external notification while cold, and processes an overdue reminder after that Session becomes live again.
| Scenario | Durable fact | Live behavior | User-visible result |
| --- | --- | --- | --- |
| Create and manage | `schedule/change` create/delete events in the original Session | Agent-scoped tools checkpoint before reading and after mutations | Stable id, UTC target, `scheduled`/`overdue`, and `session-local` disclosure |
| Due while busy | Active create remains in the fold | Owner waits for `whenIdle()`, reserves admission, queues one followup, then appends dispatch | One replayable reminder receipt; model failure does not retract it |
| Process stopped or Session cold | Active create remains in persistence | No timer or background scan exists; resume rebuilds the owner | Future target waits again; overdue target is attempted once |
| Fork | Parent events remain in the inherited prefix | Child fold starts at `seedLength` | Parent receipt may appear in history, but no parent reminder becomes active child work |
### Session log authority and tools
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed user prompt, the rule, and its UTC target. Delete and dispatch are terminal transitions. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`.
The current rule accepts a non-empty prompt and exactly one positive safe-integer `after_seconds`. Its record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`; dispatch stores only the id because the record already fixes its occurrence. `at`, `every_seconds`, `cron`, and `time_zone` are rejected rather than hidden in unused fields. Tool values derive `scheduled` or `overdue` and always include `deliveryMode: 'session-local'`.
Every tool operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create may reject input-shape failures before this preflight; after a successful preflight it allocates an id, appends create, and waits for a second barrier. Delete preflights before deciding whether an id is active and waits for a second barrier only when it appends. List and unknown or finished delete never answer from an unconfirmed live suffix. A failed barrier returns `persistence_uncertain` rather than guessing whether an eager write committed.
Every successful management preflight also asks the live owner to recompute. This closes the recovery path where create appended successfully but its post-append barrier rejected: a later list can confirm the coordinator's retained batch, return the active record, and arm its timer without a Schedule-specific retry loop.
### Persistence checkpoint and initialization recovery
`SessionStore.flush()` awaits every scoped listener and treats literal `true` as an explicit durability acknowledgement. An acknowledged call publishes a contained `session/flushed(session, throughSeq)` observation whose exclusive boundary was captured at call entry; append notification itself is not durability evidence. Observe-only listeners return void, an empty or observe-only checkpoint returns `false`, and any listener rejection prevents the success observation after all listeners settle.
The persistence coordinator supplies that acknowledgement only after its write path is quiescent. Its live controller retains the initial `seedEnd` scalar rather than a seed copy. If the first initialization rejects, a later flush rebuilds that immutable prefix from the append-only Session, reads the backend's actual cursor, and appends only a missing suffix. This covers failures before storage changed and failures reported after a commit, so one transient error neither permanently poisons the Session nor duplicates its prefix.
### Live delivery lifecycle
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. An unavailable `reserveTurnAdmission()` leaves the record active and installs one `whenIdle()` wait before retrying.
The accepted path first clears pending persistence, reserves turn admission, samples the decision clock once, and constructs the complete fixed reminder frame with JSON-escaped id and prompt. It synchronously queues one `followup()`, appends the id-only dispatch, and releases the reservation in `finally`; only then does it wait for the dispatch barrier. A framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. A later prompt-admission, request-checkpoint, or model failure cannot retract a dispatch.
Agent or plugin disposal cancels timers, stops new work, unwinds the three tool registrations, and waits for in-flight preflights or idle waits. It never deletes durable records during teardown. The narrow crash interval after synchronous followup admission and before durable dispatch may repeat the reminder after recovery; the design prefers a visible duplicate over silent loss and makes no model-success, user-read, external-effect, or exactly-once promise.
### Commit-aware Web receipt
The Schedule package owns `scheduleReminderPresentation()`, which derives `{ scheduleId, prompt, occurrenceAt, deliveryMode }` from create plus dispatch. A dispatch inside an inherited fork prefix folds that parent segment for history display; a child-owned dispatch folds only the child suffix. Presentation therefore never changes live ownership.
The Host continues to send every raw event on append. It keeps one monotonic watermark per exact live `Session` in a `WeakMap`; only `session/flushed` advancement makes it redeliver newly covered dispatch events with the generic `{ for: 'event', presentationKey: 'schedule/reminder', view }` sidecar. Taking the maximum contains reversed concurrent flush completion, and exact object identity prevents a reused Session id from inheriting another lifecycle's cursor.
Attached history independently inspects persistence and adds views only to a stored event prefix whose header identity and every event match the live Session. Persistence canonically writes absent top-level `delegationDepth` as zero, so those two forms are identity-equivalent; cwd, lineage, origin, timestamps, version, id, and every event still match exactly. Missing, failed, divergent, or longer inspection withholds the view while returning raw history. Detached history is already a persisted prefix. A parent dispatch copied into a fork seed therefore appears in child history only after child storage proves that prefix.
The browser Session accepts a repeated seq only when the durable event is deeply identical, then upgrades the sidecar without appending another event. Its existing `liveBuffer` is the sole rendezvous for tail loading, gap repair, and older-page pagination. Every current-generation settlement merges overlapping views and a contiguous suffix, including rejected, empty, and discontinuous responses; reconnect invalidates old requests and their loading ownership. `TranscriptAdapter` creates a generic `PresentedEventNode`. `ui-conversation` dispatches it through `conversation.chat.eventview` and retains an expandable JSON fallback, while `ui-schedule` owns the bilingual reminder row.
```text
schedule_create → Session create event → persistence
↓ live owner
due → admission → followup → dispatch → flush(true) → session/flushed
Host late event sidecar
client same-seq merge → keyed UI receipt
```
## Alternatives considered
**Use `ctx.tasks`.** Tasks own process-local work, terminal outcomes, collection, and notifications rather than Session-log state and replayable conversation receipts. Reusing them would make the wrong lifecycle authoritative.
**Store reminders in a private SQLite table or global scheduler.** This could run cold Sessions, but requires a second Session identity map, startup scan, ownership lease, crash protocol, and notification policy. The accepted scope deliberately runs only while the original Session is live.
**Claim dispatch before `followup()` or add exactly-once fencing.** A claim-first record can silently lose the user-visible reminder when enqueue fails. Cross-process exactly-once requires a lease, outbox, acknowledgement, and downstream idempotency boundary that Session-local best-effort model work does not provide.
**Treat the model message as the receipt.** The queued inbox item is process-local and may fail before a durable user message exists. A dispatch-derived Web receipt remains visible and replayable independently of model success.
**Attach the reminder view on append.** `session/event` precedes the durability result, so this would display a ghost receipt after a rejected flush. The success watermark makes presentation follow the commit point.
**Add a Schedule-specific wire frame, client cache, or management page.** The generic event sidecar, existing Session window buffer, keyed slot, and model-facing tools already carry the required result. A parallel transport or state store would duplicate identity and replay logic.
**Adopt existing roots or register global tools.** Late adoption makes plugin load order change which unseen timers begin running and exposes tools outside the supported root-Agent composition. Future-root, Agent-scoped installation gives one clear lifecycle.
The design does not recognize or migrate any unmerged Schedule implementation or private storage format. No fixed Session id, claim-before-send record, startup miss, or private database is a compatibility input.
## Verification
Package tests pin strict decoding, transitions, fork suffixes, id reuse, time bounds, bounded waits, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at 100% per-file coverage. Persistence tests cover new, fork, and resumed initialization failures against the actual durable cursor, and a production JSONL restart proves both pending and dispatched states. Host/client tests cover commit gating, reversed watermarks, semantic header identity, per-event prefix matching, same-seq upgrades, every window merge exit, and reconnect generations.
The opt-in Loader composition boots the source and built packages. A keyless real-browser scenario executes `schedule_create` through the complete tool pipeline, waits for a one-second dispatch, observes the identity-matched persisted prefix, and renders the durable reminder card from attached history. The deliberately absent model adapter closes the turn with an error after dispatch, proving that model failure does not remove the receipt.
## Consequences
- Reminder state survives process restart and replays through ordinary Session persistence without a new database or public service.
- A cold Session does no work and sends no external notification; reopening it may deliver an overdue reminder, and every tool/card says `session-local`.
- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. Long waits and plugin unload do not create a second durable state machine.
- The generic commit-aware event-view path is reusable by other durable events, but it adds identity checks and generation-aware merge behavior to the client Session window.
- The strict after-only protocol is intentionally small; other rule families require explicit record, time, and recurrence semantics rather than dormant fields.
@@ -0,0 +1,100 @@
# Agent Note: 持久、仅限 Session 内的 Web 提醒
Status: implemented
[English](2026-08-05-durable-web-schedule.md) | 中文
## 问题
在对话中创建的提醒需要跨进程重启存活,并始终归属于确切的原 Session。进程内 timer 或模型 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。即使后续 best-effort 模型轮次失败,用户仍需要看到回执;但 dispatch 尚未到达存储的提醒绝不能提前显示。
繁忙的 Agent、长等待、墙钟变化、cold Session、fork、持久化失败和浏览器 history 竞态,使简单 timeout 无法满足要求。设计必须区分持久 record 与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并合并可能晚于原始 event 到达的 presentation sidecar。
## 决策
[`examples/web-schedule`](../../../../examples/web-schedule/README.md) overlay 显式加载 `@deepseek-ai/dsh-tool-schedule` 与独立 renderer `@deepseek-ai/dsh-client-ui-schedule`。默认 Web 配置树保持不变。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他宿主都不会激活它。
用户可见边界固定为 `session-local`:原 Session 只有在 live 时才会准点运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。
| 场景 | 持久事实 | live 行为 | 用户可见结果 |
| --- | --- | --- | --- |
| 创建与管理 | 原 Session 中的 `schedule/change` createdelete event | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、`scheduled``overdue``session-local` 说明 |
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 `whenIdle()`、预留准入、排入一次 followup,再追加 dispatch | 一条可回放提醒回执;模型失败不会撤回它 |
| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标尝试一次 |
| fork | 父 event 留在继承前缀 | child fold 从 `seedLength` 开始 | history 可显示父回执,但父提醒不会成为 child 活动工作 |
### Session 日志权威与工具
版本 1 `schedule/change` stream 是唯一持久 Schedule 权威。create record 拥有 Session 内不复用的品牌 id、trim 后的用户 prompt、规则与 UTC 目标。delete 和 dispatch 是终结 transition。严格 decoder 与 pure fold 会拒绝未知版本、额外字段、重复 id,以及针对非活动 record 的 transition。普通 Session 折叠完整 streamfork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。
当前规则接受非空 prompt 与恰好一个正 safe-integer `after_seconds`。record 形状是 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`dispatch 只保存 id,因为 record 已经唯一确定 occurrence。`at``every_seconds``cron``time_zone` 会被拒绝,不会作为未使用字段隐藏在协议中。工具 value 派生 `scheduled``overdue`,并始终包含 `deliveryMode: 'session-local'`
每项从 fold 读取或作出判断的工具操作都会先等待 `ctx.sessions.flush(session)`。create 可以在这次 preflight 前拒绝只依赖输入 shape 的失败;preflight 成功后才分配 id、追加 create,并等待第二个 barrier。delete 在判断 id 是否活动前先 preflight,只有实际追加时才等待第二个 barrier。list 与未知或已终结 delete 绝不会从未确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
每次成功的管理 preflight 也会要求 live owner 重新计算。这闭合了 create 已成功追加、但 post-append barrier 拒绝时的恢复路径:后续 list 可以确认 coordinator 保留的 batch、返回活动 record,并在没有 Schedule 私有重试循环的情况下 arm timer。
### Persistence checkpoint 与初始化恢复
`SessionStore.flush()` 会等待所有 scoped listener,并把字面量 `true` 视为显式 durability acknowledgement。获得确认的调用会发布受包含的 `session/flushed(session, throughSeq)` observation;其中排他边界在调用入口捕获,append 通知本身不是 durability 证据。仅观察 listener 返回 void;空或只有观察者的 checkpoint 返回 `false`;任一 listener 拒绝都会在全部结算后阻止成功 observation。
persistence coordinator 只有在写路径完全停稳后才给出该确认。live controller 只保留初始 `seedEnd` 标量,不复制 seed。首次初始化拒绝后,后续 flush 会从仅追加 Session 重建该不可变前缀、读取后端实际 cursor,并只追加缺失 suffix。无论失败发生在存储变更前,还是提交后才返回拒绝,一次暂时性错误都不会永久毒化 Session 或重复写入其前缀。
### Live 交付生命周期
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。`reserveTurnAdmission()` 不可用时,record 保持活动,并安装一个 `whenIdle()` wait 后再重试。
获得准入的路径会先清空 pending persistence、预留 turn admission、只采样一次 decision clock,并使用 JSON-escaped id 与 prompt 构造完整固定 reminder frame。它同步排入一次 `followup()`,追加只含 id 的 dispatch,并在 `finally` 中释放 reservation;之后才等待 dispatch barrier。framing 或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。后续 prompt admission、request checkpoint 或模型失败都不能撤回 dispatch。
Agent 或插件 dispose 会取消 timer、停止新工作、撤销三个工具注册,并等待进行中的 preflight 或 idle wait。teardown 绝不会删除持久 record。同步 followup 获得准入后、durable dispatch 前的狭窄崩溃窗口可能在恢复后重复提醒;本设计选择可见重复而非静默丢失,不承诺模型成功、用户阅读、外部副作用或 exactly-once。
### Commit-aware Web 回执
Schedule package 拥有 `scheduleReminderPresentation()`,从 create 加 dispatch 派生 `{ scheduleId, prompt, occurrenceAt, deliveryMode }`。位于继承 fork 前缀中的 dispatch 会折叠该 parent segment 用于 history 显示;child 自有 dispatch 只折叠 child 后缀。因此 presentation 永远不会改变 live ownership。
Host 在 append 时继续发送所有 raw event。它在 `WeakMap` 中按 exact live `Session` 保存一个单调 watermark;只有 `session/flushed` 前进时,才会用通用 `{ for: 'event', presentationKey: 'schedule/reminder', view }` sidecar 重投新覆盖的 dispatch event。取最大值可以收容反序完成的并发 flush,按对象身份键控则阻止复用的 Session id 继承另一个生命周期的 cursor。
已附加 history 会独立 inspect persistence,只有 stored event prefix 的 header identity 与每个 event 都和 live Session 匹配时才添加 view。persistence 会把顶层缺失的 `delegationDepth` 规范写成零,因此两种形式在身份上等价;cwd、lineage、origin、时间戳、版本、id 与每个 event 仍必须精确匹配。inspect 缺失、失败、分歧或比 live 更长时,只会省略 viewraw history 仍然返回。已分离 history 本身就是持久前缀。因此复制进 fork seed 的 parent dispatch 只有在 child storage 证明该前缀后才会显示。
浏览器 Session 只有在 durable event 深度一致时才接受重复 seq,随后只升级 sidecar,不再追加 event。既有 `liveBuffer` 是尾部加载、gap repair 与旧页分页期间唯一的汇合点。每个当前 generation 的结算出口都会合并重叠 view 与连续 suffix,包括拒绝、空页和不连续响应;重连会使旧请求及其 loading ownership 失效。`TranscriptAdapter` 创建通用 `PresentedEventNode``ui-conversation` 通过 `conversation.chat.eventview` 分发,并保留可展开 JSON fallback`ui-schedule` 则拥有双语提醒行。
```text
schedule_create → Session create event → persistence
↓ live owner
due → admission → followup → dispatch → flush(true) → session/flushed
Host late event sidecar
client same-seq merge → keyed UI receipt
```
## 已考虑的替代方案
**使用 `ctx.tasks`。** Task 拥有进程内工作、终态结果、收集与通知语义,而不是 Session 日志状态和可回放会话回执。复用它会让错误的生命周期成为权威。
**把提醒存入私有 SQLite 表或全局 scheduler。** 这样可以运行 cold Session,却必须增加第二套 Session 身份映射、startup 扫描、ownership lease、崩溃协议与通知政策。当前范围有意只在原 Session live 时运行。
**在 `followup()` 前 claim dispatch,或增加 exactly-once fencing。** claim-first record 会在入队失败时静默丢失用户可见提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,而 Session-local best-effort 模型工作不具备这些边界。
**把模型消息当作回执。** 已排队 inbox 项是进程内状态,可能在产生持久 user message 前失败。从 dispatch 派生的 Web 回执不依赖模型成功,仍然可见、可回放。
**在 append 时附加提醒 view。** `session/event` 早于 durability 结果;这样会在 flush 拒绝后显示幽灵回执。成功 watermark 让 presentation 服从提交点。
**增加 Schedule 专属 wire frame、client cache 或管理页面。** 通用 event sidecar、既有 Session window buffer、键控 slot 与面向模型工具已经能承载所需结果。平行 transport 或状态 store 会重复身份与回放逻辑。
**接管既有根或注册全局工具。** 晚接管会让插件加载顺序改变哪些不可见 timer 开始运行,并把工具暴露到支持范围之外。只面向未来根、按 Agent scope 安装,提供了单一明确生命周期。
本设计不会识别或迁移任何未合入的 Schedule 实现或私有存储格式。固定 Session id、claim-before-send record、startup miss 与私有数据库都不是兼容输入。
## 验证
package 测试以逐文件 100% coverage 固定严格 decoding、transition、fork suffix、id 不复用、时间边界、有界等待、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳 dispose。persistence 测试依据实际 durable cursor 覆盖 new、fork 与 resumed 初始化失败;production JSONL restart 同时证明 pending 与 dispatched 状态。Host/client 测试覆盖 commit gating、反序 watermark、语义 header identity、逐 event 前缀匹配、same-seq 升级、每个 window merge 出口和 reconnect generation。
显式 Loader 组合可以启动 source 与 built package。无密钥真实浏览器场景会通过完整工具 pipeline 执行 `schedule_create`、等待一秒 dispatch、观察 identity-matched 持久前缀,并从已附加 history 渲染 durable reminder card。刻意缺少的模型 adapter 会在 dispatch 后以错误关闭 turn,从而证明模型失败不会移除回执。
## 后果
- 提醒状态通过普通 Session persistence 跨进程重启并回放,无需新数据库或公开 service。
- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 提醒,且每个工具/卡片都会显示 `session-local`
- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。长等待和插件卸载不会创建第二套持久状态机。
- 通用 commit-aware event-view 路径可供其他持久 event 复用,但为 client Session window 增加了身份检查与 generation-aware merge 行为。
- 严格的 after-only 协议有意保持小型;其他规则系列需要显式 record、时间与 recurrence 语义,而不是 dormant 字段。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md
2026-07-27-intent-named-subagent-continuation-operations.md: e74d62b7582e92f8e5ce68327a677259c8453d24
2026-07-27-intent-named-subagent-continuation-operations.zh.md: ae7b370441d8e0ee045f4d0fcf851d28d055b295
2026-07-27-intent-named-subagent-continuation-operations.md: 4175e6d593e066033f3796357c6f0aefd767c588
2026-07-27-intent-named-subagent-continuation-operations.zh.md: 6aeb036153a7d32ee61f2c08caf56273aa062ade
@@ -18,7 +18,7 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi
Caller and provider requests are distinct. `SubagentStartRequest` contains caller-supplied one-shot data; `ResolvedSubagentStartRequest` adds the service-resolved descriptor before `SubagentProvider.start()`. For continuable creation, the manager passes a `ContinuableCreateRequest` to optional `SubagentProvider.prepareContinuable()` and receives detached creation data only. `SubagentService.resume()` and provider resume dispatch are absent: the continuation manager loads the descriptor, authorizes the parent, and owns Agent materialization, prompt delivery, cold resume, and teardown.
`SessionStore.flush(session)` is the single durability barrier and returns `Promise<boolean>`. It resolves `true` after at least one scoped listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Participation cannot identify whether a selected persistence backend stored the state. Ordinary checkpoints may ignore the boolean; the continuation manager also treats its final flush as a best-effort barrier, deliberately ignores participation, logs rejection, and still disposes the child and releases ownership.
`SessionStore.flush(session)` is the single durability barrier and returns `Promise<boolean>`. Every scoped listener settles; a listener returns literal `true` only when it completed durability work. The call resolves `true` when at least one listener gives that acknowledgement, resolves `false` when none does, and rejects with the first registered listener failure after all listeners settle. The acknowledgement does not identify a selected persistence backend when several listeners are present. Ordinary checkpoints may ignore the boolean; the continuation manager also treats its final flush as a best-effort barrier, deliberately ignores it, logs rejection, and still disposes the child and releases ownership.
## Alternatives considered
@@ -26,7 +26,7 @@ Caller and provider requests are distinct. `SubagentStartRequest` contains calle
**Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route.
**Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable.
**Keep `flushRequired()`.** A second method hides only a missing-durability-acknowledgement check. Returning that acknowledgement from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable.
**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned one-shot run or immediate durable child and message identities. Separate intent methods preserve the ownership and timing distinction without a return union.
@@ -34,5 +34,5 @@ Caller and provider requests are distinct. `SubagentStartRequest` contains calle
- The Cordis service catalog contains only caller operations; a provider can opt into continuable first creation through `SubagentProvider.prepareContinuable?()` without receiving Agent lifecycle authority or a public resume operation.
- Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics.
- Session durability has one barrier operation. Its participation result remains observable, but no continuable-child path treats arbitrary listener participation as proof that a persistence backend stored the state.
- Session durability has one barrier operation. Its explicit durability acknowledgement remains observable, but no continuable-child path depends on which backend supplied it.
- The `send_message` and `report` schemas, accepted message identities, `AgentHandle` ownership, durable event vocabulary, and model-visible transcript follow the activation-based realization linked above.
@@ -18,7 +18,7 @@ Status: implemented
调用方请求与提供方请求相互分离。`SubagentStartRequest` 包含调用方提供的 one-shot 数据;`ResolvedSubagentStartRequest` 会在调用 `SubagentProvider.start()` 前加入由服务解析的描述符。创建可继续 child 时,管理器将 `ContinuableCreateRequest` 传给可选的 `SubagentProvider.prepareContinuable()`,且只接收分离的创建数据。`SubagentService.resume()` 与提供方恢复分发均不存在:继续执行管理器加载描述符、对 parent 进行鉴权,并负责 Agent 实体化、提示词投递、冷恢复与 teardown。
`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise<boolean>`至少一个作用域内监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。参与结果无法表明所选的持久化后端是否已经存储状态。普通检查点可以忽略该布尔值;继续执行管理器同样将最终 flush 视为 best-effort 屏障,有意忽略参与结果,记录拒绝日志,并仍会对 child 执行 dispose(资源释放)并释放所有权。
`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise<boolean>`所有作用域内 listener 都会结算;只有在完成持久化工作后,listener 才返回字面量 `true`。至少一个 listener 给出该确认时,调用解析为 `true`;没有 listener 确认时解析为 `false`;所有 listener 结算后,如有失败,则以注册顺序最靠前的错误拒绝。当存在多个 listener 时,该确认不会标识具体由哪个持久化后端提供。普通检查点可以忽略该布尔值;继续执行管理器同样将最终 flush 视为 best-effort 屏障,有意忽略,记录拒绝日志,并仍会对 child 执行 dispose(资源释放)并释放所有权。
## 已考虑的替代方案
@@ -26,7 +26,7 @@ Status: implemented
**在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。
**保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。
**保留 `flushRequired()`。** 第二个方法只封装了缺少持久化确认的检查。由现有屏障返回该确认,可以让分发只保留一套实现,并让每个调用方自行判定缺少确认是否可接受。
**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 one-shot run 就绪后返回,要么立即返回持久化 child 与消息标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。
@@ -34,5 +34,5 @@ Status: implemented
- Cordis 服务目录只包含调用方操作;提供方可以通过 `SubagentProvider.prepareContinuable?()` 选择参与可继续 child 的首次创建,但不会获得 Agent 生命周期权限或公开恢复操作。
- 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。
- 会话持久性只有一个屏障操作。参与结果仍可观测,但任何可继续 child 路径都不会将任意监听器参与视为持久化后端已存储状态的证明
- 会话持久性只有一个屏障操作。显式持久化确认仍可观测,但任何可继续 child 路径都不依赖由哪个后端提供确认
- `send_message``report` schema、已接受的消息标识、`AgentHandle` 所有权、持久化事件词汇与模型可见的 transcript(文本记录)遵循上文链接的基于 Activation 的实现。
+145
View File
@@ -0,0 +1,145 @@
// Keyless assembled-browser evidence for the opt-in Schedule overlay. A real
// root Agent receives schedule_create through the complete tool pipeline; the
// one-second owner path queues its best-effort followup, commits dispatch, and
// the browser renders the Host's durability-gated reminder sidecar. No model
// fixture is installed: the later prompt failure cannot retract the receipt.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/schedule-after', import.meta.url))
const RECEIPT_EXPECTED = fileURLToPath(new URL('./snapshots/schedule-after/receipt.expected.md', import.meta.url))
const PROMPT = 'Check the deployment log'
interface CreatedScheduleView {
id: string
deliveryMode: 'session-local'
}
/** Wait for one in-process lifecycle fact without using test-scoped expect.poll in beforeAll. */
async function waitForFact(read: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!read()) {
if (Date.now() >= deadline) throw new Error(`Schedule lifecycle fact did not arrive within ${timeoutMs}ms`)
await new Promise(resolve => setTimeout(resolve, 20))
}
}
describe.skipIf(MODE === 'record')('web e2e: durable after reminder receipt', () => {
let scaffold: WebScaffold
let agentHandle: AgentHandle
let browser: Browser
let page: Page
let scheduleId = ''
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
agentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-after-web-e2e'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
const workspace = await scaffold.ctx.workspace.create(scaffold.workspaceCwd, 'Schedule')
await workspace.attachSession(agentHandle.agent.id)
const created = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(10_000),
callId: CallId('schedule-after-create'),
name: 'schedule_create',
arguments: { prompt: PROMPT, after_seconds: 1 },
agent: agentHandle.agent,
})
expect(created.isError).toBe(false)
if (created.isError) throw new Error(created.error.message)
const value = created.value as unknown as CreatedScheduleView
expect(value.deliveryMode).toBe('session-local')
scheduleId = value.id
expect(scheduleId.length).toBeGreaterThan(0)
await waitForFact(() => agentHandle.agent.session.events.some(event =>
event.type === 'schedule/change'
&& (event.data as { operation?: unknown }).operation === 'dispatch'), 15_000)
await expect(scaffold.ctx.sessions.flush(agentHandle.agent.session)).resolves.toBe(true)
const durable = await scaffold.ctx.sessionPersistence.inspect(agentHandle.agent.id)
expect(durable.meta).toMatchObject(agentHandle.agent.session.header)
expect({ ...durable.meta, delegationDepth: durable.meta.delegationDepth ?? 0 }).toEqual({
...agentHandle.agent.session.header,
delegationDepth: agentHandle.agent.session.header.delegationDepth ?? 0,
})
expect(durable.events).toEqual(agentHandle.agent.session.events.slice(0, durable.events.length))
const history = await scaffold.ctx.apiProxy.sessions.history({
rpcId: RpcId('schedule-history-baseline'), payload: { sessionId: agentHandle.agent.id },
})
if (!history.result.ok) throw new Error(history.result.error.message)
expect(history.result.value.events?.find(entry =>
entry.event.type === 'schedule/change'
&& (entry.event.data as { operation?: unknown }).operation === 'dispatch')?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
})
await waitForFact(
() => agentHandle.agent.session.events.some(event => event.type === 'turn/start'),
10_000,
)
const listed = await scaffold.ctx.apiProxy.sessions.list({
rpcId: RpcId('schedule-list-baseline'), payload: {},
})
if (!listed.result.ok) throw new Error(listed.result.error.message)
expect(listed.result.value.items.find(item => item.sessionId === agentHandle.agent.id)?.blank).toBe(false)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'Schedule Web evidence teardown failed')
})
it('renders the committed reminder from attached history', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after'))
const group = page.locator('[role="treeitem"]').first()
await group.waitFor({ timeout: 15_000 })
if (await group.getAttribute('aria-expanded') !== 'true') {
await group.evaluate((element) => { (element as HTMLElement).click() })
}
await expect.poll(() => group.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
const session = page.locator('[role="treeitem"][aria-selected]').nth(1)
await session.waitFor({ timeout: 10_000 })
await session.click()
const receipt = page.locator('[data-schedule-reminder]')
await receipt.waitFor({ timeout: 15_000 })
expect(await receipt.getByText(PROMPT, { exact: true }).count()).toBe(1)
expect(await receipt.getByText('Delivered in this session only', { exact: true }).count()).toBe(1)
const snapshot = (await captureStableAria(page, '[data-schedule-reminder]', scaffold.workspaceCwd))
.split(scheduleId).join('{{scheduleId}}')
.replace(/\d{4}-\d{2}-\d{2}T(?:\d{2}:\d{2}:\d{2}\.\d{3}|\{\{clock\}\})Z/gu, '{{occurrenceAt}}')
await compareOrRefreshGolden(RECEIPT_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['receipt.expected.md'])
})
})
@@ -0,0 +1,6 @@
- note:
- banner: Scheduled reminder Delivered in this session only
- paragraph: Check the deployment log
- contentinfo:
- text: ID {{scheduleId}}
- time: Due at {{occurrenceAt}}
+1
View File
@@ -62,6 +62,7 @@
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/schedule-after.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
"tests/produced-file-mentions.e2e.ts",
+1 -1
View File
@@ -142,7 +142,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw
**Model-visible ⟺ logged**: messages entering at `step/start` plus the folded `request/header` reconstruct every request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `request/context` separately records registration-bound provider, model, and capacity metadata when the route changes; it does not participate in request reconstruction or header equality. `dsh-agent-loop/invariant` asserts reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)).
Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, then follows `turn/end` before another queued turn or idle observation. A listener returns literal `true` only after durability work completes; a successful acknowledged barrier publishes contained `session/flushed(session, throughSeq)` with the exclusive event boundary captured at entry, so commit-aware projections can advance without treating append notification as durability. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)).
Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` relies on bounded background persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; the latest title event wins, and it records the source message seqs and whether the user, fallback, or provider supplied it. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)).
+1
View File
@@ -2593,6 +2593,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts))
- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
- `@deepseek-ai/dsh-client-ui-schedule` ([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+14
View File
@@ -514,6 +514,20 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/
Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/sandbox/sandbox-policy/src/session-mode.ts)
### `schedule/*`
#### `schedule/change` — log-only
```ts persistence-catalog
/**
* Versioned Schedule mutation. The owning package validates the complete
* session-local transition stream before accepting a candidate event.
*/
'schedule/change': ScheduleChange
```
Source: [`packages/schedule/tool-schedule/src/types.ts:156`](../packages/schedule/tool-schedule/src/types.ts)
### `session/*`
#### `session/end-seed` — log-only
+4
View File
@@ -20,6 +20,10 @@ An unattended coding agent driven through the Python SDK and JSON-RPC. See the [
A self-referential agent that can inspect and change its in-memory Cordis plugin tree. See the [web-cordis example reference](web-cordis/README.md).
## web-schedule
An opt-in Web overlay for durable, Session-local reminders. It supports positive whole-second `after_seconds` reminders through `schedule_create`, `schedule_list`, and `schedule_delete`; active reminders persist in the original Session, resume when that Session becomes live again, and do not run while it is cold. Run `dsh web --config examples/web-schedule/cordis.yml`; see [web-schedule/README.md](web-schedule/README.md) for the delivery and recovery boundary.
## acp-agent
An Agent Client Protocol automation server for programmatic clients, with session, permission, and cancellation support. See the [ACP example reference](acp-agent/README.md).
+4
View File
@@ -20,6 +20,10 @@
能够检查并更改内存中 Cordis 插件树的自指 agent。详见 [web-cordis 示例参考](web-cordis/README.md)。
## web-schedule
用于持久、仅限 Session 内提醒的显式 Web overlay。它通过 `schedule_create``schedule_list``schedule_delete` 支持正整数秒的 `after_seconds` 提醒;活动提醒保存在原 Session 中,该 Session 再次 live 时恢复,而 cold 期间不会运行。使用 `dsh web --config examples/web-schedule/cordis.yml` 启动;交付与恢复边界详见 [web-schedule/README.md](web-schedule/README.md)。
## acp-agent
面向程序化客户端的 ACPAgent Client Protocol)自动化服务器,支持会话、权限和取消操作。详见 [ACP 示例参考](acp-agent/README.md)。
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write examples/web-schedule/README.md
README.md: 98ba3c78cfff2c6db62487db727f08825077f150
README.zh.md: e36b12acc97313d7feaa3af55e5dc46294d7e8da
+17
View File
@@ -0,0 +1,17 @@
# Durable Web Schedule
English | [中文](README.zh.md)
This overlay opts one `dsh web` process into durable Schedule reminders without changing the shipped default Web composition:
```sh
dsh web --config examples/web-schedule/cordis.yml
```
The current overlay supports one-shot reminders created with a positive whole-number `after_seconds`. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies the delivery mode as `session-local`.
The original Session log owns each reminder. A live root Agent waits, retries after it becomes idle, and records a durable dispatch receipt in the Web conversation. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Merely reading cold history never activates it, and a fork does not inherit its parent's reminders.
Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. A reminder receipt likewise appears only after its dispatch is durable. Schedule does not provide browser, operating-system, email, SMS, or other external notification, and the best-effort model follow-up is not a delivery acknowledgement.
Absolute-time, fixed-interval, and cron rules are not accepted by this layer.
+17
View File
@@ -0,0 +1,17 @@
# 持久 Web Schedule
[English](README.md) | 中文
此 overlay 让一个 `dsh web` 进程显式启用持久 Schedule 提醒,同时不改变交付的默认 Web 组合:
```sh
dsh web --config examples/web-schedule/cordis.yml
```
当前 overlay 支持使用正整数 `after_seconds` 创建的一次性提醒。模型通过 `schedule_create``schedule_list``schedule_delete` 管理它们;每个结果都会把交付模式标为 `session-local`
每条提醒由原 Session 日志拥有。live 根 Agent 会等待,在恢复 idle 后重试,并在 Web 会话中记录持久 dispatch 回执。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。仅查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。
创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。提醒回执同样只在 dispatch 持久化后出现。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知,best-effort 模型 follow-up 也不构成交付确认。
本层不接受绝对时间、固定间隔或 cron 规则。
+10
View File
@@ -0,0 +1,10 @@
# Opt-in Schedule patch over the shipped Web composition. The Schedule owner
# only observes roots published after this overlay loads, so this remains an
# explicit capability rather than changing the default Web tree.
- insert:
- id: tool-schedule
name: '@deepseek-ai/dsh-tool-schedule'
- id: ui-schedule
name: '@deepseek-ai/dsh-client-ui-schedule'
+2 -1
View File
@@ -15,6 +15,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
| [`schedule/`](schedule/README.md) | Session-local reminders | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`e2b/`](e2b/README.md) | E2B providers | POC |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable surface |
@@ -55,7 +56,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
New packages join existing groups; new groups update their README and this table.
New packages join existing groups; new groups update this table.
## Dependencies
+2 -1
View File
@@ -15,6 +15,7 @@
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
| [`schedule/`](schedule/README.md) | 仅限 Session 内的提醒 | 产品:稳定接口 |
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定接口 |
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:Service Definition + 本地进程树提供方 | 产品:稳定接口 |
@@ -55,7 +56,7 @@
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
新包加入现有组;新组更新其 README 和此表。
新包加入现有组;新组更新此表。
## 依赖
+1
View File
@@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
| [`ui-schedule/`](ui-schedule/README.md) | Presents durable Schedule reminder receipts. |
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
+1
View File
@@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
| [`ui-schedule/`](ui-schedule/README.md) | 展示持久的 Schedule 提醒回执。 |
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
+8
View File
@@ -12,6 +12,12 @@ The node half guards every entry under `/api` before bridging or upgrading (`src
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
`SessionEventView` is an optional non-persistent sidecar on both `session.history` entries and live `session/event` frames. Tool views keep their closed call/result shapes; a presented durable event instead carries `{ for: 'event', presentationKey, view }`, leaving the key space and JSON-compatible payload open to domain plugins. The same Session event may be delivered again with a new or changed sidecar, so consumers merge it by exact event identity and seq rather than treating the second frame as another log append.
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
@@ -23,3 +29,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
- **Attached history may omit commit-aware event views** — when persistence inspection is unavailable, fails, or cannot prove an identity-matching prefix, the Host still serves raw live events and withholds only those sidecars. A later durable live redelivery or history read can add them.
- **Tool-specific view types remain transitional** — `ToolEventView`/`ToolCallView`/`ToolResultView` stay exported while the Host's tool `viewFor` presenter exists. The generic presented-event branch is independent and remains the domain-plugin extension shape.
+8
View File
@@ -12,6 +12,12 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
`/api/events.mux``/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
`SessionEventView``session.history` 条目与实时 `session/event` 帧上的可选、非持久 sidecar。工具 view 保持封闭的 callresult 形状;由 Host presentation 的持久事件则携带 `{ for: 'event', presentationKey, view }`,把 key 空间与兼容 JSON 的 payload 开放给领域插件。同一个 Session event 可以再次投递并带有新增或变化的 sidecar,因此消费方会按完全一致的事件身份与 seq 合并,而不会把第二个帧当作另一次日志 append。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
@@ -23,3 +29,5 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
## 已知限制与暂缓事项
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。
- **已附加 history 可能省略 commit-aware event view**:当 persistence inspect 不可用、失败或无法证明 identity-matching prefix 时,Host 仍会返回原始 live event,只会省略这些 sidecar。之后的持久 live 重投或 history 读取仍可补上它们。
- **工具专属 view 类型仍是过渡表面**:只要 Host 的工具 `viewFor` presenter 仍存在,`ToolEventView``ToolCallView``ToolResultView` 就继续导出。通用 presented-event 分支与此独立,并保持为领域插件的扩展形状。
+2 -1
View File
@@ -7,7 +7,8 @@
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PresentedEventView,
SessionEventView, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
@@ -15,7 +15,8 @@ import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PresentedEventView,
SessionEventView, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
+2
View File
@@ -40,6 +40,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by its `presentationKey`. The existing `liveBuffer` is the sole rendezvous during tail loading, gap stitching, and `loadOlder`. One merge path upgrades overlaps, consumes covered entries, and attaches only a contiguous suffix on every current-generation settlement, including rejected, empty, and discontinuous page responses. Reconnect advances the generation and clears its loading ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
## Request inspection
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
+2
View File
@@ -40,6 +40,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
一个 Session event 到达其 presentation 提交点后,Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar;通用 event view 会按 `presentationKey` 形成一个 `PresentedEventNode`。既有 `liveBuffer` 是尾部加载、gap stitching 与 `loadOlder` 期间唯一的汇合点。每个当前 generation 的结算出口都使用同一条 merge 路径升级窗口重叠项、消费已覆盖项,并只接入连续后缀;RPC 拒绝、空页和不连续页同样如此。重连会推进 generation 并清除其 loading 所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
+1 -1
View File
@@ -50,7 +50,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, PresentedEventNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
@@ -252,6 +252,23 @@ export interface CommandNode {
} | null
}
/**
* Host-computed presentation for one durable non-surface event. The generic
* runtime carries the keyed JSON-compatible payload without importing the
* producing domain; a client plugin owns the keyed renderer.
*/
export interface PresentedEventNode {
kind: 'presented-event'
/** Seq of the durable event whose sidecar produced this node. */
seq: number
/** Unix epoch ms from the source Session event. */
time: number
/** Open runtime key selecting an optional domain renderer. */
presentationKey: string
/** Domain-owned JSON-compatible presentation payload. */
view: unknown
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
@@ -262,6 +279,7 @@ export type ConversationNode =
| TurnErrorNode
| ToolResultNode
| CommandNode
| PresentedEventNode
| CompactionSummaryNode
| UnknownSurfaceNode
@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
RpcId, RpcResponse, RpcResult, SessionEventView, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -74,6 +74,30 @@ function queueTextOf(content: readonly ContentBlock[]): string | null {
return content.map(block => block.text).join('')
}
/** Browser-safe structural equality for JSON-compatible wire values. */
function sameWireValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
return left.every((value, index) => sameWireValue(value, right[index]))
}
const leftRecord = left as Record<string, unknown>
const rightRecord = right as Record<string, unknown>
const leftKeys = Object.keys(leftRecord).sort()
const rightKeys = Object.keys(rightRecord).sort()
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) =>
key === rightKeys[index] && sameWireValue(leftRecord[key], rightRecord[key]))
}
/** Same-seq deliveries may add a sidecar, but must carry the identical durable event. */
function assertSameEvent(left: SessionEvent, right: SessionEvent): void {
if (!sameWireValue(left, right)) {
throw new Error(`session event identity mismatch at seq ${left.seq}`)
}
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer. Features see only
@@ -85,7 +109,7 @@ export class Session implements SessionFace {
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
private views: (ToolEventView | undefined)[] = []
private views: (SessionEventView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -147,7 +171,7 @@ export class Session implements SessionFace {
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
private liveBuffer: { event: SessionEvent; view: SessionEventView | undefined }[] = []
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
@@ -367,10 +391,12 @@ export class Session implements SessionFace {
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
const generation = this.openGeneration
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -384,18 +410,30 @@ export class Session implements SessionFace {
this.hasMore = false
return
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
this.installWindow([
...older,
...this.events.map((event, index): HistoryEntry => {
const view = this.views[index]
return view === undefined ? { event } : { event, view }
}),
], result.value.hasMore)
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
if (generation === this.openGeneration) {
console.error('[web-runtime] loadOlder failed:', error)
}
} finally {
this.loadingOlder = false
this.notifier.markDirty()
if (generation === this.openGeneration) {
try {
const { hasGap } = this.mergeWindow()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- resync can close the window while the page request is awaited.
if (hasGap && this.openState === 'open') void this.repairGap()
} catch (error) {
console.error('[web-runtime] loadOlder buffer merge failed:', error)
void this.resync()
}
this.loadingOlder = false
this.notifier.markDirty()
}
}
}
@@ -423,6 +461,8 @@ export class Session implements SessionFace {
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.loadingOlder = false
this.stitching = false
this.notifier.markDirty()
await this.open()
}
@@ -627,7 +667,9 @@ export class Session implements SessionFace {
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
const { hasGap } = this.mergeWindow()
this.openState = 'open'
if (hasGap) void this.repairGap()
} catch (error) {
if (generation !== this.openGeneration) return
this.openState = 'error'
@@ -639,29 +681,132 @@ export class Session implements SessionFace {
}
}
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
/**
* Install one history window and settle every buffered overlap or safe
* contiguous suffix through {@link mergeWindow}. A carried projections
* block seeds the value store (higher seq wins, so a stale baseline cannot
* overwrite a newer push frame); the window events themselves are never
* folded — the host is the only computation site.
*/
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.mergeWindow(entries)
this.hasMore = hasMore
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
this.notifier.markDirty()
}
/**
* Reconcile a history snapshot (when supplied), the current window, and
* buffered live deliveries by seq. Same-seq events must be identical;
* defined late sidecars upgrade but an absent sidecar never erases an
* existing one. Only the contiguous suffix joins the window, leaving a real
* gap buffered for the existing repair path.
* @param entries - replacement/prepended history window, or undefined to
* settle the current window after an RPC failure or empty page.
* @returns whether the visible window changed and whether a true gap remains.
*/
private mergeWindow(entries?: readonly HistoryEntry[]): { changed: boolean; hasGap: boolean } {
const current = new Map<number, { event: SessionEvent; view: SessionEventView | undefined }>()
for (let index = 0; index < this.events.length; index++) {
const event = this.events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event !== undefined) current.set(event.seq, { event, view: this.views[index] })
}
const events: SessionEvent[] = []
const views: (SessionEventView | undefined)[] = []
if (entries === undefined) {
events.push(...this.events)
views.push(...this.views)
} else {
let previousSeq: number | undefined
for (const entry of entries) {
if (previousSeq !== undefined && entry.event.seq !== previousSeq + 1) {
throw new Error(`history window is not contiguous at seq ${entry.event.seq}`)
}
previousSeq = entry.event.seq
const retained = current.get(entry.event.seq)
if (retained !== undefined) assertSameEvent(retained.event, entry.event)
events.push(entry.event)
views.push(entry.view ?? retained?.view)
}
}
const buffered = new Map<number, { event: SessionEvent; view: SessionEventView | undefined }>()
for (const item of this.liveBuffer) {
const retained = buffered.get(item.event.seq)
if (retained !== undefined) {
assertSameEvent(retained.event, item.event)
if (item.view !== undefined) retained.view = item.view
} else {
buffered.set(item.event.seq, { ...item })
}
}
const bySeq = new Map<number, number>()
for (let index = 0; index < events.length; index++) {
const event = events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event !== undefined) bySeq.set(event.seq, index)
}
const consumed = new Set<number>()
let viewChanged = false
const baseSeq = events[0]?.seq
const tailSeq = events.at(-1)?.seq
for (const [seq, item] of buffered) {
const index = bySeq.get(seq)
if (index !== undefined) {
const event = events[index]
/* v8 ignore next -- bySeq indexes the dense events array. */
if (event === undefined) continue
assertSameEvent(event, item.event)
if (item.view !== undefined && !sameWireValue(views[index], item.view)) {
views[index] = item.view
viewChanged = true
}
consumed.add(seq)
continue
}
// A replay older than the retained tail window is irrelevant to this
// page and cannot become a future suffix.
if (baseSeq !== undefined && seq < baseSeq) {
consumed.add(seq)
continue
}
if (tailSeq !== undefined && seq <= tailSeq) {
throw new Error(`history window is missing buffered seq ${seq}`)
}
}
const appended: SessionEvent[] = []
let expectedSeq = tailSeq === undefined ? 0 : tailSeq + 1
for (let item = buffered.get(expectedSeq); item !== undefined; item = buffered.get(++expectedSeq)) {
events.push(item.event)
views.push(item.view)
appended.push(item.event)
consumed.add(expectedSeq)
}
const remaining = [...buffered.entries()]
.filter(([seq]) => !consumed.has(seq))
.sort(([left], [right]) => left - right)
.map(([, item]) => item)
this.liveBuffer = remaining
const changed = entries !== undefined || viewChanged || appended.length > 0
if (changed) {
this.events = events
this.views = views
this.baseSeq = events[0]?.seq ?? 0
this.transcript.reset(events, views)
this.rebuildDerivedFromWindow()
for (const event of appended) this.handoffPendingSteering(event)
}
return { changed, hasGap: remaining.length > 0 }
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: ToolEventView): void {
private appendLive(event: SessionEvent, view?: SessionEventView): void {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
@@ -687,18 +832,34 @@ export class Session implements SessionFace {
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its cited summary event. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
return
}
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) {
this.liveBuffer.push({ event, view })
try {
const { changed } = this.mergeWindow()
if (changed) this.notifier.markDirty()
} catch (error) {
console.error('[web-runtime] duplicate session event failed identity validation:', error)
void this.resync()
}
return
}
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
void this.repairGap()
return
}
if (tailSeq === null && event.seq !== 0) {
this.liveBuffer.push({ event, view })
void this.repairGap()
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
@@ -717,20 +878,33 @@ export class Session implements SessionFace {
const generation = this.openGeneration
try {
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
if (generation !== this.openGeneration || this.openState !== 'open') return
if (result.ok) {
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
} else {
this.mergeWindow()
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
if (generation === this.openGeneration) {
console.error('[web-runtime] gap repair failed:', error)
try {
this.mergeWindow()
} catch (mergeError) {
console.error('[web-runtime] gap repair buffer merge failed:', mergeError)
void this.resync()
}
}
} finally {
this.stitching = false
if (generation === this.openGeneration) {
this.stitching = false
this.notifier.markDirty()
}
}
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
private applyEventSideEffects(event: SessionEvent, view?: SessionEventView): void {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)
@@ -19,7 +19,9 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type {
PresentedEventView, SessionEventView, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
@@ -48,7 +50,7 @@ interface CallIndexEntry {
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
/** One ordinary surface event -> UI node. */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
@@ -117,6 +119,17 @@ function materializeNode(
}
}
/** One host-presented non-surface event -> generic keyed conversation node. */
function materializePresented(event: SessionEvent, sidecar: PresentedEventView): ConversationNode {
return {
kind: 'presented-event',
seq: event.seq,
time: event.time,
presentationKey: sidecar.presentationKey,
view: sidecar.view,
}
}
/**
* Whether an event is a landed compaction checkpoint — all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
@@ -255,7 +268,7 @@ export class TranscriptAdapter {
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
reset(events: readonly SessionEvent[], views?: readonly (SessionEventView | undefined)[]): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
@@ -277,8 +290,13 @@ export class TranscriptAdapter {
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
for (let index = 0; index < events.length; index++) {
const event = events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event === undefined) continue
const view = views?.[index]
if (view?.for === 'event') projected.push(materializePresented(event, view))
else if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
}
this.projected = projected
}
@@ -292,12 +310,17 @@ export class TranscriptAdapter {
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
append(event: SessionEvent, view?: SessionEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
const steering = this.steeringHistory.apply(event)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (view?.for === 'event') {
this.projected = [...this.projected, materializePresented(event, view)]
this.rev++
return
}
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
@@ -392,7 +415,7 @@ export class TranscriptAdapter {
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
private indexCall(event: SessionEvent, view?: SessionEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
+238 -7
View File
@@ -33,6 +33,23 @@ function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
function logRange(start: number, end: number, label = 'fixture/log'): SessionEvent[] {
return Array.from({ length: end - start }, (_value, offset) =>
at(start + offset, { type: label, data: { index: start + offset } }))
}
function reminderEvent(seq: number, id: string): SessionEvent {
return at(seq, { type: 'schedule/change', data: { version: 1, operation: 'dispatch', id } })
}
function reminderView(id: string, prompt = '检查日志') {
return {
for: 'event' as const,
presentationKey: 'schedule/reminder',
view: { id, prompt },
}
}
describe('open', () => {
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
@@ -82,9 +99,9 @@ describe('open', () => {
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: page[5]! })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({
events: entries(page) as never[],
@@ -98,6 +115,117 @@ describe('open', () => {
})
})
describe('late event views', () => {
it('upgrades an already-open raw event without duplicating it or letting an absent sidecar erase it', async () => {
const { api, session } = makeSession()
const event = reminderEvent(0, 'schedule-1')
api.onHistory = () => histResponse([event])
await session.open()
expect(session.getSnapshot().nodes).toEqual([])
session.handleMuxEnvelope('rv1' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
session.handleMuxEnvelope('rv2' as never, {
type: 'session/event', sessionId: SID, event,
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', view: { prompt: '检查日志' },
}])
session.handleMuxEnvelope('rv3' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1', '检查发布'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', view: { prompt: '检查发布' },
}])
})
it('merges a view delivered while the tail history is loading', async () => {
const { api, session } = makeSession()
const event = reminderEvent(0, 'schedule-loading')
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
session.handleMuxEnvelope('rv' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-loading'),
})
gate.resolve(ok({ events: [{ event }] as never[], hasMore: false }))
await opening
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, view: { id: 'schedule-loading' },
}])
})
it('merges a late view buffered behind a gap repair snapshot', async () => {
const { api, session } = makeSession()
const first = logRange(0, 6)
api.onHistory = () => histResponse(first)
await session.open()
const due = reminderEvent(9, 'schedule-gap')
const full = [...first, ...logRange(6, 9), due]
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
session.handleMuxEnvelope('raw' as never, {
type: 'session/event', sessionId: SID, event: due,
})
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: due, view: reminderView('schedule-gap'),
})
gate.resolve(ok({ events: entries(full) as never[], hasMore: false }))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 9, view: { id: 'schedule-gap' },
}])
})
})
it.each(['success', 'rejection', 'empty', 'discontinuous'] as const)(
'settles a late overlap after loadOlder %s',
async (outcome) => {
const { api, session } = makeSession()
const newer = logRange(6, 12)
const target = newer[3]!
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView(`schedule-${outcome}`),
})
if (outcome === 'success') {
gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
} else if (outcome === 'rejection') {
gate.resolve(err({ code: 'internal', message: 'page rejected', details: {} }))
} else if (outcome === 'empty') {
gate.resolve(ok({ events: [], hasMore: false }))
} else {
gate.resolve(ok({ events: entries(logRange(0, 2)) as never[], hasMore: true }))
}
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: `schedule-${outcome}` },
}])
} finally {
errorSpy.mockRestore()
}
},
)
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
@@ -548,11 +676,12 @@ describe('live event path', () => {
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
const first = plainTurn(0, 0, 'a', 'b')
const { api, session } = await opened(first) // tail seq = 5
const repaired = [...first, ...plainTurn(6, 1, 'c', 'd')]
api.onHistory = () => histResponse(repaired)
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') })
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: repaired[9]! })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
@@ -883,11 +1012,12 @@ describe('remaining branches', () => {
it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
const { api, session } = makeSession()
const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
const first = plainTurn(0, 0, 'a', 'b')
const full = [...first, ...plainTurn(6, 1, 'c', 'd')]
let call = 0
api.onHistory = () => {
call++
return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full)
return histResponse(call === 1 ? first : full)
}
// Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once.
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
@@ -1175,6 +1305,107 @@ describe('resync', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
it('a stale loadOlder success and finally cannot mutate or clear a fresh-generation page request', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const staleLoad = session.loadOlder()
api.onHistory = () => histResponse(logRange(12, 18), true)
await session.resync()
const fresh = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => fresh.promise
const freshLoad = session.loadOlder()
expect(session.getSnapshot()).toMatchObject({ loadingOlder: true, hasMore: true })
stale.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
await staleLoad
expect(session.getSnapshot()).toMatchObject({ loadingOlder: true, hasMore: true })
fresh.resolve(ok({ events: entries(logRange(6, 12)) as never[], hasMore: false }))
await freshLoad
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: false })
})
it('a stale rejected or never-settled loadOlder cannot freeze the new generation', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const staleLoad = session.loadOlder()
api.onHistory = () => histResponse(logRange(12, 18), false)
await session.resync()
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
stale.reject(new Error('old page connection closed'))
await staleLoad
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
const never = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
// Re-open a pageable generation and park a request that never settles.
api.onHistory = () => histResponse(logRange(18, 24), true)
await session.resync()
api.onHistory = () => never.promise
void session.loadOlder()
expect(session.getSnapshot().loadingOlder).toBe(true)
api.onHistory = () => histResponse(logRange(24, 30), false)
await session.resync()
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
})
it('stale gap success, rejection, and finally cannot clear a fresh repair owner', async () => {
const { api, session } = makeSession()
const initial = logRange(0, 6)
api.onHistory = () => histResponse(initial)
await session.open()
const staleRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => staleRepair.promise
session.handleMuxEnvelope('old-gap' as never, {
type: 'session/event', sessionId: SID, event: reminderEvent(9, 'old-gap'),
})
const freshBase = logRange(10, 16)
api.onHistory = () => histResponse(freshBase)
await session.resync()
const freshRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let freshRepairCalls = 0
api.onHistory = () => {
freshRepairCalls++
return freshRepair.promise
}
const due = reminderEvent(18, 'fresh-gap')
session.handleMuxEnvelope('fresh-gap' as never, {
type: 'session/event', sessionId: SID, event: due, view: reminderView('fresh-gap'),
})
expect(freshRepairCalls).toBe(1)
staleRepair.reject(new Error('stale gap connection closed'))
await Promise.resolve()
await Promise.resolve()
const trailing = at(19, { type: 'fixture/log', data: { index: 19 } })
session.handleMuxEnvelope('fresh-trailing' as never, {
type: 'session/event', sessionId: SID, event: trailing,
})
expect(freshRepairCalls).toBe(1)
freshRepair.resolve(ok({
events: entries([...freshBase, ...logRange(16, 18), due, trailing]) as never[],
hasMore: false,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 18, view: { id: 'fresh-gap' },
}])
})
})
})
describe('nested run_code sub-dispatches', () => {
@@ -434,6 +434,34 @@ describe('TranscriptAdapter', () => {
})
})
it('materializes generic presented-event nodes on replay and live append', () => {
const replayed = at(0, { type: 'schedule/change', data: { operation: 'dispatch', id: 'schedule-1' } })
const live = at(1, { type: 'schedule/change', data: { operation: 'dispatch', id: 'schedule-2' } })
const adapter = new TranscriptAdapter()
adapter.reset([replayed], [{
for: 'event',
presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
adapter.append(live, {
for: 'event',
presentationKey: 'schedule/reminder',
view: { id: 'schedule-2', prompt: '检查发布' },
})
expect(adapter.nodes()).toEqual([
{
kind: 'presented-event', seq: 0, time: 1_700_000_000_000,
presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
},
{
kind: 'presented-event', seq: 1, time: 1_700_000_000_001,
presentationKey: 'schedule/reminder',
view: { id: 'schedule-2', prompt: '检查发布' },
},
])
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new TranscriptAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
@@ -24,6 +24,8 @@ The chat view keeps Tool placement but delegates Tool presentation. It passes ea
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
Host-presented durable events use the keyed `'conversation.chat.eventview'` seat alongside whole-Tool presentation. The React-free runtime turns a generic `{ presentationKey, view }` sidecar into a `PresentedEventNode`; Chat dispatches on that open key, and a domain UI plugin may register its own row without adding domain vocabulary here. When no registrant is loaded, `GenericEventCard` keeps the presentation key and JSON payload visible in an expandable disclosure rather than dropping the durable event.
`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md).
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
@@ -24,6 +24,8 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
由 Host presentation 的持久事件使用键控的 `'conversation.chat.eventview'` 座位,与整体 Tool presentation 并行。无 React 的 runtime 会把通用 `{ presentationKey, view }` sidecar 转为 `PresentedEventNode`;Chat 按开放 key 分发,领域 UI 插件无需在本包增加领域词汇即可注册自己的行。没有 registrant 被加载时,`GenericEventCard` 会在可展开 disclosure 中保留可见的 presentation key 与 JSON payload,而不会丢弃该持久事件。
`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` Tool 行属于 [`ui-tool`](../ui-tool/README.md)。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
@@ -306,6 +306,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.chat.tool': { kind: 'single', scope: 'session' },
'conversation.chat.eventview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
},
@@ -24,7 +24,7 @@ import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
CommandNode, ConversationNode, ConversationSnapshot, PresentedEventNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -33,6 +33,7 @@ import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnS
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericEventCard } from './GenericEventCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
@@ -211,6 +212,24 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }:
)
})
/** One Host-presented durable event: keyed dispatch on its open presentation
* key, with a visible JSON disclosure when no domain renderer is loaded. */
const EventRow = memo(function EventRow({ renderSlot, node, t }: {
renderSlot: RenderChatSlot
node: PresentedEventNode
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.eventview', owner, {
entryKey: node.presentationKey,
fallback: <GenericEventCard {...owner} t={t} />,
})}
</div>
)
})
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus({ startTime, t }: {
/** The running turn's logged `turn/start` time; null falls back to mount
@@ -544,6 +563,9 @@ export function ChatView({
if (node.kind === 'command') {
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
if (node.kind === 'presented-event') {
return <EventRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (
@@ -0,0 +1,34 @@
// GenericEventCard: the visible fallback for a Host-presented durable event.
// A domain plugin may replace it through the keyed eventview slot; without
// one, the presentation key and JSON sidecar remain inspectable in the flow.
import { useMemo, useState } from 'react'
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, EventRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css'
/** Card props: the event owner payload plus the render site's locale seat. */
export interface GenericEventCardProps extends EventRowOwnerProps {
t: ChatViewSlotProps['t']
}
/** Render an unregistered event presentation as a visible JSON disclosure. */
export function GenericEventCard({ node, t }: GenericEventCardProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => open ? JSON.stringify(node.view, null, 2) : '', [node.view, open])
return (
<DisclosureRow
className={css.root}
icon={<IconSparkle16 size={14} />}
chevronClassName={css.chevron}
title={t('message.presentedEvent', { key: node.presentationKey })}
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-presented-event-body>{body}</pre>
</DisclosureRow>
)
}
@@ -3,7 +3,10 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction,
PendingWait, PresentedEventNode, SessionId, ToolCallBlock, WorkspaceId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
@@ -38,6 +41,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* {@link ToolTreeOwnerProps} for every root and child wrapper.
*/
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
/**
* The chat view's per-event presentation hole: keyed dispatch on the
* Host-provided presentation key. The durable event remains in the
* runtime node; a feature plugin may replace the visible JSON fallback
* with a domain renderer without entering ui-conversation.
*/
'conversation.chat.eventview': { kind: 'keyed'; scope: 'session'; owner: EventRowOwnerProps }
/**
* The chat view's per-command row hole: keyed dispatch on the command
* name (`command/run.name`; a run-less cross-window node has none and
@@ -239,6 +249,15 @@ export interface DetailsToolOwnerProps {
cwd?: string | undefined
}
/** Owner share for one Host-presented durable event. */
export interface EventRowOwnerProps {
/** Generic runtime node carrying the durable event identity and keyed sidecar. */
node: PresentedEventNode
}
/** Full props of a registered event-presentation row component. */
export type EventRowProps = PropsRuntime<'conversation.chat.eventview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
@@ -553,9 +572,10 @@ export interface ChatViewInjected {
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
/** Full chat-view component props: runtime plus Tool, event, command, and turn-tail render shares. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
PropsRuntime<'conversation.view'>
& PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.eventview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**
@@ -17,7 +17,7 @@ export type {
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
ToolTreeOwnerProps, TurnTailOwnerProps,
EventRowOwnerProps, EventRowProps, ToolTreeOwnerProps, TurnTailOwnerProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
@@ -68,6 +68,7 @@ export const zh = {
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.presentedEvent': '事件:{key}',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.added': '已新增',
@@ -211,6 +212,7 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.presentedEvent': 'Event: {key}',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.added': 'added',
@@ -53,7 +53,7 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => {
it('registers the chat view as the first ring entry with Tool and event seats', async () => {
const b = await bench()
const entries = b.slots.entries('conversation.view')
expect(entries.map(e => e.options.id)).toEqual(['chat'])
@@ -63,6 +63,7 @@ describe('apply wiring', () => {
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' })
expect(b.slots.spec('conversation.chat.eventview')).toEqual({ kind: 'keyed', scope: 'session' })
await b.runtime.dispose()
})
@@ -110,6 +111,8 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.tool')).toBeUndefined()
expect(b.slots.entries('conversation.chat.eventview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.eventview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
@@ -8,7 +8,7 @@ import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
ModelRetryNode, PresentedEventNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -106,6 +106,13 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
shadowedTokenCount: 11_309,
...over,
})
const presentedEvent = (seq: number): PresentedEventNode => ({
kind: 'presented-event',
seq,
time: seq * 1_000,
presentationKey: 'schedule/reminder',
view: { prompt: 'check logs', scheduleId: 'schedule-1' },
})
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
@@ -953,6 +960,21 @@ describe('ChatView', () => {
expect(calls[0]?.entryKey).toBeUndefined()
})
it('dispatches presented events by key and keeps a visible JSON fallback', () => {
const node = presentedEvent(3)
const h = makeHarness({ nodes: [node] })
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
})
const view = render(<h.ChatView {...h.props} />)
expect(calls).toEqual([{ key: 'conversation.chat.eventview', entryKey: 'schedule/reminder' }])
fireEvent.click(view.getByText('事件:schedule/reminder'))
expect(view.getByText(/"prompt": "check logs"/)).toBeTruthy()
expect(view.getByText(/"scheduleId": "schedule-1"/)).toBeTruthy()
})
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
README.md: 7a37baf92c6e050e628d97d5926b08bae295137b
README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309
+20
View File
@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-schedule
English | [中文](README.zh.md)
Browser-only renderer for durable Schedule reminder receipts. The plugin registers the `schedule/reminder` key in the conversation-owned `conversation.chat.eventview` slot. The generic runtime continues to carry the durable event identity and its Host-computed JSON sidecar; this package owns only the Schedule card.
The card displays the reminder prompt, Session-local Schedule ID, exact UTC occurrence, and the `session-local` delivery boundary. A malformed or incompatible sidecar remains visible as a contained unavailable receipt instead of crashing the conversation. Unloading the plugin removes only the keyed renderer; `ui-conversation` then shows its generic visible JSON fallback for the same durable event.
## Model Experience
None, as this browser-only renderer registers no model surface; Schedule tools and reminder framing belong to `@deepseek-ai/dsh-tool-schedule`.
#### KV Cache effect
None. The renderer consumes a browser-side presentation sidecar after the durable event is committed.
## Known Limitations and Deferred Work
- **Receipt-only UI** — creating, listing, and deleting reminders remains model-driven through the Schedule tools; this package does not add a management page.
- **Session-local delivery** — the card records a receipt in the original Session. It does not imply a system, browser, email, or other external notification.
+20
View File
@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-schedule
[English](README.md) | 中文
用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册 `schedule/reminder` key。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar;本包只拥有 Schedule 卡片。
卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer;`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。
## 模型体验
无,因为这个纯浏览器 renderer 不注册模型 surfaceSchedule 工具与提醒 framing 由 `@deepseek-ai/dsh-tool-schedule` 拥有。
#### KV Cache 影响
无。renderer 只在持久事件提交后消费浏览器侧 presentation sidecar。
## 已知限制与暂缓事项
- **仅提供回执 UI**:创建、列出和删除提醒仍由模型通过 Schedule 工具完成;本包不增加管理页面。
- **仅在 Session 内交付**:卡片记录的是原 Session 中的回执,并不表示系统、浏览器、邮件或其他外部通知。
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@deepseek-ai/dsh-client-ui-schedule",
"description": "Web renderer for durable Schedule reminder receipts in the conversation flow",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}
@@ -0,0 +1,63 @@
.root {
display: grid;
min-width: 0;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
}
.header {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.icon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-brand-text);
}
.title {
min-width: 0;
flex: 1;
font: 600 13px/18px var(--ds-font-family);
}
.delivery {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family);
}
.prompt {
margin: 0;
color: var(--dsw-alias-label-primary);
font: 400 14px/21px var(--ds-font-family);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.meta {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 4px 12px;
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family);
}
.id {
font-family: var(--ds-font-family-code);
overflow-wrap: anywhere;
}
.invalid {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: 400 13px/18px var(--ds-font-family);
}
@@ -0,0 +1,61 @@
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { EventRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './ReminderRow.module.css'
interface ReminderPresentation {
scheduleId: string
prompt: string
occurrenceAt: string
deliveryMode: 'session-local'
}
/** Full Schedule row props: event owner/runtime share plus the locale seat. */
export type ReminderRowProps = EventRowProps & PropsLocale<'schedule'>
/** Narrow the domain-owned JSON sidecar without trusting its unknown carrier type. */
function reminderPresentation(value: unknown): ReminderPresentation | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (typeof record['scheduleId'] !== 'string' || record['scheduleId'].length === 0) return null
if (typeof record['prompt'] !== 'string') return null
if (typeof record['occurrenceAt'] !== 'string' || record['occurrenceAt'].length === 0) return null
if (record['deliveryMode'] !== 'session-local') return null
return {
scheduleId: record['scheduleId'],
prompt: record['prompt'],
occurrenceAt: record['occurrenceAt'],
deliveryMode: record['deliveryMode'],
}
}
/**
* Render one durable reminder dispatch carried by the generic event sidecar.
* @param props - Keyed event owner payload and the Schedule translator.
* @returns A visible reminder receipt, or a contained invalid-payload row.
*/
export function ReminderRow({ node, t }: ReminderRowProps) {
const reminder = reminderPresentation(node.view)
return (
<section className={css.root} role="note" data-schedule-reminder>
<header className={css.header}>
<span className={css.icon} aria-hidden><IconSparkle16 size={14} /></span>
<span className={css.title}>{t('reminder.title')}</span>
{reminder !== null && <span className={css.delivery}>{t('reminder.delivery')}</span>}
</header>
{reminder === null
? <p className={css.invalid}>{t('reminder.invalid')} · {node.presentationKey}</p>
: (
<>
<p className={css.prompt}>{reminder.prompt}</p>
<footer className={css.meta}>
<span className={css.id}>{t('reminder.id', { id: reminder.scheduleId })}</span>
<time dateTime={reminder.occurrenceAt}>
{t('reminder.occurrence', { time: reminder.occurrenceAt })}
</time>
</footer>
</>
)}
</section>
)
}
@@ -0,0 +1,38 @@
/** Register the Schedule durable-reminder renderer into the conversation event slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ReminderRow } from './ReminderRow.tsx'
import { en, NS, zh, type ScheduleKey } from './locales.ts'
export type { ReminderRowProps } from './ReminderRow.tsx'
export type { ScheduleKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Copy for durable Schedule reminder receipts. */
schedule: ScheduleKey
}
}
/**
* `conversation` is an ordering edge: its service is published after the chat
* entry has declared `conversation.chat.eventview`.
*/
export const inject = ['slots', 'conversation', 'locale']
/**
* Register bilingual copy and the Schedule reminder keyed row.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries')
ctx.effect(
() => ctx.slots.register({
name: 'conversation.chat.eventview',
key: 'schedule/reminder',
locale: NS,
}, ReminderRow),
'ui-schedule: reminder row registration',
)
}
@@ -0,0 +1,25 @@
/** `schedule` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'schedule'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'reminder.title': '定时提醒',
'reminder.delivery': '仅在当前会话中交付',
'reminder.invalid': '提醒回执不可用',
'reminder.id': '编号 {id}',
'reminder.occurrence': '触发时间 {time}',
} satisfies Record<string, string>
/** The Schedule namespace key union. */
export type ScheduleKey = keyof typeof zh
/** English dictionary, checked complete against the Chinese key set. */
export const en = {
'reminder.title': 'Scheduled reminder',
'reminder.delivery': 'Delivered in this session only',
'reminder.invalid': 'Reminder receipt unavailable',
'reminder.id': 'ID {id}',
'reminder.occurrence': 'Due at {time}',
} satisfies Record<ScheduleKey, string>
+4
View File
@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}
+4
View File
@@ -0,0 +1,4 @@
/** Host loader entry for the browser-only Schedule receipt renderer. */
/** Provides no host-side behavior. */
export function apply(): void {}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-schedule`.
* @module @deepseek-ai/dsh-client-ui-schedule/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-schedule'
/** Cordis companion plugin name. */
export const name = 'client-ui-schedule-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the keyed slot registry owns contribution lifecycle,
* and the component has no state outside its immutable owner payload.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,75 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { ReminderRow } from '../src/client/ReminderRow.tsx'
import { apply as nodeApply } from '../src/index.ts'
import {
apply as invariantApply,
inject as invariantInject,
name as invariantName,
} from '../src/invariant.ts'
interface CapturedEntry {
name: string
key?: string
locale?: string
component: unknown
}
function bench() {
const ctx = new Context()
let entry: CapturedEntry | undefined
ctx.provide('slots', {
register(options: Omit<CapturedEntry, 'component'>, component: unknown) {
entry = { ...options, component }
return () => { entry = undefined }
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, entry: () => entry }
}
describe('ui-schedule browser plugin', () => {
it('registers the keyed reminder renderer and unloads it with the fiber', async () => {
const b = bench()
await b.fiber.await()
expect(b.entry()).toEqual({
name: 'conversation.chat.eventview',
key: 'schedule/reminder',
locale: 'schedule',
component: ReminderRow,
})
await b.fiber.dispose()
expect(b.entry()).toBeUndefined()
})
})
describe('ui-schedule node and invariant companions', () => {
it('keeps the node half inert', () => {
expect(() => { nodeApply() }).not.toThrow()
})
it('registers exact package ownership and returns its disposer', async () => {
const ctx = new Context()
let owner: string | undefined
let disposed = false
ctx.provide('invariants', {
register(packageName: string, install: unknown) {
expect(install).toBeTypeOf('function')
owner = packageName
return () => { disposed = true }
},
})
expect(invariantName).toBe('client-ui-schedule-invariant')
expect(invariantInject).toEqual(['invariants'])
const dispose = await invariantApply(ctx)
expect(owner).toBe('@deepseek-ai/dsh-client-ui-schedule')
dispose()
expect(disposed).toBe(true)
})
})
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { PresentedEventNode } from '@deepseek-ai/dsh-client-runtime/client'
import { ReminderRow, type ReminderRowProps } from '../src/client/ReminderRow.tsx'
import { zh } from '../src/client/locales.ts'
const t: ReminderRowProps['t'] = makeTranslate(zh)
afterEach(cleanup)
function props(view: unknown): ReminderRowProps {
const node: PresentedEventNode = {
kind: 'presented-event',
seq: 4,
time: Date.parse('2026-08-05T08:00:00.000Z'),
presentationKey: 'schedule/reminder',
view,
}
return { node, t } as ReminderRowProps
}
describe('ReminderRow', () => {
it('shows the durable reminder payload and its session-local boundary', () => {
render(<ReminderRow {...props({
scheduleId: 'schedule-7',
prompt: 'Check the deploy',
occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
})} />)
expect(screen.getByRole('note')).toBeTruthy()
expect(screen.getByText('定时提醒')).toBeTruthy()
expect(screen.getByText('仅在当前会话中交付')).toBeTruthy()
expect(screen.getByText('Check the deploy')).toBeTruthy()
expect(screen.getByText('编号 schedule-7')).toBeTruthy()
const time = screen.getByText('触发时间 2026-08-05T08:00:00.000Z')
expect(time.getAttribute('datetime')).toBe('2026-08-05T08:00:00.000Z')
})
it('contains an incompatible sidecar as a visible unavailable receipt', () => {
render(<ReminderRow {...props({
scheduleId: '',
prompt: 'not trusted',
occurrenceAt: 123,
deliveryMode: 'external',
})} />)
expect(screen.getByText('提醒回执不可用 · schedule/reminder')).toBeTruthy()
expect(screen.queryByText('not trusted')).toBeNull()
expect(screen.queryByText('仅在当前会话中交付')).toBeNull()
})
})
+33
View File
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js', 'lib/types/invariant.js'])
@@ -26,6 +26,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'session/disposed': null,
'session/event': null,
'session/flush': null,
'session/flushed': null,
'subagent/end': null,
'subagent/start': null,
'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
+3 -2
View File
@@ -12,8 +12,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `origin`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches an awaited parallel checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; observe-only listeners return void, while a persistence listener returns literal `true` only after completing durability work. A fully successful checkpoint with at least one such acknowledgement returns `true` and emits contained `session/flushed(session, throughSeq)` with the exclusive event boundary captured at entry; no durability acknowledgement returns `false`, and unpublished, detached, or stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
+3 -2
View File
@@ -12,8 +12,9 @@
### 公共 API
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt``seedLength``delegationDepth`
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动调用会等待全部结算后才报告失败未发布、已脱离陈旧对象会被拒绝。
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt``seedLength``origin``delegationDepth`
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行检查点。每个监听器都会启动调用会等待全部结算后才报告失败;仅观察的监听器返回 void,持久化监听器只有在完成持久化工作后才返回字面量 `true`。全部成功且至少有一个此类确认时,调用返回 `true`,并发布受包含的 `session/flushed(session, throughSeq)`,其中 `throughSeq` 是入口处捕获的事件排他边界;没有持久化确认时返回 `false`未发布、已脱离陈旧对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
+41 -8
View File
@@ -95,14 +95,30 @@ declare module 'cordis' {
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited parallel durability checkpoint: every listener runs and the
* caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* Awaited parallel checkpoint: every listener runs and the caller awaits
* all of them, with no waterfall veto. A listener returns literal `true`
* only after completing durability work; observe-only listeners return
* void. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the
* session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
* @dshScopeScan unsupported
* @mode parallel
*/
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
'session/flush'(this: Scoped<Session>, session: Session): Promise<true | void> | true | void
/**
* Observe a successful durability checkpoint. `throughSeq` is the exclusive
* event boundary captured when {@link SessionStore.flush} began; events
* appended while its listeners run require a later successful checkpoint.
* No notification is published when no durability listener participated or
* any listener failed. Observer failures are logged and contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's
* owner scope.
* @param session - the session whose prefix completed the checkpoint.
* @param throughSeq - exclusive event sequence boundary proven by the checkpoint.
* @dshScopeScan unsupported
* @mode emit
*/
'session/flushed'(this: Scoped<Session>, session: Session, throughSeq: number): void
}
}
@@ -396,7 +412,7 @@ function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
function invokeContainedSessionObservers(
ctx: Context,
name: 'session/event' | 'session/disposed',
name: 'session/event' | 'session/disposed' | 'session/flushed',
id: SessionId,
args: unknown[],
callbacks: SessionCallback[],
@@ -1029,12 +1045,13 @@ export class SessionStore extends Service {
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
* one spelling, and the scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns whether at least one durability listener participated, after every
* listener has settled successfully.
* @returns whether at least one listener acknowledged completed durability,
* after every listener has settled successfully.
* @throws the first registered listener failure after every listener settles.
*/
async flush(session: Session): Promise<boolean> {
const { carrier } = this.liveEntryFor(session)
const throughSeq = session.seq
const callbackArgs: unknown[] = [session]
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
const results = await Promise.allSettled(callbacks.map((callback) => {
@@ -1049,7 +1066,23 @@ export class SessionStore extends Service {
}))
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
return callbacks.length > 0
const durable = results.some(result => result.status === 'fulfilled' && result.value === true)
if (durable) {
const flushedArgs: unknown[] = [session, throughSeq]
const observers = collectSessionCallbacks(this.ctx, [
carrier,
'session/flushed',
...flushedArgs,
])
invokeContainedSessionObservers(
this.ctx,
'session/flushed',
session.id,
flushedArgs,
observers,
)
}
return durable
}
/** Return the exact live entry; detached/prepared objects reject. */
+89 -2
View File
@@ -83,19 +83,41 @@ describe('sessions.flush()', () => {
it('allows an ordinary flush with no listeners', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const flushed: number[] = []
ctx.on('session/flushed', (_current, throughSeq) => { flushed.push(throughSeq) })
await expect(ctx.sessions.flush(session)).resolves.toBe(false)
expect(flushed).toEqual([])
})
it('reports a participating listener after it succeeds', async () => {
it('reports a durability listener after it acknowledges success', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const flushed: Session[] = []
ctx.on('session/flush', current => void flushed.push(current))
const checkpoints: number[] = []
ctx.on('session/flush', (current) => {
flushed.push(current)
return true as const
})
ctx.on('session/flushed', (_current, throughSeq) => { checkpoints.push(throughSeq) })
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
expect(flushed).toEqual([session])
expect(checkpoints).toEqual([0])
})
it('does not treat an observe-only flush listener as durability', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const observed: Session[] = []
const checkpoints: number[] = []
ctx.on('session/flush', current => void observed.push(current))
ctx.on('session/flushed', (_current, throughSeq) => { checkpoints.push(throughSeq) })
await expect(ctx.sessions.flush(session)).resolves.toBe(false)
expect(observed).toEqual([session])
expect(checkpoints).toEqual([])
})
it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
@@ -121,9 +143,13 @@ describe('sessions.flush()', () => {
it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
const ctx = await mount()
const checkpoints: number[] = []
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
ctx.on('session/flush', () => true)
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
const session = ctx.sessions.create()
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
expect(checkpoints).toEqual([])
})
it('does not let a synchronous flush failure starve later listeners', async () => {
@@ -160,6 +186,67 @@ describe('sessions.flush()', () => {
expect(settled).toBe(true)
})
it('publishes the entry prefix while a concurrent suffix waits for a later checkpoint', async () => {
const ctx = await mount()
const gate = Promise.withResolvers<undefined>()
let attempts = 0
ctx.on('session/flush', async () => {
attempts += 1
if (attempts === 1) await gate.promise
return true as const
})
const checkpoints: number[] = []
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = ctx.sessions.flush(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
gate.resolve(undefined)
await first
await ctx.sessions.flush(session)
expect(checkpoints).toEqual([1, 2])
})
it('contains successful-checkpoint observers without reversing the barrier', async () => {
const ctx = await mount()
const checkpoints: number[] = []
ctx.on('session/flush', () => true)
ctx.on('session/flushed', () => { throw new Error('observer failed') })
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
const session = ctx.sessions.create()
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
expect(checkpoints).toEqual([0])
})
it('may publish overlapping checkpoints out of order without widening either boundary', async () => {
const ctx = await mount()
const firstGate = Promise.withResolvers<undefined>()
const secondGate = Promise.withResolvers<undefined>()
const gates = [firstGate, secondGate]
ctx.on('session/flush', async () => {
const gate = gates.shift()
if (gate === undefined) throw new Error('unexpected checkpoint attempt')
await gate.promise
return true as const
})
const checkpoints: number[] = []
ctx.on('session/flushed', (_session, throughSeq) => { checkpoints.push(throughSeq) })
const session = ctx.sessions.create()
const first = ctx.sessions.flush(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const second = ctx.sessions.flush(session)
secondGate.resolve(undefined)
await second
firstGate.resolve(undefined)
await first
expect(checkpoints).toEqual([1, 0])
})
it('rejects a never-entered session instead of inventing a carrier', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
+2
View File
@@ -26,6 +26,8 @@ Question responses are validated against their pending request before the first
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
An optional `SessionEventView` is a non-persistent presentation sidecar. Tool calls/results keep their existing Host presenters. A Schedule dispatch remains raw on append; after an acknowledged `session/flushed(session, throughSeq)`, the gateway advances an exact-Session `WeakMap` cursor with `max`, derives newly covered receipts through the Schedule package, and redelivers the identical event with `{ for: 'event', presentationKey: 'schedule/reminder', view }`. Reversed flush completion cannot move the cursor backward or duplicate a receipt. Attached history adds these views only within a persistence-inspected prefix whose header and every event match the live identity; unavailable, failed, or mismatched inspection serves raw history without the sidecar. Detached history is already a persisted prefix.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
+3 -1
View File
@@ -26,7 +26,9 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供
可选的 `SessionEventView` 是非持久 presentation sidecar。工具 callresult 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw;收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor,通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', presentationKey: 'schedule/reminder', view }` 重投完全相同的事件。反序完成的 flush 不能让 cursor 后退或重复回执。已附加 history 只会在 persistence inspect 得到的前缀内添加这些 view,而且该前缀的 header 与每个 event 都必须和 live identity 一致;inspect 不可用、失败或不匹配时,仍会返回 raw history,只省略 sidecar。已分离 history 本身已经是持久前缀
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
+1
View File
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-tool-schedule": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+121 -7
View File
@@ -6,6 +6,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import type { Context } from 'cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -30,8 +31,9 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
ModelReasoning, MuxFrame, PresentedEventView, QuestionResponsePayload, SessionEventView,
QueuedInboxItem, SessionProjectionsBlock, SessionSearchItem, SessionSummary, SettingsNamespaceView,
SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
@@ -58,6 +60,10 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import {
SCHEDULE_REMINDER_PRESENTATION_KEY,
scheduleReminderPresentation,
} from '@deepseek-ai/dsh-tool-schedule'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
@@ -465,6 +471,28 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
return undefined
}
/**
* Derive one Schedule-owned event sidecar without allowing corrupt domain data
* to break raw event delivery. `seedLength` selects the parent-prefix or
* child-suffix ownership segment inside the package helper.
*/
function scheduleViewFor(
ctx: Context,
header: SessionHeader,
events: readonly SessionEvent[],
event: SessionEvent,
): PresentedEventView | undefined {
try {
const view = scheduleReminderPresentation(events, event.seq, header.seedLength ?? 0)
return view === undefined
? undefined
: { for: 'event', presentationKey: SCHEDULE_REMINDER_PRESENTATION_KEY, view }
} catch (error: unknown) {
ctx.logger.warn(`api-proxy: Schedule presentation failed at seq ${event.seq}; serving raw event: ${String(error)}`)
return undefined
}
}
/**
* Resolve a tool/result's call pairing by scanning a window of events backwards
* for the matching tool/call. Used by the history path (the page is the
@@ -493,17 +521,49 @@ function historyPage(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
presentation?: { header: SessionHeader; throughSeq: number },
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
const toolView = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
const eventView = presentation !== undefined && event.seq < presentation.throughSeq
? scheduleViewFor(ctx, presentation.header, events, event)
: undefined
const view: SessionEventView | undefined = toolView ?? eventView
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
}
}
/**
* Prove the exclusive durable prefix of one attached Session against a
* detached persistence inspection. The header and every stored event must
* match the live identity; absent top-level `delegationDepth` is the persisted
* format's canonical zero. A divergent or impossible suffix proves nothing
* and therefore returns zero.
*/
function identityMatchingStoredPrefix(
session: Pick<Session, 'header'>,
liveEvents: readonly SessionEvent[],
stored: { meta: SessionHeader; events: readonly SessionEvent[] },
): number {
const liveIdentity = {
...session.header,
delegationDepth: session.header.delegationDepth ?? 0,
}
const storedIdentity = {
...stored.meta,
delegationDepth: stored.meta.delegationDepth ?? 0,
}
if (!isDeepStrictEqual(storedIdentity, liveIdentity) || stored.events.length > liveEvents.length) return 0
for (let index = 0; index < stored.events.length; index += 1) {
if (!isDeepStrictEqual(stored.events[index], liveEvents[index])) return 0
}
return stored.events.length
}
/**
* The projection baseline for one history tail page: the registry's
* watermark-cache snapshot — one fully synchronous read (no await between the
@@ -745,6 +805,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Commit-aware event presentation cursor keyed by exact live Session identity. */
const presentedThrough = new WeakMap<Session, number>()
/**
* Install or return the session-local model selection that prompt assembly snapshots.
@@ -810,6 +872,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
// Raw append delivery remains unchanged. A successful durability checkpoint
// later replays only newly covered Schedule dispatches with their sidecar;
// exact-Session identity and max advancement contain id reuse and reversed
// concurrent flush completion without creating another durable state owner.
ctx.on('session/flushed', (session, throughSeq) => {
const previous = presentedThrough.get(session) ?? 0
if (throughSeq <= previous) return
presentedThrough.set(session, throughSeq)
for (let seq = previous; seq < throughSeq; seq += 1) {
const event = session.events[seq]
if (event === undefined) {
throw new Error(`api-proxy: flushed prefix for "${session.id}" is missing event seq ${seq}`)
}
const view = scheduleViewFor(ctx, session.header, session.events, event)
if (view === undefined) continue
broadcast({ type: 'session/event', sessionId: session.id, event, view })
}
})
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the Service Definition package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
@@ -1023,17 +1104,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
): Promise<{
header: SessionHeader
events: SessionEvent[]
presentedThroughSeq: number
projections?: SessionProjectionsBlock
}> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { events, ...projections === undefined ? {} : { projections } }
let presentedThroughSeq = 0
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
try {
const stored = await persistence.inspect(sessionId)
presentedThroughSeq = identityMatchingStoredPrefix(attached, events, stored)
} catch (error: unknown) {
// Attached history remains available from the live Session. A
// failed or not-yet-materialized inspection only withholds
// commit-gated event presentation sidecars.
ctx.logger.warn(`session.history: persistence inspection for attached "${sessionId}" failed; serving raw events: ${String(error)}`)
}
}
return {
header: attached.header,
events,
presentedThroughSeq,
...projections === undefined ? {} : { projections },
}
}
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
presentedThroughSeq: inspected.events.length,
...projections === undefined ? {} : { projections },
}
}
@@ -1611,7 +1717,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
let state: {
header: SessionHeader
events: SessionEvent[]
presentedThroughSeq: number
projections?: SessionProjectionsBlock
}
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
} catch (error: unknown) {
@@ -1624,7 +1735,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, {
header: state.header,
throughSeq: state.presentedThroughSeq,
})
return ok(request, {
events: page.events,
hasMore: page.hasMore,
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionEventViewSchema, sessionIdSchema,
} from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
@@ -40,7 +40,7 @@ const messageSchema = z.object({
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: sessionEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
+16 -1
View File
@@ -32,6 +32,21 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/**
* Host-computed presentation for one non-surface Session event. The domain
* owns the presentation key and JSON-compatible view shape; the carrier keeps
* both generic so an opt-in client plugin can render the event without adding
* domain vocabulary to the connection package.
*/
export interface PresentedEventView {
for: 'event'
presentationKey: string
view: unknown
}
/** Optional non-persistent presentation sidecar for one Session event. */
export type SessionEventView = ToolEventView | PresentedEventView
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
export interface QueuedInboxItem {
/** Message identity used by inbox mutations. */
@@ -66,7 +81,7 @@ export interface EventsApi {
* approval/question frames (requested = answerable server-request, the rest are pure pushes).
*/
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: SessionEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
+4 -1
View File
@@ -48,7 +48,10 @@ export type {
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type {
EventsApi, HostFrame, MuxFrame, PresentedEventView, QueuedInboxItem,
SessionEventView, ToolCallView, ToolEventView, ToolResultView,
} from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
@@ -14,7 +14,7 @@ import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { SessionEventView, ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -193,10 +193,26 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
/** Domain-owned presented-event sidecar with a carrier-validated key and present payload. */
const presentedEventViewSchema = z.object({
for: z.literal('event'),
presentationKey: z.string().min(1),
view: z.unknown(),
}).refine(value => Object.hasOwn(value, 'view'), {
message: 'presented event view payload is required',
path: ['view'],
})
/** Any optional host-computed sidecar carried with a Session event. */
export const sessionEventViewSchema = z.union([
toolEventViewSchema,
presentedEventViewSchema,
]) as unknown as z.ZodType<SessionEventView>
/** One session.history item: the session event plus its optional host-computed view. */
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
view: sessionEventViewSchema.optional(),
}) as unknown as z.ZodType<Wire<HistoryEntry>>
/**
+2 -2
View File
@@ -11,7 +11,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// cordis Context merge (via dsh-agent) must not enter client aggregates.
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { SessionEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-llm' {
@@ -33,7 +33,7 @@ declare module '@deepseek-ai/dsh-llm' {
*/
export interface HistoryEntry {
event: SessionEvent
view?: ToolEventView
view?: SessionEventView
}
/**
@@ -0,0 +1,220 @@
/**
* Schedule reminder views cross the Host only after persistence proves their
* dispatch prefix. Live append sends raw events; session/flushed replays the
* identical dispatch with a generic sidecar. History independently gates the
* same projection on an identity-matching stored prefix.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule'
interface FlushControl {
handler: () => true | Promise<true>
}
async function harness(control?: FlushControl): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
if (control !== undefined) ctx.on('session/flush', () => control.handler())
return ctx
}
function appendReminder(
session: Session,
id: string,
prompt: string,
): { create: SessionEvent; dispatch: SessionEvent } {
const scheduleId = ScheduleId(id)
const create = session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: scheduleId,
kind: 'after',
prompt,
afterSeconds: 1,
scheduledAt: '2026-08-05T12:00:01.000Z',
},
})
const dispatch = session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: scheduleId,
})
return { create, dispatch }
}
async function collectEvents(
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
count: number,
abort: AbortController,
): Promise<Extract<MuxFrame, { type: 'session/event' }>[]> {
const events: Extract<MuxFrame, { type: 'session/event' }>[] = []
for await (const envelope of iterable) {
if (envelope.payload.type !== 'session/event') continue
events.push(envelope.payload)
if (events.length >= count) abort.abort()
}
return events
}
describe('commit-aware Schedule live views', () => {
it('takes the max of reverse flush completion and replays each dispatch once', async () => {
const first = Promise.withResolvers<true>()
let calls = 0
const ctx = await harness({
handler: () => ++calls === 1 ? first.promise : true,
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const collected = collectEvents(
api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
6,
abort,
)
const session = ctx.sessions.create(SessionId('schedule-live'))
const firstPair = appendReminder(session, 'schedule-1', 'first')
const slow = ctx.sessions.flush(session)
const secondPair = appendReminder(session, 'schedule-2', 'second')
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
first.resolve(true)
await expect(slow).resolves.toBe(true)
const frames = await collected
const raw = frames.filter(frame => frame.view === undefined)
const presented = frames.filter(frame => frame.view?.for === 'event')
expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3])
expect(presented.map(frame => frame.event.seq)).toEqual([1, 3])
expect(presented[0]?.event).toBe(firstPair.dispatch)
expect(presented[1]?.event).toBe(secondPair.dispatch)
expect(presented.map(frame => frame.view)).toEqual([
{
for: 'event',
presentationKey: 'schedule/reminder',
view: {
scheduleId: 'schedule-1', prompt: 'first',
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
},
},
{
for: 'event',
presentationKey: 'schedule/reminder',
view: {
scheduleId: 'schedule-2', prompt: 'second',
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
},
},
])
expect(firstPair.create.seq).toBe(0)
await ctx.fiber.dispose()
})
it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => {
let calls = 0
const ctx = await harness({
handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const collected = collectEvents(
api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
3,
abort,
)
const session = ctx.sessions.create(SessionId('schedule-retry'))
appendReminder(session, 'schedule-1', 'retry me')
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable')
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
const frames = await collected
expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
expect(frames.at(-1)?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
})
await ctx.fiber.dispose()
})
})
describe('Schedule history views', () => {
it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => {
const ctx = await harness()
const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } })
appendReminder(parent, 'parent-reminder', 'from parent')
const session = ctx.sessions.create(SessionId('schedule-attached'), {
seed: [...parent.events],
meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
})
let inspect = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
meta: session.header,
events: [...session.events.slice(0, 1)],
})
ctx.provide('sessionPersistence', {
inspect: () => inspect(),
} as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const history = async () => {
const response = await api.sessions.history({
rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
return response.result.value.events
}
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
inspect = () => Promise.resolve({
meta: { ...session.header, delegationDepth: 0 },
events: [...session.events.slice(0, 2)],
})
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
})
inspect = () => Promise.resolve({
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
events: [...session.events.slice(0, 2)],
})
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
inspect = () => Promise.reject(new Error('inspect unavailable'))
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
await ctx.fiber.dispose()
})
it('presents every dispatch in detached persisted history', async () => {
const ctx = await harness()
let source: Session | undefined
const owner = await ctx.plugin(Object.assign((inner: Context) => {
source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } })
}, { inject: ['sessions'] }))
if (source === undefined) throw new Error('session owner did not publish its session')
appendReminder(source, 'schedule-1', 'cold reminder')
const meta = source.header
const events = [...source.events]
await owner.dispose()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history({
rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
})
if (!response.result.ok) throw new Error(response.result.error.message)
expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
for: 'event', presentationKey: 'schedule/reminder',
})
await ctx.fiber.dispose()
})
})
@@ -149,7 +149,8 @@ describe('mux live view computation', () => {
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
const diffView = byCall.get('tool/call:c-diff')?.view
expect(diffView?.for === 'call' ? diffView.view.card : undefined).toBe('diff')
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
for: 'call',
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
@@ -194,6 +194,25 @@ describe('sessions domain schemas', () => {
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
const presented = {
event: { type: 'schedule/change', seq: 2, time: 3, data: { operation: 'dispatch' } },
view: {
for: 'event',
presentationKey: 'schedule/reminder',
view: { scheduleId: 'schedule-1' },
},
}
const parsedHistory = sessionHistoryValueSchema.parse({ events: [presented], hasMore: false })
expect(parsedHistory.events?.at(0)?.view).toEqual(presented.view)
for (const view of [
{ for: 'event', presentationKey: '', view: {} },
{ for: 'event', presentationKey: 'schedule/reminder' },
]) {
expect(() => sessionHistoryValueSchema.parse({
events: [{ event: presented.event, view }],
hasMore: false,
})).toThrow()
}
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
@@ -420,6 +439,11 @@ describe('events frame schemas', () => {
it('accepts every mux frame branch', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{
type: 'session/event', sessionId: 's',
event: { type: 'schedule/change', seq: 1, time: 2, data: { operation: 'dispatch' } },
view: { for: 'event', presentationKey: 'schedule/reminder', view: null },
},
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
+3
View File
@@ -59,6 +59,9 @@
{
"path": "../../session/session-title"
},
{
"path": "../../schedule/tool-schedule"
},
{
"path": "../../session-query/session-query"
},
+11
View File
@@ -0,0 +1,11 @@
# AGENTS.md — Schedule packages
These rules supplement the repository and package instructions for `packages/schedule/*`.
- The owning Session's versioned `schedule/change` stream is the only durable Schedule state. Folds validate every durable JSON boundary and derive active records; timers, waiters, admission reservations, presentation cursors, and tool values remain disposable projections.
- A normal Session folds its complete log. A fork derives active Schedule state only from events at or after `SessionHeader.seedLength`; it never inherits an active parent reminder.
- Every Schedule management operation that reads or decides from the fold first awaits `ctx.sessions.flush(session)`. Create and an actual delete await a second barrier after append; a failed barrier returns the stable uncertainty result instead of inferring durability from the live log.
- Runtime owners attach only to future live root Agents while the plugin is loaded. They do not scan persisted Sessions, adopt already-published roots, wake cold Sessions, register global tools, or delete durable records during teardown.
- Due handling rechecks the wall clock and exact live owner, reserves turn admission through the public Agent seam, constructs the complete escaped framing before `followup()`, appends dispatch only after synchronous enqueue returns, releases the reservation in `finally`, and then awaits durability. A synchronous framing/enqueue failure appends no dispatch; a later model failure does not roll one back.
- Rule math and durable transition logic stay pure and deterministic. Production uses the platform wall clock and segmented timers; tests supply explicit samples or fake timers without adding a production clock service.
- Host and browser presentation is derived from a durability-proven event prefix. Domain view construction belongs to Schedule, generic transport and keyed fallback belong to the Host/client runtime, and the Schedule card belongs to its separate client plugin.
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/README.md
README.md: 1f21dd03d71d00e08a167efabd676dc5319f9671
README.zh.md: ab56383cd8b00001db83120d41e4bcd292a10f04
+11
View File
@@ -0,0 +1,11 @@
# schedule/ — durable Session-local reminders
English | [中文](README.zh.md)
The Schedule family owns reminders whose durable state and delivery receipt live in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel.
| Package | Role | ctx key |
|---|---|---|
| `tool-schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, live root-Agent timer owner, and pure reminder presentation | — |
The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream, while Web presentation and the browser renderer consume derived, durability-proven views.
+11
View File
@@ -0,0 +1,11 @@
# schedule/:持久、仅限 Session 内的提醒
[English](README.md) | 中文
Schedule 家族负责把持久状态与交付回执保存在原 Session 日志中的提醒。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但不会表示存在外部通知渠道。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `tool-schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建/列出/删除工具、live 根 Agent timer owner,以及纯提醒 presentation | 无 |
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;Web presentation 与浏览器 renderer 则消费由已证明持久的前缀派生出的 view。
@@ -1,2 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/schedule/tool-schedule/README.md
README.md: 55842c3cb49c43b5c577835a26ef43e6ad452dfd
README.zh.md: 8738ac6b4516a1933b206b6baee5bb3d7d77d23a
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
@@ -34,6 +34,7 @@ function renderThrown(value: unknown): string {
/** One process-local, disposable projection of an exact agent's durable schedules. */
export class ScheduleOwner {
private readonly stop = Promise.withResolvers<void>()
private timer: ReturnType<typeof setTimeout> | undefined
private idleWait: Promise<void> | undefined
private run: Promise<void> | undefined
@@ -91,6 +92,7 @@ export class ScheduleOwner {
this.stopping = true
this.requested = false
this.clearTimer()
this.stop.resolve()
const pending = [this.run, this.idleWait].filter((value): value is Promise<void> => value !== undefined)
await Promise.allSettled(pending)
})())
@@ -138,7 +140,7 @@ export class ScheduleOwner {
/** Await one public idle boundary without holding admission or creating a retry timer. */
private waitForIdle(): void {
if (this.idleWait !== undefined) return
const wait = this.agent.whenIdle()
const wait = Promise.race([this.agent.whenIdle(), this.stop.promise])
this.idleWait = wait
void wait.then(
() => {
@@ -0,0 +1,153 @@
/** Production JSONL restart evidence through the real Agent resume lifecycle. */
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as toolSchedule from '../src/index.ts'
import {
ScheduleId,
createAfterScheduleRecord,
foldScheduleEvents,
scheduleReminderPresentation,
} from '../src/domain.ts'
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const response: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'Reminder acknowledged.' } },
{ type: 'finish', reason: { kind: 'stop' } },
]
for (const chunk of response) yield chunk
}
}
async function mountPersistence(root: string): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
return ctx
}
async function mountRuntime(root: string, adapter: RecordingAdapter): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
ctx.llm.registerAdapter(['mock'], adapter)
await ctx.plugin(toolSchedule)
return ctx
}
async function disposeContext(ctx: Context): Promise<void> {
const index = contexts.indexOf(ctx)
if (index >= 0) contexts.splice(index, 1)
await ctx.fiber.dispose()
}
function waitForDispatch(ctx: Context, sessionId: SessionId): Promise<void> {
return new Promise((resolve) => {
const stop = ctx.on('session/event', (session, event) => {
if (session.id !== sessionId
|| event.type !== 'schedule/change'
|| event.data.operation !== 'dispatch') return
stop()
resolve()
})
})
}
async function settleCurrentTasks(): Promise<void> {
await new Promise<void>(resolve => setImmediate(resolve))
}
describe('Schedule production JSONL restart', () => {
it('resumes one overdue reminder exactly once across fresh runtime mounts', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-jsonl-'))
roots.push(root)
const sessionId = SessionId('schedule-jsonl-restart')
const first = await mountPersistence(root)
const pending = first.sessions.create(sessionId, { meta: { cwd: '/tmp' } })
const pendingRecord = createAfterScheduleRecord(
ScheduleId('schedule-1'), 'restart reminder', 1, Date.now() - 60_000,
)
pending.append('schedule/change', { version: 1, operation: 'create', schedule: pendingRecord })
await expect(first.sessions.flush(pending)).resolves.toBe(true)
await disposeContext(first)
const dispatchingAdapter = new RecordingAdapter()
const restarted = await mountRuntime(root, dispatchingAdapter)
const dispatched = waitForDispatch(restarted, sessionId)
const handle = await restarted.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await dispatched
await handle.agent.whenIdle()
await expect(restarted.sessions.flush(handle.agent.session)).resolves.toBe(true)
const dispatchedStored = await restarted.sessionPersistence.inspect(sessionId)
expect(foldScheduleEvents(dispatchedStored.events, dispatchedStored.meta.seedLength ?? 0).active)
.toEqual([])
const dispatches = dispatchedStored.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')
expect(dispatches).toHaveLength(1)
const dispatch = dispatches[0]
if (dispatch?.type !== 'schedule/change' || dispatch.data.operation !== 'dispatch') {
throw new Error('missing durable Schedule dispatch')
}
expect(scheduleReminderPresentation(
dispatchedStored.events,
dispatch.seq,
dispatchedStored.meta.seedLength ?? 0,
)).toEqual({
scheduleId: 'schedule-1',
prompt: 'restart reminder',
occurrenceAt: pendingRecord.scheduledAt,
deliveryMode: 'session-local',
})
expect(dispatchingAdapter.requests).toHaveLength(1)
await handle.dispose()
await disposeContext(restarted)
const replayAdapter = new RecordingAdapter()
const replayed = await mountRuntime(root, replayAdapter)
const replayHandle = await replayed.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await replayed.sessions.flush(replayHandle.agent.session)
await replayHandle.agent.whenIdle()
await settleCurrentTasks()
await replayed.sessions.flush(replayHandle.agent.session)
expect(replayAdapter.requests).toEqual([])
expect(replayHandle.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1)
const replayedStored = await replayed.sessionPersistence.inspect(sessionId)
expect(replayedStored.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1)
await replayHandle.dispose()
await disposeContext(replayed)
})
})
@@ -410,6 +410,30 @@ describe('Schedule runtime failure and teardown boundaries', () => {
await departedOwner.dispose()
})
it('stops an idle wait during dispose even if the agent never becomes idle', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
test.controls.canReserve = false
const owner = ownerFor(test)
owner.start()
await settle()
expect(test.controls.whenIdleCount).toBe(1)
let disposed = false
const disposal = owner.dispose().then(() => { disposed = true })
await settle()
try {
expect(disposed).toBe(true)
} finally {
test.controls.idle.resolve(undefined)
await disposal
}
await settle()
expect(test.followed).toEqual([])
expect(test.agent.session.events.filter(event =>
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([])
})
it('faults on corrupt or unreadable durable state after preflight', async () => {
const corrupt = await harness()
Object.defineProperty(corrupt.agent.session, 'events', {
@@ -32,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/invariants"
}
@@ -31,7 +31,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
`PersistenceCoordinator` owns per-id state and serialization, one bounded write controller per live session, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its stateful methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [flush-controller simplification](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md), and [bounded batching decision](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md).
Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure.
Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs, then returns the Session Store's literal `true` durability acknowledgement. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure.
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
@@ -31,7 +31,7 @@
`PersistenceCoordinator` 负责每 id 状态和串行化、每个活动会话各自的有界写入 controller、延迟实体化、崩溃尾部修复、会话接管和完全停稳的 dispose(资源释放)。第一方后端组合一个协调器,实现小型 `PersistenceBackend` 存储钩子接口,并委托其有状态方法。因此 JSONL 和 SQLite 共享生命周期正确性,同时保留不同存储原语;见[协调器 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)、[flush controller 简化](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md)和[有界批处理决策](../../../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)。
每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。
每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件,然后返回 Session Store 所需的字面量 `true` 持久化确认。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。
崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
@@ -1038,8 +1038,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
live.writes.enqueue(event)
})
// Callers use flush as the immediate durability barrier for buffered writes.
ctx.on('session/flush', session => this.flush(session))
// A completed bounded drain acknowledges the caller's durability barrier.
ctx.on('session/flush', async (session) => {
await this.flush(session)
return true as const
})
// Session disposal is observe-only, so retirement contains its own failure.
ctx.on('session/disposed', (session) => { this.retire(session) })
@@ -1089,8 +1092,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
writes: this.createWriteBehind(session, () => live.init),
}
this.live.set(session, live)
live.init = this.serialize(session.header.id, () => this.onCreated(session, seed))
live.init.catch(() => { /* observed by flush/dispose through the controller */ })
void this.ensureInitialized(session, live).catch(() => {
/* observed by flush/dispose through the controller or retried by a later barrier */
})
return live
}
@@ -1151,8 +1155,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const tracked = this.states.get(id)
if (tracked !== undefined) {
// case 1: already tracked.
/* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
if (tracked.owner === session) return
if (tracked.owner === session) {
await this.reconcileOwnedSeed(session, seed, tracked)
return
}
if (tracked.owner === undefined) {
// Ownerless state from the public create()/load() API. The FIRST live
// session claims it — but ONLY if BOTH the cwd scope and the seed match.
@@ -1205,6 +1211,43 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (seed.length > 0) await this.appendCore(id, seed)
}
/**
* Reconcile a retrying live owner with the backend's actual durable cursor.
* An initialization write may have committed before its promise rejected, so
* retry from storage rather than from the coordinator's last acknowledged
* cursor. This also completes a suffix whose first attempt never committed.
*/
private async reconcileOwnedSeed(
session: Session,
seed: readonly SessionEvent[],
tracked: SessionState,
): Promise<void> {
const stored = await this.backend.loadStored(session.header.id)
if (stored === undefined) {
if (tracked.materialized || tracked.cursor !== 0) {
throw new Error(`session "${session.header.id}" lost its persisted artifact during live initialization`)
}
if (seed.length > 0) await this.appendCore(session.header.id, seed)
return
}
const { meta, events, tornMarker } = stored
this.assertStoredId(session.header.id, meta)
if (meta.cwd !== session.header.cwd) {
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
this.assertVersion(meta)
const storedEvents = snapshotStoredEvents(events, session.header.id)
if (!seedCoversPrefix(seed, storedEvents)) {
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
}
if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, [])
tracked.meta = { ...meta }
tracked.cursor = storedEvents.length
tracked.materialized = true
const suffix = seed.slice(storedEvents.length)
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
}
/**
* Adopt a stored prefix as a live session's history (HMR/reload): verify the
* seed covers the stored prefix, truncate any torn tail (NOT the open turn
@@ -182,6 +182,7 @@ class ControlledBackend implements PersistenceBackend<never> {
loadAttempts = 0
repairAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
afterAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
/** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */
seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredSuffix | undefined>
@@ -218,6 +219,7 @@ class ControlledBackend implements PersistenceBackend<never> {
} else {
entry.events.push(...structuredClone(events) as SessionEvent[])
}
await this.afterAppend?.(attempt)
}
async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise<void> {
@@ -373,6 +375,130 @@ describe('PersistenceCoordinator bounded writes', () => {
})
})
describe('PersistenceCoordinator retryable live initialization', () => {
it('retries a rejected first storage read for a new empty session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const loadGate = Promise.withResolvers<undefined>()
const retryGate = Promise.withResolvers<undefined>()
backend.beforeLoadStored = async (attempt) => {
if (attempt === 1) {
await loadGate.promise
throw new Error('transient init read failure')
}
if (attempt === 2) await retryGate.promise
}
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const session = ctx.sessions.create(SessionId('retry-new-empty'))
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
const first = ctx.sessions.flush(session)
loadGate.resolve(undefined)
await expect(first).rejects.toThrow('transient init read failure')
const retries = [ctx.sessions.flush(session), ctx.sessions.flush(session)]
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(2) })
retryGate.resolve(undefined)
await expect(Promise.all(retries)).resolves.toEqual([true, true])
// The one shared retry performs the normal new-session probe and
// createCore's collision recheck; a second initialization would add two
// more reads.
expect(backend.loadAttempts).toBe(3)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1])
const live = [...(coordinator as unknown as CoordinatorInternals).live.values()][0]
expect(live).toMatchObject({ seedEnd: 0, initialized: true })
expect(live).not.toHaveProperty('seed')
} finally {
loadGate.resolve(undefined)
retryGate.resolve(undefined)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('uses the backend cursor when a fork seed committed before initialization rejected', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const appendGate = Promise.withResolvers<undefined>()
backend.afterAppend = async (attempt) => {
if (attempt === 1) {
await appendGate.promise
throw new Error('uncertain init write')
}
}
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const seed = oneTurnLog()
const session = ctx.sessions.create(SessionId('retry-fork-seed'), {
seed,
meta: { cwd: '/w', seedLength: seed.length },
})
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
const first = ctx.sessions.flush(session)
appendGate.resolve(undefined)
await expect(first).rejects.toThrow('uncertain init write')
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
expect(backend.appendAttempts).toBe(1)
expect(backend.store.get(session.id)?.events.map(event => event.seq))
.toEqual([0, 1, 2, 3, 4, 5, 6])
} finally {
appendGate.resolve(undefined)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('retries only a missing suffix after stored-session adoption rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('retry-resume-adoption')
const stored = oneTurnLog()
backend.store.set(id, { meta: meta(id, '/w'), events: structuredClone(stored) })
const appendGate = Promise.withResolvers<undefined>()
backend.beforeAppend = async (attempt) => {
if (attempt === 1) {
await appendGate.promise
throw new Error('transient adoption write failure')
}
}
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const session = ctx.sessions.create(id, { seed: stored, meta: { cwd: '/w' } })
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
const first = ctx.sessions.flush(session)
appendGate.resolve(undefined)
await expect(first).rejects.toThrow('transient adoption write failure')
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
expect(backend.appendAttempts).toBe(2)
expect(backend.store.get(id)?.events.map(event => event.seq))
.toEqual([0, 1, 2, 3, 4, 5, 6])
} finally {
appendGate.resolve(undefined)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
})
describe('PersistenceCoordinator stored identity', () => {
it('rejects a mismatched backend header before repair or state publication', async () => {
const ctx = new Context()
+26 -2
View File
@@ -48,6 +48,7 @@ import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
import PtyService from '@deepseek-ai/dsh-pty'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
import * as ToolSchedule from '@deepseek-ai/dsh-tool-schedule'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -89,15 +90,18 @@ const catalogChildScopes = new WeakMap<Context, Agent>()
* schema harvest, without starting a model, Agent loop, or persistence backend.
* @param ctx - catalog context owning the scope.
* @param mountScoped - package installer for the scoped context.
* @param key - agent-like scope key exposed to the package's scope selector.
* @param inject - services the package installer must await before mounting.
*/
async function mountCatalogChildScope(
ctx: Context,
mountScoped: (childCtx: Context) => void,
key: Agent = { id: SessionId('tool-catalog-child') } as Agent,
inject: string[] = ['tools', 'systemPrompt', 'subagents'],
): Promise<void> {
const key = { id: SessionId('tool-catalog-child') } as Agent
await ctx.plugin(Object.assign((inner: Context) => {
mountScoped(createScope(inner, key).ctx)
}, { inject: ['tools', 'systemPrompt', 'subagents'] }))
}, { inject }))
catalogChildScopes.set(ctx, key)
}
@@ -321,6 +325,26 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
},
{
pkg: '@deepseek-ai/dsh-tool-schedule',
dir: 'tool-schedule',
source: 'packages/schedule/tool-schedule/src/tools.ts',
requires: ['ctx.tools', 'ctx.sessions', 'Session persistence', 'a future live root Agent'],
writes: ['tool/call', 'schedule/change create or delete', 'tool/result'],
async mount(ctx) {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('tool-catalog-schedule'))
const agent = { id: session.id, session } as Agent
await mountCatalogChildScope(ctx, (childCtx) => {
ToolSchedule.registerScheduleTools(ctx, childCtx, agent, () => {})
}, agent, ['tools', 'systemPrompt'])
},
scope: ctx => catalogChildScopes.get(ctx) as Agent,
note:
'Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. '
+ 'Version 1 accepts positive safe-integer after_seconds and discloses session-local delivery; '
+ 'management reads and mutations require the shared Session persistence barrier.',
},
{
pkg: '@deepseek-ai/dsh-tool-lsp',
dir: 'tool-lsp',
+1
View File
@@ -33,6 +33,7 @@ const root = resolve(import.meta.dirname, '..')
// specifiers resolve from apps/cli rather than the examples workspace.
const appOverlayFiles = new Set([
'examples/web-cordis/cordis.yml',
'examples/web-schedule/cordis.yml',
...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }),
])
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
@@ -67,6 +67,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-only Schedule receipt renderer; registers no model surface.' },
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
+3
View File
@@ -94,6 +94,7 @@
"./packages/context/*/src/invariant.ts",
"./packages/goal/*/src/invariant.ts",
"./packages/feedback/*/src/invariant.ts",
"./packages/schedule/*/src/invariant.ts",
"./packages/guard/*/src/invariant.ts",
"./packages/plan/*/src/invariant.ts",
"./packages/subagent/*/src/invariant.ts",
@@ -163,6 +164,7 @@
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
"@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"],
"@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"],
"@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"],
"@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"],
"@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"],
"@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"],
@@ -202,6 +204,7 @@
"./packages/context/*/src",
"./packages/goal/*/src",
"./packages/feedback/*/src",
"./packages/schedule/*/src",
"./packages/guard/*/src",
"./packages/plan/*/src",
"./packages/subagent/*/src",

Some files were not shown because too many files have changed in this diff Show More