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

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

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 和此表。
新包加入现有组;新组更新此表。
## 依赖

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. |

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) | 提供会话感知的命令发现与分发。 |

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.

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 分支与此独立,并保持为领域插件的扩展形状。

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,

View File

@@ -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,

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.

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 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。

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'

View File

@@ -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

View File

@@ -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)

View File

@@ -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

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', () => {

View File

@@ -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: '孤儿' } }

View File

@@ -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.

View File

@@ -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 缺席即隐藏 chipchip 打开 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 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。

View File

@@ -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' },
},

View File

@@ -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 (

View File

@@ -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>
)
}

View File

@@ -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'>
/**

View File

@@ -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.

View File

@@ -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',

View File

@@ -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()

View File

@@ -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} />)

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/client/ui-schedule/README.md
README.md: 7a37baf92c6e050e628d97d5926b08bae295137b
README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309

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.

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 中的回执,并不表示系统、浏览器、邮件或其他外部通知。

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"
]
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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',
)
}

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

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 {}

View File

@@ -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 */

View File

@@ -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)
})
})

View File

@@ -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()
})
})

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"
}
]
}

View File

@@ -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'])

View File

@@ -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'],

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[]`

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[]`

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. */

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')

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`.

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`

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:^",

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,

View File

@@ -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')]) }),

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 }

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'

View File

@@ -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>>
/**

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
}
/**

View File

@@ -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()
})
})

View File

@@ -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' },

View File

@@ -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' },

View File

@@ -59,6 +59,9 @@
{
"path": "../../session/session-title"
},
{
"path": "../../schedule/tool-schedule"
},
{
"path": "../../session-query/session-query"
},

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.

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

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.

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。

View File

@@ -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

View File

@@ -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"

View File

@@ -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(
() => {

View File

@@ -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)
})
})

View File

@@ -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', {

View File

@@ -32,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/invariants"
}

View File

@@ -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.

View File

@@ -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 检查,并绝不关闭活动轮次。

View File

@@ -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 —

View File

@@ -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()