Merge worktree/schedule-conversational-after into worktree/schedule-explicit-at

# Conflicts:
#	apps/web/tests/schedule-after.e2e.ts
#	packages/client/runtime/src/client/sessions/session.ts
This commit is contained in:
Tianyi Cui
2026-08-09 21:18:04 +08:00
271 changed files with 12121 additions and 4860 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: 7567ac3cb8b1a580e145f8f0da49b6ec371a35bd
2026-07-19-gui-web-client-architecture.zh.md: 9b682febf1bd1aad1a767b8b2db4e83dd993cf2f
2026-07-19-gui-web-client-architecture.md: 5e927405e9012b5a56d82da8fcf600ca4aa1bd5b
2026-07-19-gui-web-client-architecture.zh.md: 4294af099dce4e1345e625694a054661483f8d12
@@ -44,35 +44,35 @@ Implementation homes: registry core and the props-share types in `packages/clien
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: Runtime projects Code Dispatch topology into each root's recursive `subCalls`; ui-conversation places that ordered root into the single `'conversation.chat.tool'` seat without interpreting Tool names or topology; ui-tool renders the supplied tree and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components.
There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)).
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
## The data object layer (`packages/client/runtime/src/client/sessions/`)
Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable):
Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable):
```
mux/host 帧(ConnectionController 泵入,sinks 注入)
mux/host frames (ConnectionController pump, injected sinks)
SessionManager.handleMuxEnvelope / handleHostEnvelope
sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
│ session frames target existing instances (requested waits buffer)
Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼
TranscriptAdapter PartialAccumulator
(→ nodes (→ partial
Session.handleMuxEnvelope ──► contiguous Event window
│ │ replace / prepend / append
│ ▼
ConversationNodeAssembler
Definitions -> Contexts -> view builders
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references.
- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates.
- **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
- **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
- **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering.
- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory.
## The React face (`packages/client/web-react`)
@@ -95,6 +95,7 @@ src/client/
contract/ shared slot and cross-domain types
service.ts cross-domain orchestration
skeleton/ conversation shell and details host
conversation-nodes/ independently registered business Definitions and Chat builder
chat/ ordered conversation view
input/ composer state machine
queue/ queued-message presentation
@@ -109,13 +110,13 @@ Domain implementation files never import a sibling domain; shared surfaces route
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
- **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally.
- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept.
- **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch.
- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)).
- **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`.
## Consequences
Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer.
Token streams no longer shake the render tree: Assistant chunks update one business Context and publish its keyed Node at most once per animation frame; unrelated rows' selector results retain their references, so those rows do not re-render. UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer.
## Alternatives considered
@@ -44,35 +44,35 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑 entrytab 元数据随注册 options`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:运行时把 Code Dispatch 拓扑投影进每个 root 递归的 `subCalls`ui-conversation 把这个已排序 root 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或拓扑;ui-tool 渲染传入的树,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。
slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑 entrytab 元数据随注册 options`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件和 view registry 是数据组装缝,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.md))。
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
## 数据对象层(`packages/client/runtime/src/client/sessions/`
帧从这里进、快照从这里出、fold 坐在中间——React-free(零 React importgrep 可断言):
帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React importgrep 可断言):
```
mux/host 帧(ConnectionController 泵入,sinks 注入)
mux/host frames (ConnectionController pump, injected sinks)
SessionManager.handleMuxEnvelope / handleHostEnvelope
sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
│ session frames target existing instances (requested waits buffer)
Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼
TranscriptAdapter PartialAccumulator
(→ nodes (→ partial
Session.handleMuxEnvelope ──► contiguous Event window
│ │ replace / prepend / append
│ ▼
ConversationNodeAssembler
Definitions -> Contexts -> view builders
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session**session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`Session 本身就是 uSES 源。帧分发是一个 switch`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
- **ConversationSnapshot**conversation.ts):不可变快照约定——`nodes`(人类对话记录,日志序)`partial``runningCalls``pending``running``removed``openState``hasMore``promptError`。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;未变化的 nodes 投影保持同一数组引用,消息流变化时返回新数组并复用未变化的元素引用;未变的子结构复用上一快照的引用
- **ConversationSnapshot**conversation.ts):顶层不可变快照契约。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline`nodes``partial``runningCalls``turnTimings``turnEnds` 是未迁移 Trajectory 消费者使用的兼容 slice。pending interaction、queue、runningremovedopen state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新
- **SessionManager**manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
- **Notifier**notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
- **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface`@deepseek-ai/dsh-session/surface``isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记
- **ConversationNodeAssembler**`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染
- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。
## React 面(`packages/client/web-react`
@@ -95,6 +95,7 @@ src/client/
contract/ shared slot and cross-domain types
service.ts cross-domain orchestration
skeleton/ conversation shell and details host
conversation-nodes/ independently registered business Definitions and Chat builder
chat/ ordered conversation view
input/ composer state machine
queue/ queued-message presentation
@@ -108,14 +109,14 @@ src/client/
## 怎么开发
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。
- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
- **消费新帧类型** sessionId → Session 分发 switch 加一个分支host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律
- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
- **消费新帧类型**纯传输 session frame → Session 分发 switchhost 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store[slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
- **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`
## Consequences
token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位,对被订阅视图每微任务一次合批重渲染(帧驱动 store 走 raf 合批)。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。
token 流不再震荡渲染树:Assistant chunk 只更新一个业务 Context,每 animation frame 最多发布一次对应 keyed Node;无关行的 selector 结果保持原引用,因此不会重渲染。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。
## Alternatives considered
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
2026-08-08-client-tool-presentation-ownership.md: f12a116c34064186b6be1496e1071025221817c4
2026-08-08-client-tool-presentation-ownership.zh.md: f5f7d71f1e05b27855d29acc223b340c8d89f1d8
2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8
2026-08-08-client-tool-presentation-ownership.zh.md: 031975fdebf5f72a19396f16b086a3abdcbca172
@@ -6,100 +6,63 @@ English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md)
## Problem
The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel.
Client Runtime already paired Tool call/result events by `callId` and could recover root/subcall topology from Code Dispatch events, but the Chat view also owned Tool placement in the conversation flow, recursive call-tree composition, Tool-name dispatch, the Generic fallback, card models, and first-party Tool renderers. `ui-conversation` therefore had to interpret every business Tool name; moving individual React components did not change that ownership, and removing atomic renderers left subcalls without a presentation owner.
That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place.
Tool presentation needed an independent owner without adding a second registry beside Client slots or making every atomic Tool renderer understand root/subcall structure.
## Decision
Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map.
Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-ui-tool` owns root/subcall composition, atomic renderer dispatch by wire Tool name, the Generic fallback, card models, and details output. Business plugins register only their atomic Tool renderers and do not modify conversation or Session.
“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool.
Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names.
`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models.
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists.
`ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology.
A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools.
Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this slot. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently.
The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import.
The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation contract, and this package boundary adds no Tool projector/fold registry.
The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent.
## Runtime and render path
This boundary starts at the Client's `ConversationSnapshot`; the full render path is:
```text
ConversationSnapshot.nodes
-> deriveChatFlow()
-> settled tool-group positions ----+
|
ConversationSnapshot.runningCalls |
-> ChatView flow tail ---------------+-> ToolSeat
-> conversation.chat.tool
-> ToolCallTree
-> root ToolCallBlock
`- subCalls[] (recursive)
-> tool.call.toolview(entryKey = toolName)
|- registered atomic view
`- GenericToolCard fallback
Session Event window
-> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock)
-> ChatView -> ChatNodeSeat(entryKey = tool-call)
-> ToolCallTree
-> root/subCalls[] recursion
-> tool.call.toolview(entryKey = toolName)
|- registered atomic view
`- GenericToolCard fallback
```
Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing.
## Ownership boundary
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree.
## Code and responsibility boundaries
| Owner | Primary code | Owns | Explicitly does not own |
|---|---|---|---|
| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name |
| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models |
| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold |
| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing |
| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition |
## Slot and owner contract
A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots.
The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions.
The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:<callId>"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers.
Business plugins use one registration shape:
```text
ctx.slots.inject('tool.call.toolview', () =>
ctx.slots.register({
name: 'tool.call.toolview',
key: '<wire tool name>',
}, BusinessToolRow))
```
`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session.
## Details path
[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse.
| Owner | Owns | Explicitly does not own |
|---|---|---|
| Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer |
| `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure |
| `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers |
| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering |
| Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors |
## Verification
Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle.
`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded.
## Alternatives considered
**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions.
**Keep atomic Tool slots under every conversation view.** Rejected: every view would repeat root/subcall composition and Tool registration would split by view. The whole Tool renderer occupies one business Node slot in a view, while Tool owns atomic dispatch.
**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility.
**Move only Tool React components and card models.** Rejected: conversation would still dispatch by Tool name and recurse through subcalls, so file movement would not create an ownership boundary.
**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension.
**Create a Tool-specific projector/fold registry.** Rejected: the general Conversation assembler already owns Context identity, history windows, and publication. A second Runtime registry would create two lifecycle authorities.
**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`.
**Let every atomic Tool renderer recurse through its subcalls.** Rejected: an atomic registrant should understand one Tool call without knowing whether it is a root or child. `ToolCallTree` handles recursive structure once.
**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading.
**Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior.
## Consequences
`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot contracts; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction.
`ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results.
The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here.
@@ -6,100 +6,63 @@ Status: implemented
## Problem
Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它`callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallbackcard model,注册第一方 Tool view,并在 details panel 中复用这些 model
Client Runtime 已经按 `callId` 配对 Tool call/result,并能从 Code Dispatch 事件恢复 root/subcall 拓扑,Chat view 曾同时拥有 Tool 在对话流中的放置、递归调用树、按 Tool 名称分发、Generic fallbackcard model第一方 Tool renderer。`ui-conversation` 因此必须解释每个业务 Tool 名称;只移动单个 React 组件不会改变这层所有权,移走原子 renderer 后 subcall 也会成为无主逻辑
这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排约定。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地
Tool presentation 需要一个独立所有者,同时不能建立与 Client slot 平行的第二套注册表,也不能让每个原子 Tool renderer 自己理解 root/subcall 结构
## Decision
Tool 成为 Client UI 的一级概念,`@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map
Tool Client UI 的一级展示概念,由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有 root/subcall 编排、按 wire Tool name 的原子 renderer 分发、Generic fallback、card model 和 details output。业务插件只注册自己的原子 Tool renderer,不修改 conversation 或 Session
这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool
Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.md)。`ui-conversation` 的 Tool Definition 从 Session Event 配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方 Tool identity 和拓扑,不解释具体 Tool 名称的展示
`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat snapshot 的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`
`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑
业务 Tool 插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取 Session、Context 或 Conversation assembler。Skill 仍是普通 Tool;它和其他业务 Tool 使用同一 keyed slot 注册路径
业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool`ui-skill` 通过该 slot 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它
details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text
details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。
Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示约定,这个包边界不会增加 Tool projector/fold registry。
## Runtime 与渲染链路
这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下:
## Runtime and render path
```text
ConversationSnapshot.nodes
-> deriveChatFlow()
-> settled tool-group positions ----+
|
ConversationSnapshot.runningCalls |
-> ChatView flow tail ---------------+-> ToolSeat
-> conversation.chat.tool
-> ToolCallTree
-> root ToolCallBlock
`- subCalls[] (recursive)
-> tool.call.toolview(entryKey = toolName)
|- registered atomic view
`- GenericToolCard fallback
Session Event window
-> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock)
-> ChatView -> ChatNodeSeat(entryKey = tool-call)
-> ToolCallTree
-> root/subCalls[] recursion
-> tool.call.toolview(entryKey = toolName)
|- registered atomic view
`- GenericToolCard fallback
```
Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。
## Ownership boundary
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root`ToolCallTree` 再沿该 root 的局部树递归渲染。
## 代码与职责边界
| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 |
|---|---|---|---|
| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 |
| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model |
| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold |
| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 |
| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 |
## Slot 与 owner 约定
slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。
整体席位的 `ToolTreeOwnerProps` 携带 root `callId``toolName``ToolCallBlock``selectedCallId`、session `cwd``openFile(path)``inspectCall(callId)``ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。
席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 约定:`data-chat-anchor-key="call:<callId>"``data-chat-call-id`,以及 selected call 上的 `data-selected="true"``ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。
业务插件遵循同一个注册形态:
```text
ctx.slots.inject('tool.call.toolview', () =>
ctx.slots.register({
name: 'tool.call.toolview',
key: '<wire tool name>',
}, BusinessToolRow))
```
`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。
## Details 路径
[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes``runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result textrunning call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。
| 所有者 | 拥有 | 明确不拥有 |
|---|---|---|
| Client Runtime Conversation engine | Context identity、Location、历史重放、view Node 发布 | Tool 事件含义、调用树、Tool renderer |
| `ui-conversation` Tool Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | Tool 名称分发、card model、递归 React 结构 |
| `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | Tool lifecycle、subcall 组合、原子 Tool renderer |
| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | Session Event fold、Chat 排序 |
| 业务 Tool 插件 | 一个或多个 wire Tool name 的原子 renderer | root/subcall 位置、生命周期配对、Session projector |
## Verification
测试归属跟随生产所有权。`ui-conversation` 测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主约定;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期
`ui-conversation` 测试固定 Tool Definition 的 call/result、Code Dispatch、interruption 和 running-to-settled keyed identity不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details 和具体 Tool card。组装后的 Web 测试覆盖两侧插件共同装载的路径
## Alternatives considered
**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策
**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都重复 root/subcall 编排,Tool 注册会按 view 分裂。整 Tool renderer 占据 view 的一个业务 Node slot,原子分发由 Tool 自己拥有
**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任
**只移动 Tool React 组件与 card model。** 拒绝:conversation 仍会 Tool 名称分发并递归 subcall,文件位置变化不产生所有权边界
**增加业务专属 Session projectorfold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展
**为 Tool 建立专属 projector/fold registry** 拒绝:通用 Conversation assembler 已拥有 Context identity、历史窗口和发布;第二个 Runtime registry 会制造生命周期的双重权威
**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` `ToolCallTree`
**让每个原子 Tool renderer 递归自己的 subcall。** 拒绝:原子注册方只应理解一个 Tool call,不应知道自己是 root 还是 child。递归结构统一由 `ToolCallTree` 处理
**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 保留生命周期所有权、fallback 行为与独立插件装载
**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转 feature 依赖并把 Tool 展示变成必选能力。slot 保留独立装载、生命周期和 fallback
## Consequences
`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两份跨包 slot 约定`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空details 席位则保留 raw-result fallback,且 Session 重建不受影响
`ui-conversation` 不再依赖 Tool 名称对应的业务展示,root 与 subcall 不会漂移到不同分发路径业务包可以独立拥有原子 Tool renderer`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallbackdetails 保留 raw result。
代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个 Tool 专属子 slot。Tool Definition 暂时位于 `ui-conversation`,因为本次没有拆 package;它以后可以沿 Conversation registry seam 移动,而不会改变本 Note 规定的展示所有权。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: 16a39539064644e5467f701789a7e2ef1f7ff172
2026-08-09-client-conversation-node-assembly.zh.md: 0e0fbdf8f3320393022528e6e3fe2cf0d492a1d3
@@ -0,0 +1,409 @@
# Agent Note: Client Conversation business-node assembly and keyed Chat snapshots
Status: implemented
English | [中文](2026-08-09-client-conversation-node-assembly.zh.md)
## Problem
Client Session owned transport windows, connection state, and pending interactions while also interpreting Assistant, Tool, message, command, compaction, retry, and turn-tail events in a centralized transcript fold. Adding one business node required changes to Session switches, history replay, indexes, caches, and React grouping; business identity, state evolution, and final presentation had no independent owner.
The old path also placed running Assistant and Tool values outside the finalized flow. They entered the log-ordered node list only after settlement, so their React parent changed and remounted them even when the business ID and `key` remained stable. Full history loads, older prepends, live appends, and token streaming used separate update paths, leaving reference stability and local recomputation dependent on specialized caches spread across the client.
Business events also use different correlation models. Tool has call IDs, Assistant correlates by turn and step, Compaction has its own lifecycle and checkpoint, and an Inbox splice represents one instantaneous state in a sequence. Keeping all these distinctions in one fold would make every business change pass through a global lookup and invalidate unrelated caches.
## Decision
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous Event window to the engine and publishes its snapshot instead of interpreting individual conversation businesses.
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
### Responsibility layers
| Layer | Durable responsibility | Explicitly does not own |
|---|---|---|
| Session | Maintain the contiguous Event window, distinguish replace, prepend, and append, and schedule snapshot notifications | Interpret Tool, Assistant, Compaction, or other business events |
| Event Registry | Retain the unique-`kind` Definitions and sole fallback under Cordis lifecycles | Store one Session's Context or State |
| Assembler | Match Events and maintain Contexts, Locations, dependencies, and the publication dirty set | Interpret business State fields or Chat ordering |
| Node Definition | Define one business object's identity, State transitions, Location data, and target Node | Create Contexts, mutate another business's State, or scan all Contexts |
| View Builder | Incrementally organize final target Nodes into that view's snapshot | Reinterpret raw Session Events |
| React renderer | Render renderer-owned data by the final Node's `kind` and read business data from the current Node's Location | Pair business Events, scan global Nodes, or decide business lifecycle state |
Registry contributions are Cordis effects. Removing a Definition causes a low-frequency registry rebuild for existing Sessions; ordinary business Events do not change the Registry or rebuild every business type.
### Overall `ConversationNodeDefinition` contract
Each [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Error. The Assembler asks the fallback only when every ordinary Definition returns `null`.
A Definition holds no mutable business data across Sessions. Each Session's Assembler isolates that Session's Contexts, State, dependencies, and View Builders.
#### `kind`, business ID, and Context key
The `id` returned by `match()` only needs to be stable within its Definition. A Tool ID can be a call ID, an Assistant ID can be `turn:step`, and an Inbox ID can be the splice Event seq.
The Assembler uses `conversationContextKey(kind, id)` to make a collision-free key. Definitions that return the same `id` still do not share a Context. The final view Node must retain this engine-owned key and cannot use `seq` or render position as identity.
Each `(kind, id)` has at most one start Match. A second start fails immediately; a Definition must return a new ID to represent a new lifecycle.
#### `match(event)`
`match(event)` reads only the current raw `SessionEvent` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope.
This restriction makes one Event's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
Start, result, resource, checkpoint, and business-owned terminal Events must carry or directly imply the same ID. If one Event cannot yield that ID, its producer extends the Event protocol; the Client does not guess from the "nearest unfinished object."
The `role` describes the State lifecycle, not visibility. A start may produce a terminal Node immediately, while an update may enter a pending Context before its start has loaded.
#### `ConversationMatch`
After a successful match, the Assembler combines the raw Event, optional wire presentation view, `role`, and engine-computed `location` into a read-only `ConversationMatch`.
A Context's `matches` always remain in ascending Event `seq` order, not network arrival or pagination ingestion order. If a tail page supplies a result before an older page supplies its call, the final Match order still places the call before the result.
Location can change when prepend fills a boundary or append closes one. The Assembler replaces the affected Matches' read-only Locations and replays the Context; business code does not retain an old Location copy as authority.
#### `ConversationNodeContext`
| Field | Owner | Semantics visible to the Definition |
|---|---|---|
| `key` | Assembler | Stable final identity derived from `kind + id` |
| `kind` / `id` | Definition + Assembler | Current business namespace and business ID |
| `matches` | Assembler | Complete business evidence loaded in the current window and sorted by `seq` |
| `start` | Assembler | Unique start Match, or `undefined` before it loads |
| `state` | Returned by Definition, held by Assembler | Most recent `start`/`update` return value, or `undefined` before initialization |
| `current` | Assembler | Most recently materialized Node or `null` for each target |
Read-only Context fields do not require deeply immutable business State. A Definition may return a new object or mutate the old object in place and return the same reference.
The Assembler adopts only the returned value. Returning `undefined` from `start()` or `update()` is a contract error and fails immediately; mutating an object without returning it is likewise invalid.
A Definition may inspect all `matches` to help construct State or a fallback Node, but it cannot add or remove Matches, replace Context fields, or mutate another Context.
#### `start(context, match, reader)`
`start()` is the sole State initialization entry point. The Assembler invokes it when the unique start first appears and adopts its returned State.
When an older page changes Match order, the Reader's predecessor answer, or Location facts, the Assembler recomputes from `start()` instead of applying a reverse-direction patch to old State.
The Context may already contain updates after the start when `start()` runs. After `start()` returns initial State, the Assembler still invokes `update()` for every post-start Match in ascending log order, so ingestion direction cannot change the final fold.
The `reader` is available only in `start()`. Initialization can read the nearest active Context of a specified `kind` strictly before the current start seq, but business code receives no general interface for scanning internal engine Maps.
Each new `start()` invocation replaces the Reader dependencies recorded by the prior invocation, so a Definition that changes its query branch retains no stale edges.
#### `reader.previous(kind)`
`reader.previous(kind)` finds the nearest Context whose `candidate.startSeq < current.startSeq` and whose State is initialized. It never returns a Context at the same seq, a future Context, or a pending Context without State.
The result contains the predecessor's key, kind, ID, start seq, read-only State, and Matches. The consumer interprets that State itself; the provider only maintains its State correctly and need not register a specialized query method.
Each Reader query records a `{ key, revision, windowGap }` dependency. A matched predecessor's revision change replays the consumer; a miss while older history remains records a window gap for a later prepend.
When the window already reaches the Session beginning, a miss is a definitive `undefined`. When `hasMore` is true, the Definition sees the same `undefined`, but the Assembler remembers that the result is provisional.
Dependencies point strictly from earlier starts to later starts, so transitive replay cannot form a temporal cycle. Both the Inbox instantaneous-state chain and Message reads of Inbox use this constraint.
#### `update(context, match)`
`update()` handles a post-start Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the Event.
The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`.
When no business data changes, `update()` returns the existing State. When data changes, it may return an immutable replacement or mutate the existing object and return that object.
The Assembler does not use State reference equality to decide publication or propagation. Every accepted update increments the Context revision, marks it dirty, and causes direct or transitive Reader consumers to be reevaluated.
#### `publication(match)`
`publication()` controls when the latest State materializes as a view Node; it does not delay the synchronous execution of `match()`, `start()`, or `update()`.
| Return value | Behavior |
|---|---|
| `immediate` | Request a notification and flush in the current microtask |
| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame |
| `none` | Do not schedule a flush for this Match; retain its State and dirty marker |
Omitting `publication()` means `immediate`. Assistant token deltas use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Every delta within a frame still executes update. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no tokens are lost.
#### `buildLocationData(context, scope)`
`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
A Definition receives the `step` and `turn` scopes separately and may return one value or `null` in either phase. A value must identify the exact turn/step coordinates and use the Definition's `kind` as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key.
`ConversationStepDataMap` and `ConversationTurnDataMap` use declaration merging to constrain keys and values. A Location exposes only a stable `data.get(key)` reader; consumers cannot obtain the provider Context or mutate its State.
#### `buildViewNode(context, target)`
`buildViewNode()` reads the latest Context during publication and directly produces the final business Node for the named target. The Assembler adds no generic activity, tail-candidate, or layout business layer afterward.
`null` means this Context has not yet materialized for the target. On the ordinary incremental path, a Context that has returned a non-null Node cannot later return `null`; temporary absence retains the same-key Node and uses the target's visibility representation.
The Assembler verifies `node.key === context.key` and `node.target === target`. Business code may change `anchorSeq`, data, Location, or visibility, but cannot change identity within one lifecycle.
`current` lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry and Turn Error suppression use it to avoid illegal Node withdrawal.
A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory remains on its independent `session-history` fold until it gains a registered target.
#### No generic `end()`
The engine exposes no fixed `end()` lifecycle. A single-Event business completes in `start()`, a multi-Event business records completion in its own update, and a long-lived instantaneous-state business creates a new Context for every Event.
Step and Turn closure are external Location facts and do not mutate business State. A boundary change replays and builds affected Contexts; each business combines its own completion State with whether its Location is closed to produce normal, running, or interrupted presentation.
IDs are never reused. Completed Contexts remain in the current window, providing stable render identity and possible predecessor evidence for later Readers.
### Location is a first-class engine fact
[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`.
Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events.
Each Turn and Step also carries a reference-stable Location data store. A Definition update replaces only its owned key; the same store identity can acquire new values through append or prepend, allowing Contexts, View Builders, and React renderers to share resolved hierarchy-level business facts without copying or scanning the global Node array.
`unresolved` means the current history window lacks sufficient preceding boundaries; it does not mean session-level. When older prepend supplies those boundaries, the index corrects Match Locations and replays only Contexts that own those seqs.
An appended ordinary Event only inherits current coordinates, while an appended boundary recalculates only its owning Turn. Prepend rebuilds Location facts from the expanded contiguous window, but reference-stability logic retains unchanged Turn and Step objects.
The Assembler also passes a reference-stable timeline to each View Builder. Businesses do not separately maintain turn order, step lists, last-step values, or boundary Maps.
## Three Event-window paths
"Backward history scanning" describes the UI loading pages from the newest tail toward the Session beginning; it does not mean a Definition executes `update()` in reverse. Regardless of history API order or page-loading direction, the Assembler canonicalizes each current window and each fresh page in ascending `seq` order.
| Scenario | Input range | Context and State handling | View Builder |
|---|---|---|---|
| Initial history tail or resync | Current complete contiguous window | Clear and rebuild all Contexts in ascending `seq` order | `replace()` |
| Load one older-history page | Only deduplicated fresh Events before the window | Retain existing Context identity, then add Matches, Locations, dependencies, and local replays | `apply(upserts)` |
| Live append | One contiguous tail Event | Match Definitions and update only the exact IDs; boundaries affect only their owning Turn | `apply(upserts)` |
### Initial history tail and logical backward scanning
1. `Session.open()` loads the latest tail page and passes its contiguous History Entries to `replaceWindow(entries, hasMore)`.
2. `replaceWindow` clears old Contexts, start-seq indexes, seq reverse indexes, Reader dependencies, and the input Map.
3. It sorts every entry by Event `seq` and stores the resulting current window.
4. LocationIndex rebuilds Turn and Step facts for that window.
5. The Assembler visits Events in ascending order and invokes every ordinary Definition's `match(event)`.
6. Each result gets or creates its `(kind, id)` Context and enters that Context's ordered Match array.
7. A start runs `start()`; a tail update on initialized State runs `update()` directly.
8. If the page contains only a result or resource and omits its start, the ID still creates a Context and collects Matches, while State remains `undefined`.
9. After matching all Events, the Assembler rechecks Reader dependencies so earlier instantaneous states in the same window stabilize before later consumers read them.
10. Every Context becomes dirty, and the next flush fully rebuilds Location data in Step→Turn order before invoking `buildViewNode()` for every target.
11. Some businesses return `null` without a start; Compaction, Command, Tool result, and Turn Error can construct fallback Nodes from sufficient update evidence.
12. Each View Builder receives the complete Node set and timeline and establishes the initial snapshot through `replace()`.
This path starts from the newest page only at the pagination layer. State within the page always computes forward, so the same window does not produce different business results under a different scan direction.
A Context without a start is not an error. It is a pending aggregation container waiting for an older page; that Definition's `buildViewNode()` decides whether the evidence already makes it visible.
If an update with the same ID is genuinely earlier than the start in log order, rather than merely loaded first, replay fails with a protocol error after the start arrives. Arrival order may be reversed; business log order may not.
### Prepending a newly loaded older page
1. `Session.loadOlder()` requests the immediately preceding page using the current `baseSeq` and first verifies continuity between the page tail and current window.
2. Session prepends the raw Event and view arrays to its own window and passes only that page to `assembler.prepend(entries, hasMore)`.
3. The Assembler removes seqs that overlap the current window, then sorts the fresh page internally in ascending order.
4. Existing Contexts, State, current Nodes, and View Builder instances remain intact.
5. LocationIndex rebuilds facts over the expanded complete input and reports seqs whose Location identity actually changed.
6. Contexts owning those seqs update their Match Locations and replay from start; unrelated Contexts do not join Location replay.
7. Fresh Events run Definition matchers and enter existing or new Contexts by stable ID.
8. If the new page supplies a pending Context's start, that Context initializes from the start and then applies every already-collected update in ascending order.
9. If the page establishes a nearer Reader predecessor, changes a predecessor revision, or removes a window gap, the consumer recomputes from `start()`.
10. Reader dependencies propagate replay toward later start seqs; no Event is applied in reverse within the propagation batch.
11. An empty page that changes `hasMore` from true to false also rechecks dependencies and resolves a provisional `undefined` to definitive absence.
12. The flush republishes Step/Turn Location data and target Nodes only for dirty Contexts, then passes non-null results to View Builder `apply()` as `upserts`.
Prepend retains existing Context keys and current Node identity. A page may add historical keys at the front of Chat `order` or correct an existing Node's anchor, Location, visibility, or data, but it does not recreate unrelated business Contexts.
On a structural change, the Chat Builder recomputes visible `order` and the secondary Location index from its keyed store. That is view-index work; it neither reruns every business Definition nor replaces unchanged Node values.
Reader gap repair is the largest algorithmic difference between prepend and ordinary append. A page can both add visible historical Nodes and change later Inbox instantaneous states and the Message classifications that depend on them.
### Forward live append
1. Session accepts only a live Event immediately after the current tail seq; it deduplicates overlap and runs tail-page repair before accepting a gap.
2. A non-boundary Event enters the current Turn and Step coordinates incrementally; a boundary Event updates Location facts for its owning Turn.
3. The Assembler invokes `match()` once on every ordinary Definition for this Event and scans no Definition's Context set.
4. Each successful result directly locates one Context through `(kind, id)`.
5. A new ID creates a Context; a normal tail update for an existing ID invokes `update()` once.
6. A start or any evidence inserted before the tail uses complete `replayContext()` and retains the same forward-order semantics.
7. After a Context revision changes, only recorded Reader dependents replay.
8. Location close updates affected Matches within its owning Turn and replays those Contexts, allowing unfinished Assistant, Tool, or Retry values to acquire interrupted or cancelled presentation.
9. The Assembler takes the highest publication urgency among all matching Definitions: `immediate` outranks `animation-frame`, which outranks `none`.
10. Session routes immediate work to the microtask notifier and animation-frame work to the RAF notifier.
11. The flush updates Step/Turn Location data for dirty Contexts, then invokes `buildViewNode()` and passes this transaction's upserts and latest timeline to each View Builder.
12. The new React snapshot reuses stable Context keys; the same Tool running→settled or Assistant streaming→final value never moves across parents.
Append's business-matching cost is the Definition count plus the Contexts actually updated, independent of historical Context count. Reader consumers and Location closure add replay proportional to real dependencies or the owning Turn.
A structural Chat `order` change can still reorder the current visible keys. A data-only update replaces one keyed-store Node and touches its Location index. The guarantee is that unrelated businesses do not refold and unchanged Node identity is retained, not that every view-index operation has constant complexity.
### Consistency across replace, prepend, and append
All three paths preserve the same invariants: Context Matches are seq-ordered, State folds forward from one unique start, Reader sees only strictly preceding active Contexts, Location data publishes in Step→Turn order, and Node key depends only on kind and ID.
`replaceWindow` is the low-frequency complete replacement for initial open, resync, gap repair, and registry changes; it does not implement ordinary load older. Both `prepend` and `append` retain existing Builder and Context identity.
Page size, the number of history loads, and RAF coalescing affect only when evidence arrives or publishes. They do not change final Context State and Nodes for an equal Event window.
## How built-in businesses use Definitions
### Matching, ID, and State
| Business / `kind` | Stable ID | Start Match | Update Matches | State and cross-Context reads |
|---|---|---|---|---|
| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` |
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set |
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
| Automatic Compaction / `compaction` | Compaction ID | `compact/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start |
| Retry / `model-retry` | Retry ID | Attempt 1 `llm/retry` | Later `llm/retry` and `llm/retry-started` | Aggregate one RetryId's attempts and scheduled/started state |
| Turn Error / `turn-error` | Turn number | `turn/start` | Error `turn/end` and Retry Events for that Turn | Aggregate terminal failure and use Retry evidence to decide hiding |
| Turn Tail / `turn-tail` | Turn number | `turn/start` | Assistant, Retry, `step/end`, and `turn/end` | Retain turn end, read each Step's Assistant data, and publish Turn data; use complete Matches to choose the visual tail anchor |
| Deliverables / `deliverables` | Turn number | `turn/start` | Tool calls/results in that Turn | Aggregate successful mutation paths and publish Turn data without producing a view Node |
| Unknown fallback / `unknown-surface` | Event seq | Append-surface Event unclaimed by any ordinary Definition | None | Retain raw type/data for the JSON fallback |
### Chat Node and history/live behavior
| Business | `publication()` | Chat output | History and runtime behavior |
|---|---|---|---|
| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices |
| Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
| Assistant | RAF for chunks, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Matches support fallback without `step/start`; Location close produces interruption presentation |
| Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key |
| Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key |
| Compaction | Immediate by default | `compaction` marker | A checkpoint may render before start; an older start triggers forward replay |
| Retry | Immediate by default | One `model-retry` Node containing all attempts | Multiple retries update one key; Location close presents the last scheduled attempt as cancelled |
| Turn Error | Immediate by default | Visible or hidden `turn-error` | Error end supports fallback without start; later Retry keeps the key and hides it |
| Turn Tail | Immediate only for `turn/end`; otherwise none | Independent `turn-tail` footer | Compute closing/metrics from Step Assistant data and use same-turn Matches to choose the anchor |
| Deliverables | Immediate by default | No Node | Tool settlement incrementally updates Turn data; the Turn Tail extension slot reads produced files |
| Fallback | Immediate by default | `unknown` JSON row | Covers only append-surface Events; an ordinary business that claimed but has not rendered an Event does not duplicate it |
Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox.
Assistant, Turn Tail, and Turn Error demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.
Assistant, Turn Tail, and Deliverables demonstrate layered Location data composition. Assistant writes `assistant-step` data for each Step; Turn Tail derives `turn-tail` data from those Step values; Deliverables independently maintains `deliverables` data for the same Turn. Consumers read only declaration-merged keys, do not scan another business's Nodes, and cannot obtain the provider's Context State.
Tool and Command demonstrate multi-Event aggregation: the producer supplies a shared ID, and the Context builds a tree or integrates Compaction internally instead of pushing pairing into the Chat Builder.
Compaction and historical Tool results demonstrate business fallback without a start. The engine does not impose "no start means no rendering"; each Definition decides whether current Matches are sufficient.
Retry demonstrates the State and Location split. Scheduled and started belong to Retry State, while Step and Turn closure belong to engine Location; `buildViewNode()` combines them into cancelled presentation.
Unknown fallback demonstrates Registry ownership: it handles only append-surface Events unclaimed by every ordinary matcher, and does not create a duplicate Node merely because a claimed Context temporarily returns `null`.
## View Builder and React identity
[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches.
The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value.
For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys.
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in `ui-conversation`, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher.
The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.
Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. The selector inside `useTurnData()` returns only the current Node's `turn.data.get(key)`, so selector equality filters unrelated Session publications.
The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
Assistant streaming to final and Tool running to settled update only one Seat's data and necessary ordering properties. They no longer move from a tail running container into finalized flow, so settlement does not reset component-local State.
When business logic deliberately changes a materialized Node to hidden, it leaves visible order and remounts when visible again. This is explicit business withdrawal of presentation, distinct from the stable-Seat guarantee for running→settled.
The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot.
Trajectory has no registered target and does not consume the Chat Builder's legacy slice. Its activated `SessionHistoryInspection` keeps an independent history fold, while the ordinary Session snapshot no longer runs a second transcript fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; a future Trajectory migration does not change the Event Definition, Context, Reader, or Location contracts.
## Runtime and render path
```text
Session Event window
-> ConversationNodeAssembler
-> Definition.match(event) -> (kind, id, start/update)
-> Context matches + State + Location
-> Definition.buildLocationData(step -> turn)
-> StepLocation.data / TurnLocation.data
-> Definition.buildViewNode(target = chat)
-> ChatSnapshotBuilder
-> order[] + keyed Node store + Location index + timeline
-> ChatView
-> ChatNodeSeat(key)
-> conversation.chat.node(entryKey = node.kind, hookContext = key)
-> slot-level useTurnData(businessKey)
```
## Verification
Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders.
Conversation tests cover every built-in Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch.
Slot type/runtime tests pin required parent-provided common inject, the `hookContext` type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory.
Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and Code Mode root + nested subcalls against master layout.
History-path tests cover complete replace, non-overlapping prepend, overlapping-seq deduplication, empty-page `hasMore` convergence, and live append. Equal Event windows ingested through different paths produce equal business State and final Nodes.
## Alternatives considered
**Keep the centralized Session transcript fold and extract only helpers.** Rejected: business identity, history replay, and cache invalidation would still belong to one closed switch; moving functions would not establish independent ownership.
**Let React renderers scan Session Events.** Rejected: every view would duplicate matching and lifecycle State, React would become business authority, and paging and streaming would recompute unrelated component trees.
**Pass global Nodes or Location indexes to every business renderer.** Rejected: business components would scan and infer their current Turn/Step, and their subscription scope would grow with the window. A Definition publishes aggregates onto an engine-owned Location, and a renderer reads only its own Node's Location data.
**Call every Context of a Definition for each new Event.** Rejected: append cost would grow with history, and `update()` would combine matching with conversion. Context-free `match(event)` finds the ID first, after which only one Context updates.
**Let a Definition matcher read Contexts or scan history.** Rejected: matching would depend on ingestion direction, result-first history pages could not determine ownership independently, and live append would regress to searching open objects.
**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning.
**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together.
**Register specialized query methods for cross-business reads.** Rejected: consumers would still depend on provider APIs, and each new relationship would expand a central interface. Reader exposes a named kind's read-only predecessor Context; the provider writes useful State and the consumer interprets it.
**Let a Location-data consumer read the provider's Context State directly.** Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates.
**Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation.
**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory's independent history fold remains until it registers its own Builder.
**Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts.
**Register the Turn-data Hook only on the Assistant renderer.** Rejected: current-Node Location access is a common capability of the `conversation.chat.node` slot, not one business renderer. The parent Chat entry registers common inject once, and every keyed renderer shares the same strongly typed contract.
**Keep running Assistant or Tool values in an independent tail container.** Rejected: settlement would move them across React parents, and a stable business key could not prevent remount. One keyed order permits data and position changes without changing Seat identity.
## Consequences
A new business node can register its matcher, State transitions, optional Location data, final target Node, and renderer locally without changing Session's business switch. `ChatNodeDataMap` and the Location data maps let a business package merge strongly typed data into the contract; every related Event must still expose a stable ID derivable from that Event alone.
Host business packages declaration-merge their durable Event members into `@deepseek-ai/dsh-session/types`, while Client Definitions type-only import the corresponding business package `/types` subpaths. Augmenting the declaring interface rather than a re-export barrel gives the independent Host and Client TypeScript programs the same Event narrowing without pulling Host runtime into the Client graph.
Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and high-frequency deltas are explicit engine states and require no direction-specific business cache.
Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.
Separating State updates from publication cadence folds every Assistant delta while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Steps and Turns become stable homes for cross-business aggregates. Turn Tail and Deliverables no longer depend on renderers scanning global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, Trajectory still owns an independent history fold, and built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session.
@@ -0,0 +1,409 @@
# Agent Note: Client Conversation 业务节点组装与 Chat keyed snapshot
Status: implemented
[English](2026-08-09-client-conversation-node-assembly.md) | 中文
## Problem
Client Session 既维护传输窗口、连接状态和待处理交互,也在中心化 transcript fold 中解释 Assistant、Tool、消息、命令、压缩、重试及 turn tail 等业务事件。每增加一种业务节点,都要修改 Session 的 switch、历史 replay、索引、缓存和 React 分组;业务 identity、状态演进与最终展示没有独立所有者。
旧链路还把运行中的 Assistant 和 Tool 放在 finalized flow 之外。它们结算后才进入按日志排序的节点列表,因此 React parent 会改变,即使业务 ID 和 `key` 不变也会重新挂载。全量历史加载、older prepend、实时 append 与 token streaming 又分别走不同更新路径,使引用稳定和局部重算只能靠各处特化缓存维持。
业务事件之间的关联方式并不统一。Tool 有 call IDAssistant 以 turn/step 关联,Compaction 有独立生命周期和 checkpointInbox splice 则表示一个连续状态的瞬间。把这些差异继续塞进统一 fold,会让任一业务变化都经过全局查表并使无关缓存失效。
## Decision
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builderSession 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不再解释具体 conversation 业务。
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
### 责任分层
| 层 | 长期职责 | 明确不负责 |
|---|---|---|
| Session | 维护连续 Event 窗口,区分 replace、prepend、append,调度 snapshot 通知 | 解释 Tool、Assistant、Compaction 等业务事件 |
| Event Registry | 按 Cordis 生命周期保存唯一 `kind` 的 Definition 和唯一 fallback | 保存某个 Session 的 Context 或 State |
| Assembler | 匹配 Event,维护 Context、Location、依赖和发布脏集 | 理解业务 State 字段或 Chat 排序 |
| Node Definition | 定义一个业务对象的 identity、State 演进、Location data 和 target Node | 创建 Context、修改别的业务 State 或扫描全部 Context |
| View Builder | 把最终 target Node 增量整理成该视图的 snapshot | 重新解释原始 Session Event |
| React renderer | 按最终 Node 的 `kind` 展示 renderer-owned data,并读取当前 Node 所属 Location 的只读业务 data | 配对业务 Event、扫描全局 Nodes 或决定业务生命周期 |
Registry 注册是 Cordis effectDefinition 卸载会触发现有 Session 的低频 registry rebuild。普通业务 Event 不改变 Registry,也不会因此重建全部业务类型。
### `ConversationNodeDefinition` 总体契约
每个 [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Error。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。
Definition 不持有跨 Session 的可变业务数据。每个 Session 的 Context、State、依赖和 View Builder 都由该 Session 的 Assembler 隔离持有。
#### `kind`、业务 ID 与 Context key
`match()` 返回的 `id` 只要求在当前 Definition 内稳定。Tool 的 ID 可以是 call IDAssistant 的 ID 可以是 `turn:step`Inbox 的 ID 可以是 splice Event seq。
Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同 Definition 即使返回相同 `id` 也不会共享 Context。最终 view Node 必须沿用这个 engine-owned key,不能把 `seq` 或渲染位置当 identity。
每个 `(kind, id)` 最多存在一个 start Match。第二个 start 会立即报错;Definition 需要表达新生命周期时必须返回新 ID。
#### `match(event)`
`match(event)` 只读取当前原始 `SessionEvent`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、历史、Reader、Location 或 view envelope。
这项限制使单条 Event 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
start、result、resource、checkpoint 及业务自有终止 Event 必须携带或可直接推导同一 ID。若单个 Event 不能算出 ID,生产 Event 的协议负责补足关联字段,Client 不通过“最近一个未完成对象”猜测。
`role` 描述 State 生命周期,不描述可见性。start 可以立即生成 terminal Nodeupdate 也可以在 start 尚未加载时先进入 pending Context。
#### `ConversationMatch`
匹配成功后,Assembler 把原始 Event、可选的 wire presentation view、`role` 和引擎计算的 `location` 组成只读 `ConversationMatch`
Context 的 `matches` 永远按 Event `seq` 升序保存,而不是按网络到达或分页摄入顺序保存。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。
Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assembler 替换受影响 Match 的只读 Location 并 replay Context;业务不把旧 Location 副本当权威保存。
#### `ConversationNodeContext`
| 字段 | 所有者 | Definition 可见语义 |
|---|---|---|
| `key` | Assembler | `kind + id` 的稳定最终 identity |
| `kind` / `id` | Definition + Assembler | 当前业务命名空间和业务 ID |
| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整业务证据 |
| `start` | Assembler | 唯一 start Match;尚未加载时为 `undefined` |
| `state` | Definition 返回、Assembler 持有 | 最近一次 `start`/`update` 返回值;未初始化时为 `undefined` |
| `current` | Assembler | 各 target 最近一次 materialize 的 Node 或 `null` |
Context 字段只读,不表示业务 State 必须是深度 immutable。Definition 可以返回新对象,也可以原地修改旧对象后返回同一引用。
Assembler 只采纳函数返回值。`start()``update()` 返回 `undefined` 是契约错误并立即报错;修改了对象却不返回它同样不成立。
Definition 可以读取完整 `matches` 辅助构造 State 或 fallback Node,但不能增删 Match、替换 Context 字段或修改另一个 Context。
#### `start(context, match, reader)`
`start()` 是 State 的唯一初始化入口。Assembler 首次得到唯一 start 后调用它,并采用其返回 State。
当更早分页改变 Context 的 Match 顺序、Reader 前序答案或 Location 事实时,Assembler 从 `start()` 重新计算,而不是对旧 State 做方向相反的补丁。
调用 `start()` 时,Context 可能已经收集 start 之后的 updates。`start()` 返回初始 State 后,Assembler 仍会从 start 之后按日志正序逐条调用 `update()`,因此摄入方向不会改变最终 fold 结果。
`reader` 只在 `start()` 中可用。它允许初始化逻辑读取严格位于当前 start seq 之前、指定 `kind` 的最近 active Context,但不给业务一个任意扫描引擎内部 Map 的接口。
每次重新调用 `start()` 都会替换上一次调用登记的 Reader 依赖,保证 Definition 改变查询分支时不会保留陈旧边。
#### `reader.previous(kind)`
`reader.previous(kind)` 查找满足 `candidate.startSeq < current.startSeq` 且 State 已初始化的最近 Context。它不会返回同 seq、未来 Context 或尚无 State 的 pending Context。
返回值包含前序 Context 的 key、kind、id、start seq、只读 State 和 Matches。消费者自行解释 State;提供方只负责把自己的 State 维护正确,不需要注册特化 query 方法。
Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前序 Context 时,其 revision 变化会 replay 消费者;未命中且仍有 older 历史时,window gap 会等待后续 prepend。
若窗口已经到达 Session 起点仍未命中,`undefined` 是确定答案。若 `hasMore` 为 trueDefinition 看到的仍是同一个 `undefined`,但 Assembler 会记住这是暂定结果。
依赖严格从较早 start 指向较晚 start,因此传递 replay 不形成时序环。Inbox 瞬间态链和 Message 对 Inbox 的读取都使用这一约束。
#### `update(context, match)`
`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start Match。它不再判断 Event 属于哪个 Context。
Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。
没有业务变化时,`update()` 返回原 State。存在业务变化时,它可以返回 immutable replacement,也可以原地修改并返回同一对象。
Assembler 不以 State 引用相等判断是否需要发布或传播。每次成功 update 都增加 Context revision、标记 dirty,并使直接或传递 Reader 消费者重新求值。
#### `publication(match)`
`publication()` 只决定最新 State 何时 materialize 成 view Node,不改变 `match()``start()``update()` 的同步执行。
| 返回值 | 行为 |
|---|---|
| `immediate` | 请求当前 microtask 通知与 flush |
| `animation-frame` | 把多条高频更新合并到下一帧 materialize |
| `none` | 本 Match 不主动安排 flushState 和 dirty 标记仍被保留 |
省略 `publication()` 等于 `immediate`。Assistant token delta 使用 `animation-frame`,不可见 Inbox Context 使用 `none`final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
一帧内的每条 delta 仍执行 update;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 token。
#### `buildLocationData(context, scope)`
`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`
Definition 分别收到 `step``turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。
`ConversationStepDataMap``ConversationTurnDataMap` 通过 declaration merging 约束 key 与 value。Location 只暴露稳定的 `data.get(key)` reader,消费者不能取得提供方 Context 或修改它的 State。
#### `buildViewNode(context, target)`
`buildViewNode()` 在发布阶段读取最新 Context,为指定 target 直接生成最终业务 Node。Assembler 不在它之后附加通用 activity、tail candidate 或 layout 业务层。
`null` 表示该 Context 对这个 target 尚未 materialize。普通增量路径中,一个已经返回过非空 Node 的 Context 不能再返回 `null`;暂时隐藏必须保留同 key Node,并使用 target 自己的 visibility。
Assembler 校验 Node `key === context.key` 且 Node `target === target`。业务可以改变 `anchorSeq`、data、Location 或 visibility,但不能在一次生命周期内改变 identity。
`current` 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry 和 Turn Error suppression 使用它避免非法的 Node 撤回。
Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder;在拥有注册 target 之前,Trajectory 继续使用独立的 `session-history` fold。
#### 不提供通用 `end()`
引擎不提供固定 `end()` 生命周期。单 Event 业务在 `start()` 中完成,多 Event 业务在自己的 update 中记录完成,长期瞬间态业务则每条 Event 建立新 Context。
Step/Turn 关闭属于外部 Location 事实,不替业务修改 State。边界变化会 replay 并 build 受影响 Context;业务结合“自己的 State 是否完成”和“Location 是否 closed”生成正常、running 或 interrupted 表现。
ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定渲染 identity,也可以作为后续 Reader 的前序证据。
### Location 是一级引擎事实
[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start``step/start`、显式 turn/step payload、`step/end``turn/end` 建立 Event 到 Location 的映射。
Location 有 `session``turn``step``unresolved` 四种形状。Turn/Step 各自带 `open``closed``unknown` 状态,以及已加载的 start/end Event。
每个 Turn 和 Step 还持有 reference-stable 的 Location data store。Definition 更新只替换自己拥有的 key;同一个 store identity 可以随 append 或 prepend 获得新值,使 Context、View Builder 和 React renderer 共享已经确定的层级业务事实,而不复制或遍历全局 Node 数组。
`unresolved` 表示当前历史窗口缺少足够前序边界,不等于 session-level。older prepend 补入边界后,索引修正 Match Location,并只 replay 拥有这些 seq 的 Context。
Append 普通 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于扩展后的完整连续窗口重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。
Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复维护 turn order、step list、last step 或边界 Map。
## 三种事件窗口链路
“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 `update()`。无论历史 API 返回顺序或页面加载方向如何,Assembler 对每个当前窗口和每个 fresh page 都按 `seq` 升序 canonicalize。
| 场景 | 输入范围 | Context/State 处理 | View Builder |
|---|---|---|---|
| 初始历史尾页或 resync | 当前完整连续窗口 | 清空并按 `seq` 正序重建全部 Context | `replace()` |
| 加载一页 older history | 只传更早且去重后的 fresh Events | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` |
| 实时 append | 一条连续尾部 Event | 只匹配 Definitions 并精确更新命中 ID,边界只影响所属 Turn | `apply(upserts)` |
### 初始历史尾页与逻辑反扫
1. `Session.open()` 拉取最新 tail page,并把连续 History Entries 交给 `replaceWindow(entries, hasMore)`
2. `replaceWindow` 清空旧 Context、start-seq 索引、seq 反向索引、Reader 依赖和输入 Map。
3. 全部 entries 按 Event `seq` 升序排序并写入当前窗口。
4. LocationIndex 对这个窗口重建 Turn/Step facts。
5. Assembler 按升序 Event 逐条调用每个普通 Definition 的 `match(event)`
6. 每个命中结果按 `(kind, id)` 取得或创建 Context,并把 Match 插入该 Context 的有序数组。
7. 遇到 start 时执行 `start()`;已有 State 的尾部 update 直接执行 `update()`
8. 当前页只含 result/resource 而缺 start 时,Context 仍会按 ID 创建并收集 Matches,但 State 保持 `undefined`
9. 全部 Event 匹配后,Assembler 复查 Reader 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。
10. 所有 Context 标记 dirty,下一次 flush 先按 Step→Turn 完整重建 Location data,再对每个 target 调用 `buildViewNode()`
11. 某些业务在缺 start 时返回 `null`Compaction、Command、Tool result 或 Turn Error 等可根据充分 update 证据构造 fallback Node。
12. 每个 View Builder 收到完整 Node 集和 timeline,通过 `replace()` 建立初始 snapshot。
这条链路“从最新页开始”只发生在分页选择层。页面内部 State 始终正序计算,因此同一个窗口不会因为扫描方向不同产生不同业务结果。
缺 start 的 Context 不是错误。它是等待 older 页补齐的 pending 聚合容器;是否提前可见由该 Definition 的 `buildViewNode()` 决定。
若当前页中的同 ID update 在日志顺序上真的早于 start,而不是仅仅先被加载,补齐 start 后 replay 会报协议错误。到达顺序可以反向,业务日志顺序不能反向。
### 新 older 分页的 prepend
1. `Session.loadOlder()` 以当前 `baseSeq` 拉取紧邻前页,并先验证页尾与当前窗口连续。
2. Session 把 raw Event/view 数组 prepend 到自己的窗口,只把这一页传给 `assembler.prepend(entries, hasMore)`
3. Assembler 按 seq 去掉与当前窗口重叠的 Events,再把 fresh page 内部升序排列。
4. 已存在的 Context、State、current Nodes 和 View Builder 实例不清空。
5. LocationIndex 用扩展后的完整输入重建 facts,并报告 Location identity 真正变化的 seq。
6. 拥有这些 seq 的 Context 更新 Match Location,并从 start replay;无关 Context 不参与 Location replay。
7. fresh Events 逐条执行 Definition matcher,并按稳定 ID 插入已有或新 Context 的有序 Matches。
8. 新页补出 pending Context 的 start 时,该 Context 从 start 初始化,再正序应用已经收集的所有 updates。
9. 新页建立更近的 Reader predecessor、改变 predecessor revision 或消除 window gap 时,消费者从 `start()` 重算。
10. Reader 依赖沿 start seq 向后传递 replay;同一传播批次不会把 Event 逆序应用。
11. `hasMore` 从 true 变为 false 的空页也会复查依赖,把暂定 `undefined` 收敛为确定不存在。
12. flush 只为 dirty Context 重新发布 Step/Turn Location data 和 target Node,并把非空结果作为 `upserts` 交给 View Builder `apply()`
Prepend 保留已有 Context key 和 current Node identity。新页可以在 Chat `order` 前部增加 key,也可以修正既有 Node 的 anchor、Location、visibility 或 data,但不会为无关业务重新创建 Context。
Chat Builder 遇到结构变化时会从 keyed store 重算可见 `order` 和 Location 二级索引;这是视图索引计算,不会重新执行全部业务 Definition 或替换未变化 Node value。
Reader gap 修复是 prepend 与普通 append 最大的算法差异。新页不仅可能创建可见历史 Node,也可能改变后续 Inbox 瞬间态以及依赖它的 Message 分类。
### 正向实时 append
1. Session 只接受紧邻当前 tail seq 的 live Event;重叠 seq 去重,出现 gap 时先走 tail-page repair。
2. 非边界 Event 增量写入当前 Turn/Step 坐标;边界 Event 更新所属 Turn 的 Location facts。
3. Assembler 对这一个 Event 的每个普通 Definition 调用一次 `match()`,不会遍历任何 Definition 的 Context 集合。
4. 每个命中结果通过 `(kind, id)` 直接定位一个 Context。
5. 新 ID 创建 Context;已有 ID 的正常尾部 update 直接调用一次 `update()`
6. start 或任何需要插入非尾部位置的证据会走完整 `replayContext()`,保持同一正序语义。
7. Context revision 变化后,只沿已登记 Reader 依赖 replay 消费者。
8. Location close 会更新所属 Turn 中受影响 Match 的 Location,并 replay 这些 Context,使未完成 Assistant、Tool 或 Retry 得到 interrupted/cancelled 语气。
9. Assembler 汇总所有命中 Definition 的 publication urgency`immediate` 高于 `animation-frame`,后者高于 `none`
10. Session 把 immediate 交给 microtask notifier,把 animation-frame 交给 RAF notifier。
11. flush 先为 dirty Context 更新 Step/Turn Location data,再调用 `buildViewNode()`,最后把本轮 upserts 和最新 timeline 交给 View Builder。
12. React 订阅的新 snapshot 复用稳定 Context key;同一 Tool running→settled 或 Assistant streaming→final 不跨父节点移动。
Append 的业务匹配成本是 Definition 数量加实际命中的 Context 更新,不随历史 Context 数量增长。Reader 消费者和 Location 关闭会增加与真实依赖或所属 Turn 成比例的 replay。
Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新只替换 keyed store 中一个 Node,并 touch 所属 Location 索引。这里保证的是无关业务不 refold、Node identity 不替换,而不是宣称所有视图索引操作都是常数复杂度。
### Replace、prepend 与 append 的一致性
三条链路最终都遵守同一不变量:Context Matches 按 seq 排序,State 从唯一 start 正序 foldReader 只看严格前序 active ContextLocation data 按 Step→Turn 发布,Node key 只由 kind 和 ID 决定。
`replaceWindow` 是初始打开、resync、gap repair 和 registry 变化的低频完整替换,不用于实现普通 load older。`prepend``append` 都保留现有 Builder 和 Context identity。
分页页宽、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变窗口证据相同时的最终 Context State 与 Node。
## 内建业务如何使用 Definition
### 匹配、ID 与 State
| 业务 / `kind` | 稳定 ID | start Match | update Matches | State 与跨 Context 读取 |
|---|---|---|---|---|
| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice |
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 |
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent MapDispatch Event 用 `rootCallId` 精确路由 |
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compact/start` | summary、end、replacement checkpoint | 聚合 summary/checkpointcheckpoint 足够时可在缺 start 下 fallback |
| Retry / `model-retry` | retry ID | attempt 1 的 `llm/retry` | 后续 `llm/retry``llm/retry-started` | 聚合同一 RetryId 的 attempts 与 scheduled/started 状态 |
| Turn Error / `turn-error` | turn number | `turn/start` | error `turn/end` 与该 turn Retry Events | 聚合 terminal failure,并用 Retry 证据决定隐藏 |
| Turn Tail / `turn-tail` | turn number | `turn/start` | Assistant、Retry、`step/end``turn/end` | 保存 turn end,读取各 Step 的 Assistant data,发布 Turn data;完整 Matches 用于选择视觉尾部 anchor |
| Deliverables / `deliverables` | turn number | `turn/start` | 该 Turn 的 Tool call/result | 聚合成功 mutation paths 并发布 Turn data,不生成 view Node |
| Unknown fallback / `unknown-surface` | Event seq | 未被普通 Definition 认领的 append-surface Event | 无 | 保存原始 type/data 作为 JSON fallback |
### Chat Node 与历史/实时特性
| 业务 | `publication()` | Chat 产物 | 历史分页与运行时行为 |
|---|---|---|---|
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 |
| Message | 默认 immediate | `user``steering``context` | window gap 修复可让同一 message key 重新分类 |
| Assistant | chunk 为 RAFfinal immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallbackLocation close 生成中断表现 |
| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallbackrunning→settled 保持 key |
| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
| Compaction | 默认 immediate | `compaction` marker | checkpoint 可先展示,older 补 start 后正序 replay |
| Retry | 默认 immediate | 一个 `model-retry` Node 内含 attempts | 多次 retry 更新同一 keyLocation close 把最后 scheduled 表现为 cancelled |
| Turn Error | 默认 immediate | `turn-error` visible/hidden | 缺 start 可从 error end fallbackRetry 到达后保留 key 并隐藏 |
| Turn Tail | 仅 `turn/end` immediate,其余 none | 独立 `turn-tail` footer | 从 Step Assistant data 计算 closing/metrics,并通过同 turn Matches 决定 anchor |
| Deliverables | 默认 immediate | 不生成 Node | Tool 结算增量更新所属 Turn dataTurn Tail 扩展槽读取 produced files |
| Fallback | 默认 immediate | `unknown` JSON row | 只兜底 append surface,普通业务已认领但暂不可见时不会重复生成 |
Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。
Assistant、Turn Tail 和 Turn Error 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。
Assistant、Turn Tail 和 Deliverables 展示了 Location data 的分层组合。Assistant 负责写好每个 Step 的 `assistant-step` dataTurn Tail 从这些 Step values 计算 `turn-tail` dataDeliverables 独立维护同一 Turn 的 `deliverables` data。消费者只读取声明合并后的 key,不扫描其他业务 Node,也不取得提供方的 Context State。
Tool 和 Command 展示了多 Event 聚合:生产者提供共同 ID,Context 在业务内部构树或整合 Compaction,不把配对工作推给 Chat Builder。
Compaction 和历史 Tool result 展示了缺 start 时的业务 fallback。引擎不统一规定“没有 start 就不渲染”;Definition 根据当前 Matches 是否足够自行决定。
Retry 展示了业务 State 与 Location 的分工。scheduled/started 属于 Retry StateStep/Turn 是否关闭属于引擎 Location`buildViewNode()` 组合两者得到 cancelled 视觉状态。
Unknown fallback 展示了 Registry ownershipfallback 只处理没有任何普通 matcher 认领的 append surface Event,不会因为普通 Context 暂时返回 `null` 而误生成第二个 Node。
## View Builder 与 React identity
[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。
Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`keyed Node store 只替换该 key 的 value。
Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer`registerConversationNodes()``registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 `ui-conversation`,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。
`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected``ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slotSlot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 dataAssistant renderer 不拥有特殊注入权限。
Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 内部 selector 只返回当前 Node 的 `turn.data.get(key)`,无关 Session publication 会被 selector equality 截断。
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 的 data 和必要的排序属性,不再从末尾 running container 移入 finalized flow,因此组件内部 State 不因结算自动归零。
业务主动把已发布 Node 改成 hidden 时,它会退出 visible order,恢复 visible 时会重新 mount。这是明确的业务撤显语义,与 running→settled 的稳定 Seat 保证不同。
具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data`ui-tool` 再按 Tool name keyed slot 分发具体表现。
Trajectory 尚未注册 target,也不消费 Chat Builder 的 legacy slice。它已激活的 `SessionHistoryInspection` 继续维护独立 history fold,而普通 Session snapshot 不再运行第二套 transcript fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;未来迁移 Trajectory 不改变 Event Definition、Context、Reader 或 Location 契约。
## Runtime and render path
```text
Session Event window
-> ConversationNodeAssembler
-> Definition.match(event) -> (kind, id, start/update)
-> Context matches + State + Location
-> Definition.buildLocationData(step -> turn)
-> StepLocation.data / TurnLocation.data
-> Definition.buildViewNode(target = chat)
-> ChatSnapshotBuilder
-> order[] + keyed Node store + Location index + timeline
-> ChatView
-> ChatNodeSeat(key)
-> conversation.chat.node(entryKey = node.kind, hookContext = key)
-> slot-level useTurnData(businessKey)
```
## Verification
Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。
Conversation tests 覆盖全部内建 Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。
Slot type/runtime tests 固定父注册必须提供声明的 common inject、`hookContext` 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。
Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 Code Mode root + nested subcalls 与 master 的布局。
历史链路验证同时覆盖完整 replace、非重叠 prepend、重叠 seq 去重、空页 `hasMore` 收敛和 live append。相同 Event 窗口通过不同摄入路径得到相同业务 State 与最终 Node。
## Alternatives considered
**保留中心化 Session transcript fold,只抽 helper。** 拒绝:业务 identity、历史 replay 和 cache invalidation 仍属于一个闭合 switch,移动函数不会产生独立所有权。
**让 React renderer 自己扫描 Session Event。** 拒绝:每种 view 都会重复匹配和生命周期 State,React 会成为业务权威,paging 与 streaming 也会重算无关组件树。
**把全局 Nodes 或 Location 索引传给每个业务 renderer。** 拒绝:业务组件会自行扫描和推断当前 Turn/Step,订阅范围随窗口增长。Definition 把聚合值发布到 Engine-owned Locationrenderer 只读取自己 Node 的 Location data。
**每个新 Event 都调用同 Definition 的全部 Context。** 拒绝:append 成本随历史增长,`update()` 也会同时承担匹配与转换。无 Context 的 `match(event)` 先算出 ID,随后只更新一个 Context。
**让 Definition 的 matcher 读取 Context 或扫描历史。** 拒绝:匹配将依赖摄入方向,result-first 历史页无法独立算出归属,实时 append 也退化成开放对象查找。
**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。
**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。
**给跨业务查询注册特化 query method。** 拒绝:消费者仍要依赖提供方 API,新增关系会扩张中心接口。Reader 暴露指定 kind 的只读前序 Context,由提供方写好 State、消费者读懂 State。
**让 Location data 消费者直接读取提供方 Context State。** 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。
**增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 StateLocation close 触发 replay/buildReader dependency 负责补页失效。
**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在注册自己的 Builder 之前继续使用独立 history fold。
**在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。
**只在 Assistant renderer 注册 Turn data Hook。** 拒绝:访问当前 Node Location 是 `conversation.chat.node` slot 的公共能力,不属于某个业务 renderer。父 Chat entry 注册一次 common inject,所有 keyed renderer 共享同一强类型契约。
**把 running Assistant 或 Tool 保留在独立 tail container。** 拒绝:结算时会跨 React parent 移动,稳定业务 key 也无法阻止 remount。统一 keyed order 允许 data 和排序位置改变,但不改变 Seat identity。
## Consequences
新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer,不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。
Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deepseek-ai/dsh-session/types`Client Definition 则通过对应业务 package 的 `/types` 子路径进行 type-only import。增强实际声明接口而不是重导出 barrel,使 Host 和 Client 的独立 TypeScript Program 都能获得相同的 Event narrowing,同时不把 Host runtime 带入 Client 图。
初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。
Append 不扫描历史 Contextprepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。
State 更新与发布频率分离后,Assistant 每条 delta 都被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 不再依赖 renderer 扫描全局 NodesSlot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuildChat Builder 继续为 StatsLine 和顶层公共字段维护 legacy sliceTrajectory 继续拥有独立 history fold,内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
2026-07-30-web-transcript-log-ordered-projection.md: e261ccba597f27149a9527f99b93454dfe5fe5fb
2026-07-30-web-transcript-log-ordered-projection.zh.md: 5684a8157bb7e1b23cd868fa13ab201ffd07a274
2026-07-30-web-transcript-log-ordered-projection.md: f2b7c3830b585db619f69046917e07a0b6ff6832
2026-07-30-web-transcript-log-ordered-projection.zh.md: 77787f9613b64a35e79a8667c6598de5a20ff400
@@ -33,13 +33,13 @@ What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root
The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact'
```
Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it.
`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it.
The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
@@ -33,13 +33,13 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact'
```
重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根经由它可达的宿主侧 `Context` 合并。
`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根经由它可达的宿主侧 `Context` 合并隔离
因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
-1
View File
@@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
// Resolve the resident approval so the ordinary composer bar (which owns
// ContextMeter) resumes without replacing the session shell. This minimal
// boot graph intentionally does not mount the separate question UI plugin.
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import {
launchWebScaffold,
watchConsole,
@@ -160,6 +161,14 @@ function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>):
.join('')
}
function messageKey(event: SessionEvent<'user/message'>): string {
return conversationContextKey('input-message', String(event.data.id))
}
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
}
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
@@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& userText(event).includes(spec.userMarker)
)), { timeout: 15_000 }).toBe(true)
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
@@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
),
)
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await userRow.textContent()).toContain(spec.userMarker)
@@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
+33 -16
View File
@@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
@@ -115,6 +116,18 @@ function requiredEvent<T extends SessionEvent['type']>(
return event
}
function messageKey(event: SessionEvent<'user/message'>): string {
return conversationContextKey('input-message', String(event.data.id))
}
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
}
function turnTailKey(turn: number): string {
return conversationContextKey('turn-tail', String(turn))
}
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
@@ -176,8 +189,10 @@ describe('web e2e: long Chat interaction contract', () => {
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
const toolUserKey = messageKey(toolUserEvent)
const toolAssistantKey = assistantKey(toolAssistantEvent)
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
@@ -186,28 +201,27 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await call1.count()).toBe(1)
expect(await call2.count()).toBe(1)
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
expect(await call1.textContent()).toContain(toolMarker1)
expect(await call2.textContent()).toContain(toolMarker2)
const expectedOrder = [
`node:${String(toolUserEvent.seq)}`,
`call:${TARGET_CALL_1}`,
`call:${TARGET_CALL_2}`,
`node:${String(toolAssistantEvent.seq)}`,
toolUserKey,
conversationContextKey('tool-call', TARGET_CALL_1),
conversationContextKey('tool-call', TARGET_CALL_2),
toolAssistantKey,
]
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
.filter((key): key is string => key !== undefined && keys.includes(key))
), expectedOrder)
expect(actualOrder).toEqual(expectedOrder)
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
))))
expect(groupKeys[0]).not.toBeNull()
expect(groupKeys[1]).toBe(groupKeys[0])
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
@@ -219,9 +233,12 @@ describe('web e2e: long Chat interaction contract', () => {
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
const branchUserKey = messageKey(branchUserEvent)
const branchAssistantKey = assistantKey(branchAssistantEvent)
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
@@ -230,8 +247,8 @@ describe('web e2e: long Chat interaction contract', () => {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
.toBe(expectedUserText)
await assistantRow.hover()
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await turnTailRow.hover()
await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },
+23 -11
View File
@@ -9,6 +9,7 @@ import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
import {
assertFixtureInventory,
captureStableAria,
@@ -153,27 +154,36 @@ function expectReminderFraming(options: GenerateOptions): void {
)
}
/** Wait for one exact assistant reply and return its durable sequence. */
async function waitForReply(handle: AgentHandle, text: string, timeoutMs: number): Promise<number> {
/** Wait for and return one exact durable assistant reply. */
async function waitForReply(
handle: AgentHandle,
text: string,
timeoutMs: number,
): Promise<SessionEvent<'assistant/message'>> {
const deadline = Date.now() + timeoutMs
while (true) {
const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => (
candidate.type === 'assistant/message' && assistantText(candidate) === text
))
if (event !== undefined) return event.seq
if (event !== undefined) return event
if (Date.now() >= deadline) throw new Error(`assistant reply did not arrive within ${timeoutMs}ms: ${text}`)
await new Promise<void>(resolve => setTimeout(resolve, 20))
}
}
/** Resolve the semantic assistant-step key owned by the conversation assembler. */
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${String(event.data.turn)}:${String(event.data.step)}`)
}
describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
let scaffold: WebScaffold
let afterHandle: AgentHandle
let atHandle: AgentHandle
let browser: Browser
let page: Page
let afterAssistantSeq = -1
let atAssistantSeq = -1
let afterAssistantReply: SessionEvent<'assistant/message'> | undefined
let atAssistantReply: SessionEvent<'assistant/message'> | undefined
let tripwire: ReturnType<typeof watchConsole>
const afterAdapter = new ReminderAdapter()
const atAdapter = new BrowserZoneAtAdapter()
@@ -236,7 +246,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
state: 'scheduled',
deliveryMode: 'session-local',
})
afterAssistantSeq = await waitForReply(afterHandle, AFTER_REPLY, 15_000)
afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000)
await afterHandle.agent.whenIdle()
expect(afterAdapter.requests).toHaveLength(1)
const afterReminderRequest = afterAdapter.requests[0]
@@ -283,7 +293,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
await page.getByRole('button', { name: 'Send message', exact: true }).click()
expect(await settled).toBe(atHandle.agent.id)
await page.getByText(AT_ACK, { exact: true }).waitFor({ timeout: 15_000 })
atAssistantSeq = await waitForReply(atHandle, AT_REPLY, 20_000)
atAssistantReply = await waitForReply(atHandle, AT_REPLY, 20_000)
await atHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
}, 120_000)
@@ -302,10 +312,11 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after'))
const session = page.getByRole('treeitem', { name: /Scheduled After follow-up/ })
await session.click()
const selector = `[data-chat-anchor-key="node:${String(afterAssistantSeq)}"]`
if (afterAssistantReply === undefined) throw new Error('After assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(afterAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AFTER_REPLY)
await compareOrRefreshGolden(
AFTER_EXPECTED,
@@ -373,10 +384,11 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
const session = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
await session.click()
const selector = `[data-chat-anchor-key="node:${String(atAssistantSeq)}"]`
if (atAssistantReply === undefined) throw new Error('At assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(atAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AT_REPLY)
await compareOrRefreshGolden(
AT_EXPECTED,
+2 -2
View File
@@ -2,7 +2,7 @@
// Assembled search-card snapshot: boots the real built workspace client bundles
// through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// session, and pins the search card the `grep` turn (fixture turn 67) renders in
// the assembled application. The built-boot smoke proves the graph boots but
// carries no behavior assertions by contract; this is the assembled-output check
// that a broken SearchRow registration or a dropped card would fail — the
@@ -51,7 +51,7 @@ describe('assembled search card', () => {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
// Wait for chat content to reach the fixture's later turns (the bash sample
// is turn 65, the grep card turn 66).
// is turn 66, the grep card turn 67).
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
+14 -3
View File
@@ -91,11 +91,15 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
return taken
}
const commandId = 'cmd-seeded-manual-compact'
const compactionId = 'compact-seeded-manual-compact'
at({
type: 'command/run',
data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
})
const startSeq = at({ type: 'compact/start', data: { turn: null } })
const startSeq = at({
type: 'compact/start',
data: { compactionId, sourceCommandId: commandId, turn: null },
})
// Load-bearing exactness: the projections subtract this count verbatim, so
// it must equal what the host's fold prices for these nodes. The estimator
// prices message CONTENT only, so a minimal wrapper per storage shape is
@@ -124,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
const summarySeq = at({
type: 'compact/summary',
data: {
compactionId,
sourceCommandId: commandId,
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
@@ -142,12 +148,17 @@ function withCompaction(raw: string, meter: TokenMeterService): string {
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
source: {
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
},
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn: null } })
at({
type: 'compact/end',
data: { compactionId, sourceCommandId: commandId, turn: null },
})
at({
type: 'command/done',
data: {
+2
View File
@@ -109,6 +109,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
{ timeout: 10_000 },
).toBe(1)
const settled = scaffold.whenTurnSettled()
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
await composer.press('Enter')
@@ -135,6 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
// The injection started a turn; the replay adapter answers it.
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
await settled
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
@@ -14,10 +14,10 @@
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
@@ -1,6 +1 @@
- paragraph: "Reminder: Review the release window."
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
@@ -1,6 +1 @@
- paragraph: "Reminder: Check the deployment log."
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
+1 -1
View File
@@ -2,7 +2,7 @@
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the
// keyless FixtureApiClient transport, opens the fixture session, and pins the
// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`)
// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`)
// reaches — the `todo_write` tool row and the dock's plan strip.
//
// The row is pinned as three separate fields on purpose. `summary=` is the
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 1c4733eeaca2bce54761440bda3cee0ed9ab4c32
architecture.zh.md: ab6eea8f7656003c728796125c64d9242a37ceb4
architecture.md: 73e1cb70d97f4754113c9d0ed289063226b82b9f
architecture.zh.md: e34ac28839f1e11917a2fcae1ae25f4199e4a5af
+2 -1
View File
@@ -184,10 +184,11 @@ New behavior attaches to a documented extension point; a loop change updates thi
| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary |
| Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer |
| Add durable session state | extend `SessionEventMap`; render and replay from the log |
| Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider |
| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |
| Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) |
The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
+2 -1
View File
@@ -184,10 +184,11 @@ idle inject:
| 拦截请求、工具或轮次 | 使用相应的 `agent/*``tools/*` 事件;`agent/turn-stopping` 是停止边界 |
| 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 |
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 |
| Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer |
| 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 |
| 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 |
| 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent``agent/*` 续跑 |
| fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) |
[扩展实操手册cookbook](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。
[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。
+1 -1
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 4e41f55dfd12362acb580c12062dc424ce094fad
config-catalog.md: a71a50e650e86e0e117d82b274fd1129a7673af0
config-catalog.zh.md: 0f065d86596d83a9141032824bb76c86e7c65d22
+2 -2
View File
@@ -917,7 +917,7 @@ Requires: `agents`
export type Config = Readonly<Record<string, never>>
```
Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts)
Source: [`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts)
## `@deepseek-ai/dsh-lsp-local`
@@ -2325,7 +2325,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:624`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-typert-loader`
@@ -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 docs/cookbook/adding-a-conversation-node.md
adding-a-conversation-node.md: ea4ec73eb109af6b0e4c7cf50fc8692942c75dd4
adding-a-conversation-node.zh.md: 4b9a8049e2f1d060ec4bc3334036559b989ea562
+232
View File
@@ -0,0 +1,232 @@
# Add a Web Client conversation node
English | [中文](adding-a-conversation-node.zh.md)
This tutorial adds one business-owned row to the Web Client Chat view. The finished plugin correlates a durable Session event family into one Context, incrementally builds business State, publishes typed Step data, and renders a keyed Chat Node without scanning the Session window or other rendered nodes. It assumes the Host already records the events and the client plugin is composed into the Web bundle; external Host-side UIs and additional view targets such as Trajectory are outside this tutorial.
The [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns the rationale and complete engine model. This guide covers the implementation path.
## 1. Design a replayable event family
Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context.
For a review job, the event contract could be:
| Event | Role | Required durable facts |
|---|---|---|
| `review/start` | unique start | `reviewId`, Turn/Step coordinates, title |
| `review/progress` | update | the same `reviewId`, coordinates, replayable progress |
| `review/end` | update | the same `reviewId`, coordinates, final summary |
Use the producer-owned branded id type across the process boundary. Put the `SessionEventMap` merge and payload types on the producer's type-only export, then import that export for side effects from the client package. Each `(kind, id)` may have at most one start event. A single-event business can use the event's stable identity, such as `event.seq`, as its Definition-local id.
Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events.
## 2. Implement the Definition and typed Chat payload
The example keeps the producer declarations and client contribution in one block so the complete relationship is visible. In a package family, keep the branded id and `SessionEventMap` declaration with the event producer, and keep the Definition, Chat data merge, and renderer in the client plugin.
```ts ignore-check
import { createElement } from 'react'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {
ClientContext, ConversationLocation, ConversationNodeContext,
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
type ReviewId = Branded<'ReviewId'>
interface ReviewStartData {
readonly reviewId: ReviewId
readonly turn: number
readonly step: number
readonly title: string
}
interface ReviewProgressData {
readonly reviewId: ReviewId
readonly turn: number
readonly step: number
readonly completed: number
}
interface ReviewEndData {
readonly reviewId: ReviewId
readonly turn: number
readonly step: number
readonly summary: string
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Opens one durable review job.
* @mode emit
* @param data - stable identity, location, and initial display state.
*/
'review/start': ReviewStartData
/**
* Records replayable progress for one review job.
* @mode emit
* @param data - stable identity, location, and latest progress.
*/
'review/progress': ReviewProgressData
/**
* Closes one review job with its final summary.
* @mode emit
* @param data - stable identity, location, and final display state.
*/
'review/end': ReviewEndData
}
}
interface ReviewChatData {
readonly title: string
readonly completed: number
readonly status: 'running' | 'completed'
readonly summary?: string
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
'review-job': ReviewChatData
}
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationStepDataMap {
'review-job': ReviewChatData
}
}
interface ReviewState extends ReviewChatData {
readonly turn: number
readonly step: number
}
function locationOf(context: ConversationNodeContext): ConversationLocation {
return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
}
function viewData(state: ReviewState): ReviewChatData {
return {
title: state.title,
completed: state.completed,
status: state.status,
...state.summary === undefined ? {} : { summary: state.summary },
}
}
const reviewDefinition: ConversationNodeDefinition<ReviewState> = {
kind: 'review-job',
match: (event) => {
if (event.type === 'review/start') {
return { id: String(event.data.reviewId), role: 'start' }
}
if (event.type === 'review/progress' || event.type === 'review/end') {
return { id: String(event.data.reviewId), role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'review/start') throw new Error('review-job requires review/start')
return {
turn: match.event.data.turn,
step: match.event.data.step,
title: match.event.data.title,
completed: 0,
status: 'running',
}
},
update: (context, match) => {
if (match.event.type === 'review/progress') {
return { ...context.state, completed: match.event.data.completed }
}
if (match.event.type === 'review/end') {
return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary }
}
return context.state
},
publication: match => match.event.type === 'review/progress'
? 'animation-frame'
: 'immediate',
buildLocationData: (context, scope) => {
if (scope !== 'step' || context.state === undefined) return null
return {
kind: 'step',
turn: context.state.turn,
step: context.state.step,
key: 'review-job',
value: viewData(context.state),
}
},
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined) return null
return {
key: context.key,
kind: 'review-job',
id: context.id,
target: 'chat',
anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0,
location: locationOf(context),
visibility: 'visible',
data: viewData(context.state),
}
},
}
function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) {
const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%`
return createElement('p', null, text)
}
export const inject = ['conversationEvents', 'slots']
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(reviewDefinition)
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'review-job',
}, ReviewNodeView))
}
```
`match(event)` is an identity extractor, not a fold: it receives only the current event and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once or `update` with the current State. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
`buildViewNode(context, target)` materializes the final target-specific Node. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
## 3. Query an earlier business Context only at start
Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous<State>(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data.
The assembler records that dependency. If an older prepend later supplies a nearer predecessor, closes a previously unknown window gap, or revises the predecessor State, it reruns the dependent Context from `start` and replays its updates in ascending `seq`. The queried Definition remains responsible for writing useful State; the reader exposes no business-specific query methods and grants no mutation authority over another Context.
## 4. Understand the three ingestion paths
History may be requested from the tail backward one page at a time, but every accepted page is normalized into ascending `seq` before State replay.
| Path | Engine work | Definition-visible behavior |
|---|---|---|
| Replace on open, resync, or gap repair | Rebuild the loaded window, match every event once per Definition, then replay each started Context | `start`, followed by its updates in ascending `seq`; pending update-only Contexts remain without State |
| Prepend one older page | Match only fresh older events, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found start activates its collected updates; a changed Location or predecessor may rerun the Context |
| Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One `update` and one requested publication for a matching post-start event; no existing Context scan |
With `D` registered Definitions, one incoming event performs `D` current-event matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies.
`publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine still applies every update in log order; cadence only coalesces view publication.
## 5. Verify replay, pagination, and rendering
Add focused tests that establish these outcomes:
1. A complete window passed through replace produces the expected final State, Location data, Node payload, and `anchorSeq`.
2. An update-only tail stays pending; prepending the unique start produces the same result as a complete replace.
3. Initial history followed by live append produces the same result as replaying the combined window.
4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change.
5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested.
6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes.
Use [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node.
@@ -0,0 +1,232 @@
# 添加 Web Client Conversation Node
[English](adding-a-conversation-node.md) | 中文
本教程为 Web Client Chat 视图添加一行由业务自行拥有的内容。完成后的插件会把一个持久 Session 事件族关联成一个 Context,增量构造业务 State,发布类型化 Step 数据,再渲染 keyed Chat Node;整个过程不扫描 Session 窗口或其他已渲染节点。本教程假设 Host 已经记录这些事件,且该 Client 插件已组装进 Web bundleHost 侧外部 UI 和 Trajectory 等额外视图目标不在本文范围内。
[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md)记录完整的引擎模型和设计理由;本文只说明实现路径。
## 1. 设计可回放的事件族
编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 idClient 绝不能把 update 猜测为属于“最近一个未完成”的 Context。
以一个 review job 为例,事件约定可以是:
| 事件 | 角色 | 必须持久化的事实 |
|---|---|---|
| `review/start` | 唯一 start | `reviewId`、Turn/Step 坐标、标题 |
| `review/progress` | update | 相同的 `reviewId`、坐标、可回放进度 |
| `review/end` | update | 相同的 `reviewId`、坐标、最终摘要 |
跨进程边界使用生产方拥有的 branded id 类型。把 `SessionEventMap` 合并和 payload 类型放在生产方的纯类型导出中,再由 Client 包通过仅类型副作用导入该导出。每个 `(kind, id)` 最多只能有一条 start 事件。单事件业务可以把事件自身的稳定身份(例如 `event.seq`)作为 Definition 内部 id。
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
## 2. 实现 Definition 与类型化 Chat payload
为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。
```ts ignore-check
import { createElement } from 'react'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {
ClientContext, ConversationLocation, ConversationNodeContext,
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
type ReviewId = Branded<'ReviewId'>
interface ReviewStartData {
readonly reviewId: ReviewId
readonly turn: number
readonly step: number
readonly title: string
}
interface ReviewProgressData {
readonly reviewId: ReviewId
readonly turn: number
readonly step: number
readonly completed: number
}
interface ReviewEndData {
readonly reviewId: ReviewId
readonly turn: number
readonly step: number
readonly summary: string
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Opens one durable review job.
* @mode emit
* @param data - stable identity, location, and initial display state.
*/
'review/start': ReviewStartData
/**
* Records replayable progress for one review job.
* @mode emit
* @param data - stable identity, location, and latest progress.
*/
'review/progress': ReviewProgressData
/**
* Closes one review job with its final summary.
* @mode emit
* @param data - stable identity, location, and final display state.
*/
'review/end': ReviewEndData
}
}
interface ReviewChatData {
readonly title: string
readonly completed: number
readonly status: 'running' | 'completed'
readonly summary?: string
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
'review-job': ReviewChatData
}
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationStepDataMap {
'review-job': ReviewChatData
}
}
interface ReviewState extends ReviewChatData {
readonly turn: number
readonly step: number
}
function locationOf(context: ConversationNodeContext): ConversationLocation {
return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
}
function viewData(state: ReviewState): ReviewChatData {
return {
title: state.title,
completed: state.completed,
status: state.status,
...state.summary === undefined ? {} : { summary: state.summary },
}
}
const reviewDefinition: ConversationNodeDefinition<ReviewState> = {
kind: 'review-job',
match: (event) => {
if (event.type === 'review/start') {
return { id: String(event.data.reviewId), role: 'start' }
}
if (event.type === 'review/progress' || event.type === 'review/end') {
return { id: String(event.data.reviewId), role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'review/start') throw new Error('review-job requires review/start')
return {
turn: match.event.data.turn,
step: match.event.data.step,
title: match.event.data.title,
completed: 0,
status: 'running',
}
},
update: (context, match) => {
if (match.event.type === 'review/progress') {
return { ...context.state, completed: match.event.data.completed }
}
if (match.event.type === 'review/end') {
return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary }
}
return context.state
},
publication: match => match.event.type === 'review/progress'
? 'animation-frame'
: 'immediate',
buildLocationData: (context, scope) => {
if (scope !== 'step' || context.state === undefined) return null
return {
kind: 'step',
turn: context.state.turn,
step: context.state.step,
key: 'review-job',
value: viewData(context.state),
}
},
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined) return null
return {
key: context.key,
kind: 'review-job',
id: context.id,
target: 'chat',
anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0,
location: locationOf(context),
visibility: 'visible',
data: viewData(context.state),
}
},
}
function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) {
const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%`
return createElement('p', null, text)
}
export const inject = ['conversationEvents', 'slots']
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(reviewDefinition)
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'review-job',
}, ReviewNodeView))
}
```
`match(event)` 是身份提取器,不是 fold:它只能收到当前事件,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context,再调用一次 `start`,或把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
`buildViewNode(context, target)` 物化最终的目标专用 Node。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。
## 3. 只在 start 时查询更早的业务 Context
有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous<State>(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。
Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的前序 Context、补齐了原先未知的窗口缺口,或者前序 State 被修订,引擎会从 `start` 重新运行依赖方 Context,并按 `seq` 升序回放其 update。被查询的 Definition 仍负责把有用信息写入自身 State;Reader 不提供业务专用查询方法,也不授予修改其他 Context 的权限。
## 4. 理解三条摄入路径
历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 `seq` 升序归一化,再进入 State 回放。
| 路径 | 引擎工作 | Definition 可观察到的行为 |
|---|---|---|
| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条事件对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State |
| prepend 一页更早历史 | 只匹配新增的更早事件,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 start 会激活已收集 updateLocation 或前序依赖变化也可能重跑 Context |
| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 `update` 并请求一次发布;不扫描已有 Context |
注册 `D` 个 Definition 时,一条新事件会进行 `D` 次仅当前事件匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`。
`publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎仍会按日志顺序应用每条 update;该选项只合并视图发布频率。
## 5. 验证回放、分页与渲染
添加聚焦测试,证明以下结果:
1. 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与 `anchorSeq`。
2. 只有 update 的尾部窗口保持 pendingprepend 唯一 start 后,结果与完整 replace 相同。
3. 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。
4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。
5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。
6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。
流式与中断处理可参考 [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md
extension-cookbook.md: 025a1b6ecd11593b5d6be9d0f64e57ac8b5e3139
extension-cookbook.zh.md: f838a281fabdba473b72b27f7b14274d9aa97528
extension-cookbook.md: 5d9312f2f5cf840100b12045bde1829b342e580d
extension-cookbook.zh.md: 29bb57c558a0ff9413619d0bb2e8bb5e6b70da56
+2 -1
View File
@@ -34,7 +34,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an
## A UI plugin
A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`.
A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation Node guide](adding-a-conversation-node.md).
```ts
import type { Context } from 'cordis'
@@ -123,6 +123,7 @@ Every product feature maps to a listener on a documented extension point — the
| Memory | section provider + tool |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` |
| Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer |
| Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
| Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) |
| Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works |
+2 -1
View File
@@ -34,7 +34,7 @@ export function apply(ctx: Context) {
## UI 插件
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体步骤见 [Conversation Node 指南](adding-a-conversation-node.md)。
```ts
import type { Context } from 'cordis'
@@ -123,6 +123,7 @@ export function apply(ctx: Context) {
| 记忆 | section 提供方 + 工具 |
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
| UI(GUICLI(命令行界面)输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` |
| Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition``conversation.chat.node` keyed renderer |
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek``dsh-llm-pi-ai` |
| 插件热重载 | 每个注册都是一个 `ctx.effect` → 随仓库提供的 HMR(热模块替换)直接生效 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: d1cb21e7f8dadcb517b62580f4f5e6305e965e5a
event-producer-consumer.zh.md: bc323d3c4a6643a98f4285d6d42e92ba0a0c7017
event-producer-consumer.md: c9c500d3337981a79ab23b3ffea828a29eac7dc0
event-producer-consumer.zh.md: dabbda3bb342afa0e14cbb732ec76bc8aa9873f3
+19 -19
View File
@@ -8,20 +8,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server`, [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server`, [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
@@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+22 -22
View File
@@ -10,20 +10,20 @@
| 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server`, [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server`, [`tool-schedule`](../packages/schedule/tool-schedule) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
@@ -46,12 +46,12 @@
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -59,9 +59,9 @@
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## 包源码中出现的非 harness 或未声明事件字符串
## Non-harness or undeclared event strings seen in package source
| 事件字符串 | 派发方 | 监听方 |
| Event string | Dispatchers | Listeners |
| --- | --- | --- |
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
@@ -80,4 +80,4 @@
| `slots/changed` | `runtime` (`emit`) | - |
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |
维护模式:英文源文件是生成内容,Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析;本中文文件作为经评审对侧通过双语配对维护。
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: e3d58401db3ca81966b701e35325f8b094f8ac0a
module-graph.zh.md: c1095a5865532d6ddc8fa0ca89e9f7e098236b5c
module-graph.md: b2c76f11d2ed57383f953b26327edffdbade1ee4
module-graph.zh.md: cd882722c2f628c7c9e142ce1ba833cf4f557949
+131 -122
View File
@@ -439,9 +439,6 @@ flowchart TD
pkg_fs --> pkg_sandbox
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_compact --> pkg_invariants
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
pkg_web_fetch_local --> pkg_invariants
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
@@ -505,15 +502,11 @@ flowchart TD
pkg_session_projection --> pkg_invariants
pkg_session_projection --> pkg_session
pkg_llm_retry --> pkg_agent
pkg_llm_retry --> pkg_brand
pkg_llm_retry --> pkg_invariants
pkg_llm_retry --> pkg_llm
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_timeout
pkg_token_meter --> pkg_compact
pkg_token_meter --> pkg_invariants
pkg_token_meter --> pkg_llm
pkg_token_meter --> pkg_session
pkg_token_meter --> pkg_session_projection
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_invariants
pkg_agent_default_model --> pkg_llm
@@ -553,10 +546,6 @@ flowchart TD
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_invariants
pkg_hook_protocol --> pkg_session
pkg_llm_replay --> pkg_compact
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_loader_smoke --> pkg_agent
pkg_loader_smoke --> pkg_invariants
pkg_loader_smoke --> pkg_llm
@@ -677,14 +666,11 @@ flowchart TD
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_command_compact --> pkg_commands
pkg_command_compact --> pkg_compact
pkg_command_compact --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_compact
pkg_compact_tool_result_prune --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_llm
pkg_compact_tool_result_prune --> pkg_session
pkg_compact_tool_result_prune --> pkg_token_meter
pkg_compact --> pkg_brand
pkg_compact --> pkg_commands
pkg_compact --> pkg_invariants
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
pkg_session_query --> pkg_brand
pkg_session_query --> pkg_invariants
pkg_session_query --> pkg_llm
@@ -706,13 +692,6 @@ flowchart TD
pkg_headless --> pkg_invariants
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_client_ui_conversation --> pkg_client_locale
pkg_client_ui_conversation --> pkg_client_runtime
pkg_client_ui_conversation --> pkg_client_ui_primitives
pkg_client_ui_conversation --> pkg_client_ui_slash
pkg_client_ui_conversation --> pkg_client_ui_slots
pkg_client_ui_conversation --> pkg_invariants
pkg_client_ui_conversation --> pkg_token_meter
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
@@ -745,6 +724,11 @@ flowchart TD
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
pkg_tasks_local --> pkg_timeout
pkg_token_meter --> pkg_compact
pkg_token_meter --> pkg_invariants
pkg_token_meter --> pkg_llm
pkg_token_meter --> pkg_session
pkg_token_meter --> pkg_session_projection
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_invariants
pkg_agent_loop --> pkg_llm
@@ -793,13 +777,9 @@ flowchart TD
pkg_tool_skill --> pkg_llm
pkg_tool_skill --> pkg_skill
pkg_tool_skill --> pkg_tools
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_compact_tool_result_prune
pkg_compact_basic --> pkg_invariants
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_compact_basic --> pkg_token_meter
pkg_command_compact --> pkg_commands
pkg_command_compact --> pkg_compact
pkg_command_compact --> pkg_invariants
pkg_subagent --> pkg_agent
pkg_subagent --> pkg_brand
pkg_subagent --> pkg_invariants
@@ -860,33 +840,10 @@ flowchart TD
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_client_ui_command --> pkg_client_connection
pkg_client_ui_command --> pkg_client_locale
pkg_client_ui_command --> pkg_client_runtime
pkg_client_ui_command --> pkg_client_ui_conversation
pkg_client_ui_command --> pkg_client_ui_primitives
pkg_client_ui_command --> pkg_client_ui_slash
pkg_client_ui_command --> pkg_client_ui_slots
pkg_client_ui_command --> pkg_invariants
pkg_client_ui_deliverables --> pkg_client_locale
pkg_client_ui_deliverables --> pkg_client_runtime
pkg_client_ui_deliverables --> pkg_client_ui_conversation
pkg_client_ui_deliverables --> pkg_client_ui_slots
pkg_client_ui_deliverables --> pkg_invariants
pkg_client_ui_goal --> pkg_api_remotes
pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
pkg_client_ui_goal --> pkg_client_ui_conversation
pkg_client_ui_goal --> pkg_client_ui_primitives
pkg_client_ui_goal --> pkg_client_ui_slots
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_tool --> pkg_client_locale
pkg_client_ui_tool --> pkg_client_runtime
pkg_client_ui_tool --> pkg_client_ui_conversation
pkg_client_ui_tool --> pkg_client_ui_primitives
pkg_client_ui_tool --> pkg_client_ui_slots
pkg_client_ui_tool --> pkg_invariants
pkg_llm_replay --> pkg_compact
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_session_reference --> pkg_agent
pkg_session_reference --> pkg_compact
pkg_session_reference --> pkg_invariants
@@ -1000,6 +957,11 @@ flowchart TD
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tasks
pkg_tool_pwsh --> pkg_tools
pkg_compact_tool_result_prune --> pkg_compact
pkg_compact_tool_result_prune --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_llm
pkg_compact_tool_result_prune --> pkg_session
pkg_compact_tool_result_prune --> pkg_token_meter
pkg_subagent_acp --> pkg_agent
pkg_subagent_acp --> pkg_invariants
pkg_subagent_acp --> pkg_llm
@@ -1048,50 +1010,18 @@ flowchart TD
pkg_web_app --> pkg_bash_env
pkg_web_app --> pkg_invariants
pkg_web_app --> pkg_system_prompt
pkg_client_ui_model --> pkg_client_connection
pkg_client_ui_model --> pkg_client_locale
pkg_client_ui_model --> pkg_client_runtime
pkg_client_ui_model --> pkg_client_ui_command
pkg_client_ui_model --> pkg_client_ui_conversation
pkg_client_ui_model --> pkg_client_ui_primitives
pkg_client_ui_model --> pkg_client_ui_slash
pkg_client_ui_model --> pkg_client_ui_slots
pkg_client_ui_model --> pkg_invariants
pkg_client_ui_permission --> pkg_client_connection
pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
pkg_client_ui_permission --> pkg_client_schema_form
pkg_client_ui_permission --> pkg_client_ui_command
pkg_client_ui_permission --> pkg_client_ui_primitives
pkg_client_ui_permission --> pkg_client_ui_slash
pkg_client_ui_permission --> pkg_client_ui_slots
pkg_client_ui_permission --> pkg_invariants
pkg_client_ui_permission --> pkg_permission
pkg_client_ui_plan --> pkg_client_connection
pkg_client_ui_plan --> pkg_client_locale
pkg_client_ui_plan --> pkg_client_runtime
pkg_client_ui_plan --> pkg_client_ui_conversation
pkg_client_ui_plan --> pkg_client_ui_primitives
pkg_client_ui_plan --> pkg_client_ui_slots
pkg_client_ui_plan --> pkg_invariants
pkg_client_ui_plan --> pkg_plan_mode
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_locale
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_primitives
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_client_ui_tool
pkg_client_ui_skill --> pkg_invariants
pkg_client_ui_subagent --> pkg_client_locale
pkg_client_ui_subagent --> pkg_client_runtime
pkg_client_ui_subagent --> pkg_client_ui_conversation
pkg_client_ui_subagent --> pkg_client_ui_primitives
pkg_client_ui_subagent --> pkg_client_ui_slash
pkg_client_ui_subagent --> pkg_client_ui_slots
pkg_client_ui_subagent --> pkg_invariants
pkg_client_ui_subagent --> pkg_subagent
pkg_client_ui_subagent --> pkg_token_meter
pkg_client_ui_conversation --> pkg_agent
pkg_client_ui_conversation --> pkg_client_locale
pkg_client_ui_conversation --> pkg_client_runtime
pkg_client_ui_conversation --> pkg_client_ui_primitives
pkg_client_ui_conversation --> pkg_client_ui_slash
pkg_client_ui_conversation --> pkg_client_ui_slots
pkg_client_ui_conversation --> pkg_commands
pkg_client_ui_conversation --> pkg_compact
pkg_client_ui_conversation --> pkg_invariants
pkg_client_ui_conversation --> pkg_llm_retry
pkg_client_ui_conversation --> pkg_token_meter
pkg_client_ui_conversation --> pkg_tools
pkg_sdk_protocol --> pkg_invariants
pkg_sdk_protocol --> pkg_llm
pkg_sdk_protocol --> pkg_session
@@ -1115,6 +1045,14 @@ flowchart TD
pkg_workflow_workerthread --> pkg_subagent
pkg_workflow_workerthread --> pkg_tools
pkg_workflow_workerthread --> pkg_workflow
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_commands
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_compact_tool_result_prune
pkg_compact_basic --> pkg_invariants
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_compact_basic --> pkg_token_meter
pkg_subagent_codex --> pkg_invariants
pkg_subagent_codex --> pkg_llm
pkg_subagent_codex --> pkg_sdk_protocol
@@ -1130,6 +1068,50 @@ flowchart TD
pkg_subagent_spawn --> pkg_invariants
pkg_subagent_spawn --> pkg_subagent
pkg_subagent_spawn --> pkg_subagent_inprocess
pkg_client_ui_command --> pkg_client_connection
pkg_client_ui_command --> pkg_client_locale
pkg_client_ui_command --> pkg_client_runtime
pkg_client_ui_command --> pkg_client_ui_conversation
pkg_client_ui_command --> pkg_client_ui_primitives
pkg_client_ui_command --> pkg_client_ui_slash
pkg_client_ui_command --> pkg_client_ui_slots
pkg_client_ui_command --> pkg_invariants
pkg_client_ui_deliverables --> pkg_client_locale
pkg_client_ui_deliverables --> pkg_client_runtime
pkg_client_ui_deliverables --> pkg_client_ui_conversation
pkg_client_ui_deliverables --> pkg_client_ui_slots
pkg_client_ui_deliverables --> pkg_invariants
pkg_client_ui_goal --> pkg_api_remotes
pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
pkg_client_ui_goal --> pkg_client_ui_conversation
pkg_client_ui_goal --> pkg_client_ui_primitives
pkg_client_ui_goal --> pkg_client_ui_slots
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_plan --> pkg_client_connection
pkg_client_ui_plan --> pkg_client_locale
pkg_client_ui_plan --> pkg_client_runtime
pkg_client_ui_plan --> pkg_client_ui_conversation
pkg_client_ui_plan --> pkg_client_ui_primitives
pkg_client_ui_plan --> pkg_client_ui_slots
pkg_client_ui_plan --> pkg_invariants
pkg_client_ui_plan --> pkg_plan_mode
pkg_client_ui_subagent --> pkg_client_locale
pkg_client_ui_subagent --> pkg_client_runtime
pkg_client_ui_subagent --> pkg_client_ui_conversation
pkg_client_ui_subagent --> pkg_client_ui_primitives
pkg_client_ui_subagent --> pkg_client_ui_slash
pkg_client_ui_subagent --> pkg_client_ui_slots
pkg_client_ui_subagent --> pkg_invariants
pkg_client_ui_subagent --> pkg_subagent
pkg_client_ui_subagent --> pkg_token_meter
pkg_client_ui_tool --> pkg_client_locale
pkg_client_ui_tool --> pkg_client_runtime
pkg_client_ui_tool --> pkg_client_ui_conversation
pkg_client_ui_tool --> pkg_client_ui_primitives
pkg_client_ui_tool --> pkg_client_ui_slots
pkg_client_ui_tool --> pkg_invariants
pkg_agent_spine_demo --> pkg_agent
pkg_agent_spine_demo --> pkg_agent_loop
pkg_agent_spine_demo --> pkg_bash_env
@@ -1171,6 +1153,33 @@ flowchart TD
pkg_subagent_dsh_sdk --> pkg_session
pkg_subagent_dsh_sdk --> pkg_subagent
pkg_subagent_dsh_sdk --> pkg_subprocess
pkg_client_ui_model --> pkg_client_connection
pkg_client_ui_model --> pkg_client_locale
pkg_client_ui_model --> pkg_client_runtime
pkg_client_ui_model --> pkg_client_ui_command
pkg_client_ui_model --> pkg_client_ui_conversation
pkg_client_ui_model --> pkg_client_ui_primitives
pkg_client_ui_model --> pkg_client_ui_slash
pkg_client_ui_model --> pkg_client_ui_slots
pkg_client_ui_model --> pkg_invariants
pkg_client_ui_permission --> pkg_client_connection
pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
pkg_client_ui_permission --> pkg_client_schema_form
pkg_client_ui_permission --> pkg_client_ui_command
pkg_client_ui_permission --> pkg_client_ui_primitives
pkg_client_ui_permission --> pkg_client_ui_slash
pkg_client_ui_permission --> pkg_client_ui_slots
pkg_client_ui_permission --> pkg_invariants
pkg_client_ui_permission --> pkg_permission
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_locale
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_primitives
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_client_ui_tool
pkg_client_ui_skill --> pkg_invariants
pkg_acp_demo --> pkg_acp
pkg_acp_demo --> pkg_agent_spine_demo
pkg_acp_demo --> pkg_app_boot
@@ -1248,7 +1257,6 @@ flowchart TD
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) |
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
@@ -1265,8 +1273,7 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1277,7 +1284,6 @@ flowchart TD
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
@@ -1305,19 +1311,18 @@ flowchart TD
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
@@ -1325,7 +1330,7 @@ flowchart TD
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
@@ -1335,10 +1340,7 @@ flowchart TD
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -1358,6 +1360,7 @@ flowchart TD
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
@@ -1366,20 +1369,26 @@ flowchart TD
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) |
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) |
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+131 -122
View File
@@ -441,9 +441,6 @@ flowchart TD
pkg_fs --> pkg_sandbox
pkg_skill_badge --> pkg_invariants
pkg_skill_badge --> pkg_skill
pkg_compact --> pkg_invariants
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
pkg_web_fetch_local --> pkg_invariants
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
@@ -507,15 +504,11 @@ flowchart TD
pkg_session_projection --> pkg_invariants
pkg_session_projection --> pkg_session
pkg_llm_retry --> pkg_agent
pkg_llm_retry --> pkg_brand
pkg_llm_retry --> pkg_invariants
pkg_llm_retry --> pkg_llm
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_timeout
pkg_token_meter --> pkg_compact
pkg_token_meter --> pkg_invariants
pkg_token_meter --> pkg_llm
pkg_token_meter --> pkg_session
pkg_token_meter --> pkg_session_projection
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_invariants
pkg_agent_default_model --> pkg_llm
@@ -555,10 +548,6 @@ flowchart TD
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_invariants
pkg_hook_protocol --> pkg_session
pkg_llm_replay --> pkg_compact
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_loader_smoke --> pkg_agent
pkg_loader_smoke --> pkg_invariants
pkg_loader_smoke --> pkg_llm
@@ -679,14 +668,11 @@ flowchart TD
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_command_compact --> pkg_commands
pkg_command_compact --> pkg_compact
pkg_command_compact --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_compact
pkg_compact_tool_result_prune --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_llm
pkg_compact_tool_result_prune --> pkg_session
pkg_compact_tool_result_prune --> pkg_token_meter
pkg_compact --> pkg_brand
pkg_compact --> pkg_commands
pkg_compact --> pkg_invariants
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
pkg_session_query --> pkg_brand
pkg_session_query --> pkg_invariants
pkg_session_query --> pkg_llm
@@ -708,13 +694,6 @@ flowchart TD
pkg_headless --> pkg_invariants
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_client_ui_conversation --> pkg_client_locale
pkg_client_ui_conversation --> pkg_client_runtime
pkg_client_ui_conversation --> pkg_client_ui_primitives
pkg_client_ui_conversation --> pkg_client_ui_slash
pkg_client_ui_conversation --> pkg_client_ui_slots
pkg_client_ui_conversation --> pkg_invariants
pkg_client_ui_conversation --> pkg_token_meter
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
@@ -747,6 +726,11 @@ flowchart TD
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
pkg_tasks_local --> pkg_timeout
pkg_token_meter --> pkg_compact
pkg_token_meter --> pkg_invariants
pkg_token_meter --> pkg_llm
pkg_token_meter --> pkg_session
pkg_token_meter --> pkg_session_projection
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_invariants
pkg_agent_loop --> pkg_llm
@@ -795,13 +779,9 @@ flowchart TD
pkg_tool_skill --> pkg_llm
pkg_tool_skill --> pkg_skill
pkg_tool_skill --> pkg_tools
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_compact_tool_result_prune
pkg_compact_basic --> pkg_invariants
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_compact_basic --> pkg_token_meter
pkg_command_compact --> pkg_commands
pkg_command_compact --> pkg_compact
pkg_command_compact --> pkg_invariants
pkg_subagent --> pkg_agent
pkg_subagent --> pkg_brand
pkg_subagent --> pkg_invariants
@@ -862,33 +842,10 @@ flowchart TD
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_client_ui_command --> pkg_client_connection
pkg_client_ui_command --> pkg_client_locale
pkg_client_ui_command --> pkg_client_runtime
pkg_client_ui_command --> pkg_client_ui_conversation
pkg_client_ui_command --> pkg_client_ui_primitives
pkg_client_ui_command --> pkg_client_ui_slash
pkg_client_ui_command --> pkg_client_ui_slots
pkg_client_ui_command --> pkg_invariants
pkg_client_ui_deliverables --> pkg_client_locale
pkg_client_ui_deliverables --> pkg_client_runtime
pkg_client_ui_deliverables --> pkg_client_ui_conversation
pkg_client_ui_deliverables --> pkg_client_ui_slots
pkg_client_ui_deliverables --> pkg_invariants
pkg_client_ui_goal --> pkg_api_remotes
pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
pkg_client_ui_goal --> pkg_client_ui_conversation
pkg_client_ui_goal --> pkg_client_ui_primitives
pkg_client_ui_goal --> pkg_client_ui_slots
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_tool --> pkg_client_locale
pkg_client_ui_tool --> pkg_client_runtime
pkg_client_ui_tool --> pkg_client_ui_conversation
pkg_client_ui_tool --> pkg_client_ui_primitives
pkg_client_ui_tool --> pkg_client_ui_slots
pkg_client_ui_tool --> pkg_invariants
pkg_llm_replay --> pkg_compact
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_session_reference --> pkg_agent
pkg_session_reference --> pkg_compact
pkg_session_reference --> pkg_invariants
@@ -1002,6 +959,11 @@ flowchart TD
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tasks
pkg_tool_pwsh --> pkg_tools
pkg_compact_tool_result_prune --> pkg_compact
pkg_compact_tool_result_prune --> pkg_invariants
pkg_compact_tool_result_prune --> pkg_llm
pkg_compact_tool_result_prune --> pkg_session
pkg_compact_tool_result_prune --> pkg_token_meter
pkg_subagent_acp --> pkg_agent
pkg_subagent_acp --> pkg_invariants
pkg_subagent_acp --> pkg_llm
@@ -1050,50 +1012,18 @@ flowchart TD
pkg_web_app --> pkg_bash_env
pkg_web_app --> pkg_invariants
pkg_web_app --> pkg_system_prompt
pkg_client_ui_model --> pkg_client_connection
pkg_client_ui_model --> pkg_client_locale
pkg_client_ui_model --> pkg_client_runtime
pkg_client_ui_model --> pkg_client_ui_command
pkg_client_ui_model --> pkg_client_ui_conversation
pkg_client_ui_model --> pkg_client_ui_primitives
pkg_client_ui_model --> pkg_client_ui_slash
pkg_client_ui_model --> pkg_client_ui_slots
pkg_client_ui_model --> pkg_invariants
pkg_client_ui_permission --> pkg_client_connection
pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
pkg_client_ui_permission --> pkg_client_schema_form
pkg_client_ui_permission --> pkg_client_ui_command
pkg_client_ui_permission --> pkg_client_ui_primitives
pkg_client_ui_permission --> pkg_client_ui_slash
pkg_client_ui_permission --> pkg_client_ui_slots
pkg_client_ui_permission --> pkg_invariants
pkg_client_ui_permission --> pkg_permission
pkg_client_ui_plan --> pkg_client_connection
pkg_client_ui_plan --> pkg_client_locale
pkg_client_ui_plan --> pkg_client_runtime
pkg_client_ui_plan --> pkg_client_ui_conversation
pkg_client_ui_plan --> pkg_client_ui_primitives
pkg_client_ui_plan --> pkg_client_ui_slots
pkg_client_ui_plan --> pkg_invariants
pkg_client_ui_plan --> pkg_plan_mode
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_locale
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_primitives
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_client_ui_tool
pkg_client_ui_skill --> pkg_invariants
pkg_client_ui_subagent --> pkg_client_locale
pkg_client_ui_subagent --> pkg_client_runtime
pkg_client_ui_subagent --> pkg_client_ui_conversation
pkg_client_ui_subagent --> pkg_client_ui_primitives
pkg_client_ui_subagent --> pkg_client_ui_slash
pkg_client_ui_subagent --> pkg_client_ui_slots
pkg_client_ui_subagent --> pkg_invariants
pkg_client_ui_subagent --> pkg_subagent
pkg_client_ui_subagent --> pkg_token_meter
pkg_client_ui_conversation --> pkg_agent
pkg_client_ui_conversation --> pkg_client_locale
pkg_client_ui_conversation --> pkg_client_runtime
pkg_client_ui_conversation --> pkg_client_ui_primitives
pkg_client_ui_conversation --> pkg_client_ui_slash
pkg_client_ui_conversation --> pkg_client_ui_slots
pkg_client_ui_conversation --> pkg_commands
pkg_client_ui_conversation --> pkg_compact
pkg_client_ui_conversation --> pkg_invariants
pkg_client_ui_conversation --> pkg_llm_retry
pkg_client_ui_conversation --> pkg_token_meter
pkg_client_ui_conversation --> pkg_tools
pkg_sdk_protocol --> pkg_invariants
pkg_sdk_protocol --> pkg_llm
pkg_sdk_protocol --> pkg_session
@@ -1117,6 +1047,14 @@ flowchart TD
pkg_workflow_workerthread --> pkg_subagent
pkg_workflow_workerthread --> pkg_tools
pkg_workflow_workerthread --> pkg_workflow
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_commands
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_compact_tool_result_prune
pkg_compact_basic --> pkg_invariants
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_compact_basic --> pkg_token_meter
pkg_subagent_codex --> pkg_invariants
pkg_subagent_codex --> pkg_llm
pkg_subagent_codex --> pkg_sdk_protocol
@@ -1132,6 +1070,50 @@ flowchart TD
pkg_subagent_spawn --> pkg_invariants
pkg_subagent_spawn --> pkg_subagent
pkg_subagent_spawn --> pkg_subagent_inprocess
pkg_client_ui_command --> pkg_client_connection
pkg_client_ui_command --> pkg_client_locale
pkg_client_ui_command --> pkg_client_runtime
pkg_client_ui_command --> pkg_client_ui_conversation
pkg_client_ui_command --> pkg_client_ui_primitives
pkg_client_ui_command --> pkg_client_ui_slash
pkg_client_ui_command --> pkg_client_ui_slots
pkg_client_ui_command --> pkg_invariants
pkg_client_ui_deliverables --> pkg_client_locale
pkg_client_ui_deliverables --> pkg_client_runtime
pkg_client_ui_deliverables --> pkg_client_ui_conversation
pkg_client_ui_deliverables --> pkg_client_ui_slots
pkg_client_ui_deliverables --> pkg_invariants
pkg_client_ui_goal --> pkg_api_remotes
pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
pkg_client_ui_goal --> pkg_client_ui_conversation
pkg_client_ui_goal --> pkg_client_ui_primitives
pkg_client_ui_goal --> pkg_client_ui_slots
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_plan --> pkg_client_connection
pkg_client_ui_plan --> pkg_client_locale
pkg_client_ui_plan --> pkg_client_runtime
pkg_client_ui_plan --> pkg_client_ui_conversation
pkg_client_ui_plan --> pkg_client_ui_primitives
pkg_client_ui_plan --> pkg_client_ui_slots
pkg_client_ui_plan --> pkg_invariants
pkg_client_ui_plan --> pkg_plan_mode
pkg_client_ui_subagent --> pkg_client_locale
pkg_client_ui_subagent --> pkg_client_runtime
pkg_client_ui_subagent --> pkg_client_ui_conversation
pkg_client_ui_subagent --> pkg_client_ui_primitives
pkg_client_ui_subagent --> pkg_client_ui_slash
pkg_client_ui_subagent --> pkg_client_ui_slots
pkg_client_ui_subagent --> pkg_invariants
pkg_client_ui_subagent --> pkg_subagent
pkg_client_ui_subagent --> pkg_token_meter
pkg_client_ui_tool --> pkg_client_locale
pkg_client_ui_tool --> pkg_client_runtime
pkg_client_ui_tool --> pkg_client_ui_conversation
pkg_client_ui_tool --> pkg_client_ui_primitives
pkg_client_ui_tool --> pkg_client_ui_slots
pkg_client_ui_tool --> pkg_invariants
pkg_agent_spine_demo --> pkg_agent
pkg_agent_spine_demo --> pkg_agent_loop
pkg_agent_spine_demo --> pkg_bash_env
@@ -1173,6 +1155,33 @@ flowchart TD
pkg_subagent_dsh_sdk --> pkg_session
pkg_subagent_dsh_sdk --> pkg_subagent
pkg_subagent_dsh_sdk --> pkg_subprocess
pkg_client_ui_model --> pkg_client_connection
pkg_client_ui_model --> pkg_client_locale
pkg_client_ui_model --> pkg_client_runtime
pkg_client_ui_model --> pkg_client_ui_command
pkg_client_ui_model --> pkg_client_ui_conversation
pkg_client_ui_model --> pkg_client_ui_primitives
pkg_client_ui_model --> pkg_client_ui_slash
pkg_client_ui_model --> pkg_client_ui_slots
pkg_client_ui_model --> pkg_invariants
pkg_client_ui_permission --> pkg_client_connection
pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
pkg_client_ui_permission --> pkg_client_schema_form
pkg_client_ui_permission --> pkg_client_ui_command
pkg_client_ui_permission --> pkg_client_ui_primitives
pkg_client_ui_permission --> pkg_client_ui_slash
pkg_client_ui_permission --> pkg_client_ui_slots
pkg_client_ui_permission --> pkg_invariants
pkg_client_ui_permission --> pkg_permission
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_locale
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_primitives
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_client_ui_tool
pkg_client_ui_skill --> pkg_invariants
pkg_acp_demo --> pkg_acp
pkg_acp_demo --> pkg_agent_spine_demo
pkg_acp_demo --> pkg_app_boot
@@ -1250,7 +1259,6 @@ flowchart TD
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) |
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
@@ -1267,8 +1275,7 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1279,7 +1286,6 @@ flowchart TD
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
@@ -1307,19 +1313,18 @@ flowchart TD
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
@@ -1327,7 +1332,7 @@ flowchart TD
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
@@ -1337,10 +1342,7 @@ flowchart TD
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -1360,6 +1362,7 @@ flowchart TD
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
@@ -1368,20 +1371,26 @@ flowchart TD
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) |
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) |
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: 597ab8f8e94daa684c5da30b3b8cf18bcc6a1782
persistence-catalog.zh.md: 2b733ce26ed17fec99fcabc0ecc743ce179ea5e4
persistence-catalog.md: 62ae3984a9f876473afc3cd5d87e56da4396af37
persistence-catalog.zh.md: f2b61b756e30a5ad248835574cbb2c7486d31483
+27 -39
View File
@@ -3,7 +3,7 @@
# Session Persistence Event Catalog
Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).
Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).
This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).
@@ -101,7 +101,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src
}
```
Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts)
### `approval/*`
@@ -212,7 +212,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/
}
```
Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts)
Source: [`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts)
#### `command/run` — log-only
@@ -230,7 +230,7 @@ Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interacti
'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
```
Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts)
Source: [`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts)
### `compact/*`
@@ -241,10 +241,10 @@ Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interacti
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.
*/
'compact/end': { turn: number | null; error?: string }
'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string }
```
Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:71`](../packages/compact/compact/src/types.ts)
#### `compact/prune` — log-only
@@ -268,7 +268,7 @@ Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact
}
```
Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:81`](../packages/compact/compact/src/types.ts)
#### `compact/start` — log-only
@@ -278,10 +278,10 @@ Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact
* `compact/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compact/start': { turn: number | null }
'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null }
```
Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts)
#### `compact/summary` — log-only
@@ -296,6 +296,8 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
* before it (`compact/prune` documents the shared protocol).
*/
'compact/summary': {
compactionId: CompactionId
sourceCommandId?: CommandId
summary: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
@@ -331,7 +333,7 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md)
Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts)
Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact/src/types.ts)
### `feedback/*`
@@ -412,29 +414,19 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-
```ts persistence-catalog
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
'llm/retry': {
turn: number
step: number
provider: string
mode: 'normal'
policyKey: string
retry: number
maxRetries: number
delayMs: number
failure: LlmFailure
} | {
turn: number
step: number
provider: string
mode: 'always'
policyKey: string
retry: number
delayMs: number
failure: LlmFailure
}
'llm/retry': LlmRetryEventData
```
Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts)
Source: [`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/types.ts)
#### `llm/retry-started` — log-only
```ts persistence-catalog
/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
'llm/retry-started': LlmRetryStartedEventData
```
Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts)
### `permission/*`
@@ -670,12 +662,10 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/
* before returning), so its execution-enclosure relation holds by
* construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
'tool/code-dispatch': CodeDispatchEventData
```
Types: [CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md)
Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts)
Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts)
#### `tool/code-dispatch-start` — log-only
@@ -693,12 +683,10 @@ Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/c
* with `tool/code-dispatch` by `subCallId` (timing = the two events'
* `time` fields).
*/
'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown }
'tool/code-dispatch-start': CodeDispatchStartEventData
```
Types: [CallId](subsystems/core.md)
Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts)
Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts)
#### `tool/result` — surface
+27 -39
View File
@@ -5,7 +5,7 @@
[English](persistence-catalog.md) | 中文
会话持久事件日志中可能出现的所有事件类型:完整持久化的 `SessionEvent` 信封,以及可通过合并扩展的 `SessionEventMap` 中的每个成员,包括 `@deepseek-ai/dsh-session` 所属的词汇和本仓库中每个插件的声明合并,并附有源 JSDoc、完整 payload 声明、surface 标记和声明位置。本文档是 [session.md](subsystems/session.md)surface 排序与 `deriveMessages()` 投影)、[persistence.md](subsystems/persistence.md)(如何让日志持久化)和 [session.md](subsystems/session.md#cordis-surface) 中生成区域(实时总线接线;日志事件**不是** cordis 事件,它通过唯一一次 `session/event` emit 到达监听器)的补充。
会话持久事件日志中可能出现的所有事件类型:完整持久化的 `SessionEvent` 信封,以及可通过合并扩展的 `SessionEventMap` 中的每个成员,包括 `@deepseek-ai/dsh-session` 所属的词汇和本仓库中每个插件`@deepseek-ai/dsh-session/types` 的声明合并,并附有源 JSDoc、完整 payload 声明、surface 标记和声明位置。本文档是 [session.md](subsystems/session.md)surface 排序与 `deriveMessages()` 投影)、[persistence.md](subsystems/persistence.md)(如何让日志持久化)和 [session.md](subsystems/session.md#cordis-surface) 中生成区域(实时总线接线;日志事件**不是** cordis 事件,它通过唯一一次 `session/event` emit 到达监听器)的补充。
英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog``doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。
@@ -103,7 +103,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts)
来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts)
### `approval/*`
@@ -214,7 +214,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts)
来源:[`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts)
#### `command/run` — log-only
@@ -232,7 +232,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
```
来源:[`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts)
来源:[`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts)
### `compact/*`
@@ -243,10 +243,10 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.
*/
'compact/end': { turn: number | null; error?: string }
'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string }
```
来源:[`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts)
来源:[`packages/compact/compact/src/types.ts:71`](../packages/compact/compact/src/types.ts)
#### `compact/prune` — log-only
@@ -270,7 +270,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts)
来源:[`packages/compact/compact/src/types.ts:81`](../packages/compact/compact/src/types.ts)
#### `compact/start` — log-only
@@ -280,10 +280,10 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
* `compact/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compact/start': { turn: number | null }
'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null }
```
来源:[`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts)
来源:[`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts)
#### `compact/summary` — log-only
@@ -298,6 +298,8 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
* before it (`compact/prune` documents the shared protocol).
*/
'compact/summary': {
compactionId: CompactionId
sourceCommandId?: CommandId
summary: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
@@ -333,7 +335,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md)
来源:[`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts)
来源:[`packages/compact/compact/src/types.ts:33`](../packages/compact/compact/src/types.ts)
### `feedback/*`
@@ -414,29 +416,19 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
```ts persistence-catalog
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
'llm/retry': {
turn: number
step: number
provider: string
mode: 'normal'
policyKey: string
retry: number
maxRetries: number
delayMs: number
failure: LlmFailure
} | {
turn: number
step: number
provider: string
mode: 'always'
policyKey: string
retry: number
delayMs: number
failure: LlmFailure
}
'llm/retry': LlmRetryEventData
```
来源:[`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts)
来源:[`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/types.ts)
#### `llm/retry-started` — log-only
```ts persistence-catalog
/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
'llm/retry-started': LlmRetryStartedEventData
```
来源:[`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts)
### `permission/*`
@@ -672,12 +664,10 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
* before returning), so its execution-enclosure relation holds by
* construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
'tool/code-dispatch': CodeDispatchEventData
```
类型:[CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md)
来源:[`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts)
来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts)
#### `tool/code-dispatch-start` — log-only
@@ -695,12 +685,10 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
* with `tool/code-dispatch` by `subCallId` (timing = the two events'
* `time` fields).
*/
'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown }
'tool/code-dispatch-start': CodeDispatchStartEventData
```
类型:[CallId](subsystems/core.md)
来源:[`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts)
来源:[`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts)
#### `tool/result` — surface
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/commands.md
commands.md: c71c19b8a3f94ba468e6871d5658c4eca1c6276d
commands.zh.md: 3f1df0e8aa40524160c8f770793b6064dd5988ae
commands.md: 5e280d659c2a74b3d8ba20f362922e9eb4e0b84c
commands.zh.md: 24f099d1926f7dba45122bdc53c23a4274e3f507
+4 -2
View File
@@ -49,6 +49,8 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi
```ts type-equiv
/** Invocation passed to one registered command handler. */
interface CommandInvocation {
/** Pairing id already written to this invocation's `command/run` event. */
readonly commandId: CommandId
/** Exact agent whose human-facing surface received the command. */
readonly agent: Agent
/** Exact text following the registered command name, including separator whitespace. */
@@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<Comma
Types: [Agent](core.md)
Source: [`packages/interaction/commands/src/index.ts:305`](../../packages/interaction/commands/src/index.ts)
Source: [`packages/interaction/commands/src/index.ts:267`](../../packages/interaction/commands/src/index.ts)
<a id="commands-events"></a>
@@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific
'commands/change'(): void
```
Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts)
Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts)
<!-- END GENERATED cordis-surface -->
+4 -2
View File
@@ -49,6 +49,8 @@ interface CommandDefinition {
```ts type-equiv
/** Invocation passed to one registered command handler. */
interface CommandInvocation {
/** Pairing id already written to this invocation's `command/run` event. */
readonly commandId: CommandId
/** Exact agent whose human-facing surface received the command. */
readonly agent: Agent
/** Exact text following the registered command name, including separator whitespace. */
@@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<Comma
Types: [Agent](core.md)
Source: [`packages/interaction/commands/src/index.ts:305`](../../packages/interaction/commands/src/index.ts)
Source: [`packages/interaction/commands/src/index.ts:267`](../../packages/interaction/commands/src/index.ts)
<a id="commands-events"></a>
@@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific
'commands/change'(): void
```
Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts)
Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/compaction.md
compaction.md: 942cc740db6b7006952bfc33a39ea9826d673e61
compaction.zh.md: c5b65a95f9c0db8b02912c68ef93edce2ceb7c43
compaction.md: 8325694d92aa1a3019aef8f6b0c1f99d45ad0df5
compaction.zh.md: 9f9014b8c1c1db12abc6581fbe6ebacfac3dd6fe
+14 -6
View File
@@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th
The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored.
These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes.
These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes.
## `CompactionResult`
@@ -29,6 +29,10 @@ What a successful compaction returns to its caller: the bookkeeping-event seqs,
```ts type-equiv
/** Result of a successful compaction operation. */
interface CompactionResult {
/** Stable identity shared by this compaction's complete durable lifecycle. */
compactionId: CompactionId
/** Human command that initiated this compaction, when it was manual. */
sourceCommandId?: CommandId
/** The seq of the appended `compact/start` event. */
startSeq: number
/** The seq of the appended `compact/summary` event. */
@@ -62,7 +66,7 @@ Automatic callers state why policy is running; implementations may treat confirm
type CompactionTrigger = 'pressure' | 'context-overflow'
```
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend marks its replacement `user/message` with `COMPACT_CHECKPOINT_SOURCE`; client and wire consumers import that value and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports both for host consumers. The predicate keeps checkpoint recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend creates its replacement `user/message` source with `compactCheckpointSource(compactionId, sourceCommandId?)`; client and wire consumers import that constructor, `CompactCheckpointSource`, and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports them for host consumers. The required transaction identity correlates the replacement checkpoint, while the predicate keeps recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
Expected manual failures use `ManualCompactionErrorCode`:
@@ -125,7 +129,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.compact` — `CompactService` (abstract seam)
Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`.
Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses compactCheckpointSource with the transaction identity so consumers recognize and correlate it independently of the backend. Load one implementation per context as `ctx.compact`.
```ts cordis-catalog
/**
@@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for a manual compaction.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, agent-cancellation,
* changed-span, summarization/shrink, commit-stage, or persistence failures;
* an aborted request preserves its exact abort reason. Failed attempts remain
* visible in the log.
*/
abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>
abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
@@ -170,7 +175,8 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges. The target session is `agent.session`.
* Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.
* Its replacement user message must use {@link compactCheckpointSource} with
* the transaction's `CompactionId`.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*
@@ -184,7 +190,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P
abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
```
Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts)
Types: [CommandId](commands.md)
Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts)
<a id="ctxtoolresultprune--toolresultpruneservice"></a>
+14 -6
View File
@@ -20,7 +20,7 @@
这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。
这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。
这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。
## `CompactionResult`
@@ -29,6 +29,10 @@
```ts type-equiv
/** Result of a successful compaction operation. */
interface CompactionResult {
/** Stable identity shared by this compaction's complete durable lifecycle. */
compactionId: CompactionId
/** Human command that initiated this compaction, when it was manual. */
sourceCommandId?: CommandId
/** The seq of the appended `compact/start` event. */
startSeq: number
/** The seq of the appended `compact/summary` event. */
@@ -62,7 +66,7 @@ interface CompactionResult {
type CompactionTrigger = 'pressure' | 'context-overflow'
```
`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用 `user/message`client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出两者。该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。
`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换用 `user/message` 的源client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该构造函数、`CompactCheckpointSource` 和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出它们。必填的事务身份会关联替换检查点,而该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。
预期的手动失败使用 `ManualCompactionErrorCode`
@@ -125,7 +129,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.compact` — `CompactService` (abstract seam)
Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`.
Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses compactCheckpointSource with the transaction identity so consumers recognize and correlate it independently of the backend. Load one implementation per context as `ctx.compact`.
```ts cordis-catalog
/**
@@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - cancellation scoped to this compaction request.
* @param sourceCommandId - initiating command identity for a manual compaction.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, agent-cancellation,
* changed-span, summarization/shrink, commit-stage, or persistence failures;
* an aborted request preserves its exact abort reason. Failed attempts remain
* visible in the log.
*/
abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>
abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
@@ -170,7 +175,8 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges. The target session is `agent.session`.
* Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.
* Its replacement user message must use {@link compactCheckpointSource} with
* the transaction's `CompactionId`.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*
@@ -184,7 +190,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P
abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
```
Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts)
Types: [CommandId](commands.md)
Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts)
<a id="ctxtoolresultprune--toolresultpruneservice"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
core.md: ec9a966164f2b706ae16341c628fb8f849eb7382
core.zh.md: 4de168fd9714a935b4d9b08bbabd52492d1ea1cc
core.md: 27c4359e360223d336cd94695bb45a79f0fd370c
core.zh.md: 4e7519665b6d9efb8075546d93325debd961905d
+13 -13
View File
@@ -547,7 +547,7 @@ list(): Agent[]
roots(): Agent[]
```
Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts)
<a id="agent-events"></a>
@@ -575,7 +575,7 @@ A fully configured agent and live session were published. Setup is composition-o
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentdisposed--emit"></a>
@@ -597,7 +597,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts)
<a id="agenterror--emit"></a>
@@ -621,7 +621,7 @@ A step or turn errored. The machine reports a failure here even when the error h
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentinboxclaimed--emit"></a>
@@ -645,7 +645,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentinboxdiscarded--emit"></a>
@@ -666,7 +666,7 @@ One message was discarded from the live inbox.
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentinboxinserted--emit"></a>
@@ -687,7 +687,7 @@ One message entered the live inbox.
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentpre-step--waterfall"></a>
@@ -712,7 +712,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentrequest--waterfall"></a>
@@ -738,7 +738,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach
Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentrequest-error--waterfall"></a>
@@ -767,7 +767,7 @@ Handle one failed model-request attempt before the loop retries or closes its st
Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentsession-start--emit"></a>
@@ -791,7 +791,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentstatus--emit"></a>
@@ -814,7 +814,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running`
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentturn-stopping--serial"></a>
@@ -845,7 +845,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-loop-events"></a>
+13 -13
View File
@@ -555,7 +555,7 @@ list(): Agent[]
roots(): Agent[]
```
Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts)
<a id="agent-events"></a>
@@ -583,7 +583,7 @@ A fully configured agent and live session were published. Setup is composition-o
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentdisposed--emit"></a>
@@ -605,7 +605,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts)
<a id="agenterror--emit"></a>
@@ -629,7 +629,7 @@ A step or turn errored. The machine reports a failure here even when the error h
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentinboxclaimed--emit"></a>
@@ -653,7 +653,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentinboxdiscarded--emit"></a>
@@ -674,7 +674,7 @@ One message was discarded from the live inbox.
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentinboxinserted--emit"></a>
@@ -695,7 +695,7 @@ One message entered the live inbox.
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentpre-step--waterfall"></a>
@@ -720,7 +720,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentrequest--waterfall"></a>
@@ -746,7 +746,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach
Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentrequest-error--waterfall"></a>
@@ -775,7 +775,7 @@ Handle one failed model-request attempt before the loop retries or closes its st
Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentsession-start--emit"></a>
@@ -799,7 +799,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentstatus--emit"></a>
@@ -822,7 +822,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running`
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts)
<a id="agentturn-stopping--serial"></a>
@@ -853,7 +853,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f
Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-loop-events"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: a5162c77d510c5ab66fa2dd6789693e1449970fb
session.zh.md: 989a3f5368aa0b0475dec4272fde724fd4a98230
session.md: e5eb78908ba4b7ebfc398d5c735969a5a8937374
session.zh.md: 51729999a831c94e87e36ec1c4705201f9c8360f
+2
View File
@@ -580,6 +580,8 @@ Activity ordering excludes the boundary through `lastActivityTime(events)`: pick
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The generated [persistence log event catalog](../persistence-catalog.md) enumerates every core and plugin-contributed event with its payload, surface badge, and declaration site; the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
When several events in one plugin-owned family assemble into one Web Client Conversation Node, every start, update, result, resource, or interruption event in that family carries or independently derives the same stable business id. This requirement applies to correlated Node families, not to every Session event; it lets the client group each event without guessing from adjacency or scanning history. See the [Conversation Node cookbook](../cookbook/adding-a-conversation-node.md).
The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
## Durability contract
+2
View File
@@ -584,6 +584,8 @@ interface TurnEndReasonMap {
插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。生成的[持久化日志事件目录](../persistence-catalog.md)会列出每个核心或插件贡献的事件,以及其 payload、surface 标记和声明位置;压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。
如果同一个插件事件族中的多条事件要组装成一个 Web Client Conversation Node,该事件族中的每条 start、update、result、resource 或 interruption 事件都必须携带或独立推导出同一个稳定业务 id。此要求只约束需要关联的 Node 事件族,并不要求每条 Session 事件都有业务 id;Client 因此无须根据相邻关系猜测归属,也无须扫描历史。参见 [Conversation Node 实操手册](../cookbook/adding-a-conversation-node.md)。
钩子桥接层的 `hook/invoked` / `hook/result` 对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。
## 持久性约定
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/tools.md
tools.md: 87910dfbb86e9db0f0545bc2fe1135c4aefb2e69
tools.zh.md: d312090b722de3fd1b8efc7703eafdf7f3c38682
tools.md: 83389f39c3188fc251504ed5786249ff1921acae
tools.zh.md: 45cb85b3f2940f84b46bc58406bc8255cbe08be7
+14 -7
View File
@@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
*/
interface ToolExecutionInput {
readonly callId: CallId
/**
* Root model-requested call owning this execution tree. Callers omit it for
* a root execution; nested dispatchers propagate the enclosing value.
*/
readonly rootCallId?: CallId
readonly name: string
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
readonly arguments: unknown
@@ -280,6 +285,8 @@ interface CodeDispatchLog {
* observers run.
*/
interface ToolExecution extends ToolExecutionInput {
/** Root model-requested call, resolved for every root and nested execution. */
readonly rootCallId: CallId
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken
}
@@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](scope.md)
Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts)
<a id="tools-events"></a>
@@ -572,7 +579,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts)
<a id="toolscode-dispatch-log--waterfall"></a>
@@ -598,7 +605,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts)
<a id="toolsexecute--waterfall"></a>
@@ -622,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
<a id="toolspost-execute--waterfall"></a>
@@ -647,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts)
<a id="toolspre-execute--waterfall"></a>
@@ -670,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts)
<a id="toolsresult--emit"></a>
@@ -691,5 +698,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts)
<!-- END GENERATED cordis-surface -->
+14 -7
View File
@@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
*/
interface ToolExecutionInput {
readonly callId: CallId
/**
* Root model-requested call owning this execution tree. Callers omit it for
* a root execution; nested dispatchers propagate the enclosing value.
*/
readonly rootCallId?: CallId
readonly name: string
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
readonly arguments: unknown
@@ -280,6 +285,8 @@ interface CodeDispatchLog {
* observers run.
*/
interface ToolExecution extends ToolExecutionInput {
/** Root model-requested call, resolved for every root and nested execution. */
readonly rootCallId: CallId
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken
}
@@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](scope.md)
Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts)
<a id="tools-events"></a>
@@ -572,7 +579,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts)
<a id="toolscode-dispatch-log--waterfall"></a>
@@ -598,7 +605,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts)
<a id="toolsexecute--waterfall"></a>
@@ -622,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
<a id="toolspost-execute--waterfall"></a>
@@ -647,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts)
<a id="toolspre-execute--waterfall"></a>
@@ -670,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts)
<a id="toolsresult--emit"></a>
@@ -691,5 +698,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
Types: [Scoped](scope.md)
Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -1,6 +1,6 @@
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-agent'
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-tools'
@@ -26,7 +26,7 @@ export function apply(ctx: Context): void {
if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Earlier context was compacted for this snapshot.' }],
source: COMPACT_CHECKPOINT_SOURCE,
source: compactCheckpointSource(CompactionId('workspace-context-fixture')),
}), {
surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq },
sourceEventSeqs: [baseline.seq],
@@ -25,8 +25,8 @@
{"type":"assistant/chunk","seq":23,"time":1785730458465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":24,"time":1785730458465,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e3061430-3f2d-4dd8-a3ee-c0fde800547d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"tool/call","seq":25,"time":1785730458465,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}
{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}
{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}
{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}
{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}
{"type":"tool/result","seq":28,"time":1785730458520,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"4dce223d-0097-4ac2-a717-d1c430240cef"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
{"type":"step/end","seq":29,"time":1785730458520,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":30,"time":1785730458527,"data":{"turn":1,"step":3}}
@@ -18,8 +18,8 @@
{"type":"assistant/chunk","seq":103,"time":1785730479356,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":104,"time":1785730479356,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."},{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1cf31c-fd73-42fc-805d-a14d91228bd9"},"usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103],"surfaceOp":"append"}
{"type":"tool/call","seq":105,"time":1785730479356,"data":{"turn":1,"step":1,"callId":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}}
{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}}
{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}}
{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}}
{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}}
{"type":"tool/result","seq":108,"time":1785730479423,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Era4M5eh79bvNOIey5q90401"},"content":[{"type":"tool-result","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false}],"role":"user","id":"028e19dd-dcfc-4a67-a6e4-c9fa19716ea3"}},"sourceEventSeqs":[105],"surfaceOp":"append"}
{"type":"step/end","seq":109,"time":1785730479423,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":110,"time":1785730479431,"data":{"turn":1,"step":2}}
@@ -18,10 +18,10 @@
{"type":"assistant/chunk","seq":187,"time":1785730477079,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":188,"time":1785730477079,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."},{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59e638d7-2aa2-48a2-ae0e-5833b1152ce6"},"usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187],"surfaceOp":"append"}
{"type":"tool/call","seq":189,"time":1785730477080,"data":{"turn":1,"step":1,"callId":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}}
{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}}
{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}}
{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}}
{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}}
{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}}
{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}}
{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}}
{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}}
{"type":"tool/result","seq":194,"time":1785730477150,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UiQPVqoELyzBZCY5pm1z7875"},"content":[{"type":"tool-result","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false}],"role":"user","id":"e40c6472-d68e-4be1-963f-edb0edc80d82"}},"sourceEventSeqs":[189],"surfaceOp":"append"}
{"type":"step/end","seq":195,"time":1785730477150,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":196,"time":1785730477158,"data":{"turn":1,"step":2}}
@@ -16,8 +16,8 @@
{"type":"assistant/chunk","seq":14,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":15,"time":1785733131056,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9402d85-58bd-4881-b890-0b186f661671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}
{"type":"tool/call","seq":16,"time":1785733131056,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}}
{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}}
{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}]}}
{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}}
{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}]}}
{"type":"tool/result","seq":19,"time":1785733131112,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"bde1c12e-44d1-44f7-ba7e-868349ed2b05"}},"sourceEventSeqs":[16],"surfaceOp":"append"}
{"type":"step/end","seq":20,"time":1785733131112,"data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","seq":21,"time":1785733131112,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"29b0eb87-92d5-4915-ba64-7bd8133ed011"}]}}
File diff suppressed because one or more lines are too long
@@ -10,12 +10,13 @@
{"type":"request/context","seq":8,"time":1785730441192,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1785498788105,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}}
{"type":"assistant/chunk","seq":10,"time":1785730441201,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}}
{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}
{"type":"assistant/chunk","seq":12,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":17,"time":1785730441209,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}
{"type":"step/end","seq":18,"time":1785730441209,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":19,"time":1785730441209,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}
{"type":"llm/retry-started","seq":12,"time":1786238385130,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"retry":1}}
{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}}
{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":17,"time":1786238385135,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":18,"time":1786238385135,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
{"type":"step/end","seq":19,"time":1786238385135,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":20,"time":1786238385135,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -16,7 +16,7 @@
{"type":"assistant/chunk","seq":14,"time":1785730689194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":15,"time":1785730689194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9e9648c4-949e-4cf1-b9ef-0eb65897d36b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}
{"type":"tool/call","seq":16,"time":1785730689195,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}
{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}}
{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact","compactionId":"workspace-context-fixture"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}}
{"type":"tool/result","seq":18,"time":1785982371865,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"a46fded2-333a-4fb2-b01e-28520bffbc21"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[16],"surfaceOp":"append"}
{"type":"step/end","seq":19,"time":1785982371865,"data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","seq":20,"time":1785730689207,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"},{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"}]}}
@@ -24,8 +24,8 @@
{"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}
{"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}
{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}
{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}
{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}
{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}
{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"}
{"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}}
@@ -23,8 +23,8 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":3}}}
File diff suppressed because one or more lines are too long
@@ -17,10 +17,10 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n<compacted-summary>"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":"</compacted-summary>"}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"compactionId":"{{sessionId}}","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n<compacted-summary>"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":"</compacted-summary>"}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}}
@@ -7,13 +7,14 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":10,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
{"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}}
@@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution {
signal: testToolSignal,
token: Symbol('bash-env-test') as ToolExecution['token'],
callId: CallId('bash-env-call'),
rootCallId: CallId('bash-env-call'),
name: 'bash',
arguments: { command: 'true' },
...(sessionId === undefined
+6
View File
@@ -54,6 +54,12 @@ Non-negotiables across the layers:
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Conversation Node discipline
- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md).
- `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`.
- The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks.
## Directory regime (plugin packages)
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
@@ -96,7 +96,7 @@ function sgr(code: number, body: string): string {
}
/**
* Terminal output sample for fixture turn 65, authored to carry every feature
* Terminal output sample for fixture turn 66, authored to carry every feature
* the terminal card draws that turn 60's two prompt rows cannot reach:
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
@@ -141,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
}
/**
* Structured grep result for the search sample (turn 66): matches grouped by
* Structured grep result for the search sample (turn 67): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
@@ -191,7 +191,7 @@ const SEARCH_MATCHES_TEXT = [
].join('\n')
/**
* Structured glob result for the search sample (turn 67): a flat path list,
* Structured glob result for the search sample (turn 68): a flat path list,
* truncated with a larger `total` so the path card shows its capped indicator.
*/
const SEARCH_PATHS_FIXTURE = [
@@ -426,19 +426,19 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
// the presenter reads to emit the two-hunk sample: the card draws one path
// header, the first hunk, a `⋯` gap, then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
// keyed registration a top-level bash row uses).
{
const turn = 64
const turn = 65
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
@@ -456,12 +456,12 @@ function buildAlphaLog(): SessionEvent[] {
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
push({
type: 'tool/code-dispatch-start',
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
})
push({
type: 'tool/code-dispatch',
data: {
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
},
})
@@ -476,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
// in_progress: this fixture chooses the parallel policy, so both surfaces
// must render a parallel plan rather than the first active item alone.
@@ -486,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] {
{ content: '跑后台构建', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
// whose prompt label is its last segment, and a non-zero exit authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
@@ -498,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] {
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
// truncated). Both ride the keyed SearchRow registration under their own
// names; the render-site fallback row is covered by the model derivation
// tests, since every fixture search tool has a keyed row. Ordered before the
// todo turn for the same standing-plan reason the bash turn is.
toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
// The render-site fallback ROW SHAPE (a read call on the generic flattened
// path) is covered by the turn 64 run_code read sub-dispatches, which
// path) is covered by the turn 65 run_code read sub-dispatches, which
// session.ts folds with resultView: null; the fallback-row + read-CARD
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
// this fixture. The read render intent is result-side only, so its pending
// call stays a generic `kind: 'read'` card; presentResult carries the
// structured window.
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
// Turns 69-70: the web render intent — a web_search whose result view carries
// Turns 70-71: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
// Both keep a generic pending call view and add the `web` card only at
// result time, which is the contract's result-only web shape. Named after
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the todo turn for the same reason turn 66 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -578,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
case 'read':
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
case 'edit':
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
if (str(args.file_path) === 'src/config.ts') {
return {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 402b8c2cc3270565f30b9c1a4550e72173eda20d
README.zh.md: 40a7421fc1a600550ba34a4d535ad959aab5caec
README.md: 47dbb9cb38d6dc380f438f5f3259e15fcf0bf297
README.zh.md: 2a2392d5a83d0d35e7da27a91fbe8508a1f3d869
+9 -5
View File
@@ -36,11 +36,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## The human transcript
## Conversation assembly
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view with the producer role and name: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the Service Definition's declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair.
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.
Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path.
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target.
The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md).
## Request inspection
@@ -48,7 +52,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
## Code Mode child-call tree
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract.
## Session title projection
@@ -56,7 +60,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start
## Model retry projection
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error.
The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.
## Session forking
+9 -5
View File
@@ -36,11 +36,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`
## 面向人的 transcript(文本记录)
## Conversation 组装
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并在与之匹配的 `user/message` 落地时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份包含生产者角色和名称的 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在 Service Definition 的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)
每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识
Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chatcompaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`Session 则把该 legacy slice 镜像到顶层 `nodes``partial``runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。
Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。
## 请求检查
@@ -48,7 +52,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Code Mode 子调用树
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime`ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会`callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatchstart/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍`callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约
## Session 标题投影
@@ -56,7 +60,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段约定,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久重试提示。该提示在后续重试轮次开始前为 `scheduled`源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry``llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。
## 会话 fork
+3 -1
View File
@@ -32,9 +32,10 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -42,6 +43,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"zustand": "~4.4.7"
@@ -0,0 +1,265 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
* installed business packages supply their concrete keys in consuming Client programs. */
/** One raw log event plus its optional envelope-level presentation view. */
export interface ConversationEventInput {
readonly event: SessionEvent
readonly view: ToolEventView | undefined
}
/** Definition-local identity and lifecycle role extracted from one event. */
export interface ConversationMatchResult {
readonly id: string
readonly role: 'start' | 'update'
}
/** Merge-extensible business values published against one Turn. */
export interface ConversationTurnDataMap {}
/** Merge-extensible business values published against one Step. */
export interface ConversationStepDataMap {}
/** Stable keyed reader for independently owned Location business values. */
export interface ConversationLocationDataStore<DataMap extends object> {
/**
* Read one business value without exposing another owner's mutable State.
* @param key - declaration-merged business key.
* @returns latest immutable value, when its owning Context has published one.
*/
get<Key extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | undefined
}
interface ConversationLocationDataValue {
readonly kind: 'turn' | 'step'
readonly turn: number
readonly step?: number
readonly key: string
readonly value: unknown
}
type RegisteredTurnData = {
[Key in keyof ConversationTurnDataMap & string]: {
readonly kind: 'turn'
readonly turn: number
readonly key: Key
readonly value: ConversationTurnDataMap[Key]
}
}[keyof ConversationTurnDataMap & string]
type RegisteredStepData = {
[Key in keyof ConversationStepDataMap & string]: {
readonly kind: 'step'
readonly turn: number
readonly step: number
readonly key: Key
readonly value: ConversationStepDataMap[Key]
}
}[keyof ConversationStepDataMap & string]
/** One Definition-owned value attached to an Engine-owned Turn or Step. */
export type ConversationLocationData =
[keyof ConversationTurnDataMap | keyof ConversationStepDataMap] extends [never]
? ConversationLocationDataValue
: RegisteredTurnData | RegisteredStepData
/** Immutable resolved boundary for one Agent step. */
export interface StepLocation {
readonly turn: number
readonly step: number
readonly start: SessionEvent<'step/start'> | undefined
readonly end: SessionEvent<'step/end'> | undefined
readonly status: 'open' | 'closed' | 'unknown'
/** Stable reader for Step-scoped business values. */
readonly data: ConversationLocationDataStore<ConversationStepDataMap>
}
/** Immutable resolved boundary for one Agent turn. */
export interface TurnLocation {
readonly turn: number
readonly start: SessionEvent<'turn/start'> | undefined
readonly end: SessionEvent<'turn/end'> | undefined
readonly status: 'open' | 'closed' | 'unknown'
readonly steps: readonly StepLocation[]
/** Stable reader for Turn-scoped business values. */
readonly data: ConversationLocationDataStore<ConversationTurnDataMap>
}
/** Engine-owned placement of one matched event in the Session hierarchy. */
export type ConversationLocation =
| { readonly kind: 'session' }
| { readonly kind: 'turn'; readonly turn: TurnLocation }
| { readonly kind: 'step'; readonly turn: TurnLocation; readonly step: StepLocation }
| { readonly kind: 'unresolved' }
/** One event accepted by a Definition, with its current resolved Location. */
export interface ConversationMatch extends ConversationEventInput {
readonly role: 'start' | 'update'
readonly location: ConversationLocation
}
/** Target-neutral identity returned by a business Definition. */
export interface ConversationViewNode {
readonly key: string
readonly kind: string
readonly id: string
readonly target: string
readonly data: unknown
}
/** Final Chat render unit produced directly by a business Definition. */
export interface ChatConversationViewNode extends ConversationViewNode {
readonly target: 'chat'
readonly anchorSeq: number
readonly location: ConversationLocation
readonly visibility: 'visible' | 'hidden'
}
/** Immutable public view of an assembled business Context. */
export interface ConversationNodeContext<State = unknown> {
readonly key: string
readonly kind: string
readonly id: string
readonly matches: readonly ConversationMatch[]
readonly start: ConversationMatch | undefined
readonly state: State | undefined
readonly current: ReadonlyMap<string, ConversationViewNode | null>
}
/** Read-only predecessor returned to a Definition's start function. */
export interface ConversationPreviousContext<State = unknown> {
readonly key: string
readonly kind: string
readonly id: string
readonly startSeq: number
readonly state: Readonly<State>
readonly matches: readonly ConversationMatch[]
}
/** Strictly-backward Context lookup available while a start is evaluated. */
export interface ConversationContextReader {
/**
* Find the active Context of `kind` with the greatest start seq below the
* current start event.
* @param kind - Definition kind to query.
* @returns the nearest predecessor, or undefined when absent in the current window.
*/
previous<State>(kind: string): ConversationPreviousContext<State> | undefined
}
/** Requested cadence for materializing updated business State into view Nodes. */
export type ConversationPublication = 'none' | 'animation-frame' | 'immediate'
/** Engine-owned Location data publication phase. */
export type ConversationLocationDataScope = 'step' | 'turn'
/** One independently registered business Event-to-Node state machine. */
export interface ConversationNodeDefinition<State = unknown> {
readonly kind: string
/**
* Extract this Definition's stable business identity from one event.
* @param event - raw Session event; no Context or history access is available.
* @returns identity and lifecycle role, or null when unrelated.
*/
match(event: SessionEvent): ConversationMatchResult | null
/**
* Create State from the unique start Match.
* @param context - complete evidence currently collected for the Context.
* @param match - the start Match.
* @param reader - strictly-backward read-only Context lookup.
* @returns the State adopted by the engine.
*/
start(
context: ConversationNodeContext<State>,
match: ConversationMatch,
reader: ConversationContextReader,
): State
/**
* Apply one post-start update Match.
* @param context - Context with its current State.
* @param match - update Match in ascending log order.
* @returns the State adopted by the engine.
*/
update(
context: ConversationNodeContext<State> & { readonly state: State },
match: ConversationMatch,
): State
/**
* Select publication cadence for one accepted Match.
* @param match - accepted Match.
* @returns requested cadence; omission defaults to immediate.
*/
publication?(match: ConversationMatch): ConversationPublication
/**
* Publish this Definition's read-only business value for one Location phase.
* The Engine evaluates every Definition first for Step and then for Turn,
* owns replacement/removal, and rejects another Context trying to publish
* the same Location key.
* @param context - latest complete Context.
* @param scope - Location hierarchy level currently being materialized.
* @returns current Location value, or null while unavailable.
*/
buildLocationData?(
context: ConversationNodeContext<State>,
scope: ConversationLocationDataScope,
): ConversationLocationData | null
/**
* Materialize one final Node for a registered view target.
* @param context - latest complete Context.
* @param target - registered view target such as `chat`.
* @returns final Node, or null when this Context is not currently visible.
*/
buildViewNode(
context: ConversationNodeContext<State>,
target: string,
): ConversationViewNode | null
}
/** Reference-stable Turn/Step facts published beside view Nodes. */
export interface ConversationTimelineSnapshot {
readonly turnOrder: readonly number[]
readonly turns: ReadonlyMap<number, TurnLocation>
}
/** Per-Session incremental builder for one view target. */
export interface ConversationViewBuilder<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
readonly empty: Snapshot
/**
* Replace the low-frequency complete materialized Node set.
* @param input - complete Nodes and current timeline.
* @returns next view snapshot.
*/
replace(input: {
readonly nodes: readonly Node[]
readonly timeline: ConversationTimelineSnapshot
}): Snapshot
/**
* Apply only Nodes whose materialized values changed in this transaction.
* @param input - changed Nodes and current timeline.
* @returns next view snapshot.
*/
apply(input: {
readonly upserts: readonly Node[]
readonly timeline: ConversationTimelineSnapshot
}): Snapshot
}
/** Registry contribution that creates one isolated view builder per Session. */
export interface ConversationViewDefinition<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
readonly target: string
/** @returns a new Session-owned incremental builder. */
create(): ConversationViewBuilder<Node, Snapshot>
}
/**
* Build a stable collision-free key for one Definition-local business identity.
* @param kind - Definition kind.
* @param id - Definition-local business identity.
* @returns engine-owned Context key.
*/
export function conversationContextKey(kind: string, id: string): string {
return `${kind.length}:${kind}${id}`
}
@@ -0,0 +1,60 @@
import { Service } from 'cordis'
/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */
export abstract class ConversationDefinitionRegistry<Definition> extends Service {
protected readonly definitions = new Map<string, Definition>()
private listeners = new Set<() => void>()
private cached: readonly Definition[] = []
/**
* Return reference-stable Definitions in registration order.
* @returns current Definitions.
*/
entries(): readonly Definition[] {
return this.cached
}
/**
* Observe low-frequency registry changes.
* @param listener - synchronous invalidation callback.
* @returns unsubscribe callback.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Register one uniquely keyed Definition for the caller's lifetime.
* @param key - registry-local unique key.
* @param definition - contributed Definition.
* @param duplicateMessage - error raised when the key is already owned.
* @param effectName - Cordis effect diagnostic label.
* @returns idempotent disposer.
*/
protected registerDefinition(
key: string,
definition: Definition,
duplicateMessage: string,
effectName: string,
): () => void {
if (this.definitions.has(key)) throw new Error(duplicateMessage)
const owner = this.ctx
const dispose = owner.effect(() => {
this.definitions.set(key, definition)
this.refresh()
return () => {
if (this.definitions.get(key) !== definition) return
this.definitions.delete(key)
this.refresh()
}
}, effectName)
return () => { void dispose() }
}
/** Refresh cached entries and synchronously invalidate subscribers. */
protected refresh(): void {
this.cached = [...this.definitions.values()]
for (const listener of this.listeners) listener()
}
}
@@ -0,0 +1,56 @@
import type { Context } from 'cordis'
import type { ConversationNodeDefinition } from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
/** Runtime registry of independently owned Conversation business Definitions. */
export class ConversationEventRegistry extends ConversationDefinitionRegistry<ConversationNodeDefinition> {
private fallback: ConversationNodeDefinition | undefined
/** @param ctx - owning Client Runtime context. */
constructor(ctx: Context) {
super(ctx, 'conversationEvents')
}
/**
* Register a uniquely named business Definition for the caller's lifetime.
* @param definition - Definition contribution.
* @returns idempotent disposer.
*/
register(definition: ConversationNodeDefinition): () => void {
return this.registerDefinition(
definition.kind,
definition,
`conversation Definition "${definition.kind}" is already registered`,
`conversationEvents.register(${JSON.stringify(definition.kind)})`,
)
}
/**
* Register the sole fallback used only when no ordinary Definition matches.
* @param definition - fallback Definition.
* @returns idempotent disposer.
*/
registerFallback(definition: ConversationNodeDefinition): () => void {
if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered')
const owner = this.ctx
const dispose = owner.effect(() => {
this.fallback = definition
this.refresh()
return () => {
if (this.fallback !== definition) return
this.fallback = undefined
this.refresh()
}
}, `conversationEvents.registerFallback(${JSON.stringify(definition.kind)})`)
return () => { void dispose() }
}
/**
* Return the current unmatched-event fallback.
* @returns installed fallback, when present.
*/
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
}
@@ -0,0 +1,26 @@
import type { Context } from 'cordis'
import type { ConversationViewDefinition } from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
/** Runtime registry of per-target Conversation snapshot builders. */
export class ConversationViewRegistry extends ConversationDefinitionRegistry<ConversationViewDefinition> {
/** @param ctx - owning Client Runtime context. */
constructor(ctx: Context) {
super(ctx, 'conversationViews')
}
/**
* Register a uniquely named view builder factory for the caller's lifetime.
* @param definition - target builder contribution.
* @returns idempotent disposer.
*/
register(definition: ConversationViewDefinition): () => void {
return this.registerDefinition(
definition.target,
definition,
`conversation view target "${definition.target}" is already registered`,
`conversationViews.register(${JSON.stringify(definition.target)})`,
)
}
}
+36 -3
View File
@@ -10,8 +10,27 @@ import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
import { ConversationEventRegistry } from './conversation/event-registry.ts'
import { ConversationViewRegistry } from './conversation/view-registry.ts'
export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
export { SlotsService } from './slots.ts'
export { ConversationEventRegistry } from './conversation/event-registry.ts'
export { ConversationViewRegistry } from './conversation/view-registry.ts'
export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts'
export { ConversationLocationIndex } from './sessions/conversation-location-index.ts'
export { conversationContextKey } from './contract/conversation.ts'
export type {
ChatConversationViewNode, ConversationContextReader, ConversationEventInput,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
ConversationStepDataMap,
ConversationLocation, ConversationMatch, ConversationMatchResult,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation,
} from './contract/conversation.ts'
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
@@ -49,11 +68,17 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts'
export { emptyAssistantBlock } from './sessions/partial.ts'
export { isTokenDelta } from './sessions/assistant-timing.ts'
export { contextForm, contextProvenance } from './sessions/context-provenance.ts'
export { displayFailureMessage } from './sessions/failure-display.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
@@ -165,6 +190,10 @@ declare module 'cordis' {
}
interface Context {
slots: import('./slots.ts').SlotsService
/** Event-to-business-Context Definition registry. */
conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry
/** Per-target Conversation snapshot builder registry. */
conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** Read-only history sources isolated from Chat sessions and workspace state. */
@@ -182,8 +211,12 @@ export const inject = ['connection', 'typert']
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const conversation = {
events: new ConversationEventRegistry(ctx),
views: new ConversationViewRegistry(ctx),
}
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const sessions = new SessionsService(ctx, connection.api, conversation)
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
})
@@ -1,7 +1,6 @@
// Shared assistant step-timing fold: both transcript projections (the live
// window adapter and the trajectory history fold) derive AssistantTiming from
// the same step/start -> first token delta -> assistant/message sequence, so
// the derivation lives once here instead of drifting per projection.
// Shared assistant step-timing fold: Chat Definitions and the Trajectory
// history fold derive AssistantTiming from the same step/start -> first token
// delta -> assistant/message sequence.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { AssistantTiming } from './conversation.ts'
@@ -0,0 +1,799 @@
import type {
ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode,
} from '../contract/conversation.ts'
import { conversationContextKey } from '../contract/conversation.ts'
import {
ConversationLocationIndex, type ConversationLocationDataChange,
} from './conversation-location-index.ts'
interface Dependency {
readonly kind: string
readonly key: string | undefined
readonly revision: number | undefined
readonly windowGap: boolean
}
interface InternalContext {
readonly key: string
readonly kind: string
readonly id: string
readonly definition: ConversationNodeDefinition
startSeq: number | undefined
start: ConversationMatch | undefined
matches: ConversationMatch[]
state: unknown
revision: number
readonly current: Map<string, ConversationViewNode | null>
readonly locationData: Record<ConversationLocationDataScope, ConversationLocationData | null>
dependencies: Map<string, Dependency>
}
interface PendingMatch {
readonly definition: ConversationNodeDefinition
readonly id: string
readonly match: ConversationMatch
}
interface ViewState {
readonly target: string
readonly builder: ConversationViewBuilder
snapshot: unknown
}
const PUBLICATION_RANK: Record<ConversationPublication, number> = {
none: 0,
'animation-frame': 1,
immediate: 2,
}
const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn']
function emptyLocationData(): Record<ConversationLocationDataScope, ConversationLocationData | null> {
return { step: null, turn: null }
}
function maximumPublication(
left: ConversationPublication,
right: ConversationPublication,
): ConversationPublication {
return PUBLICATION_RANK[left] >= PUBLICATION_RANK[right] ? left : right
}
function startSeq(context: InternalContext): number | undefined {
return context.startSeq
}
function insertionIndex(contexts: readonly InternalContext[], seq: number): number {
let low = 0
let high = contexts.length
while (low < high) {
const middle = low + Math.floor((high - low) / 2)
const candidate = contexts[middle]
if (candidate !== undefined && (candidate.startSeq as number) < seq) low = middle + 1
else high = middle
}
return low
}
function contextSnapshot<State>(context: InternalContext): ConversationNodeContext<State> {
return {
key: context.key,
kind: context.kind,
id: context.id,
matches: context.matches,
start: context.start,
state: context.state as State | undefined,
current: context.current,
}
}
function mergeMatches(
key: string,
additions: readonly ConversationMatch[],
existing: readonly ConversationMatch[],
): ConversationMatch[] {
const merged: ConversationMatch[] = []
let added = 0
let current = 0
while (added < additions.length || current < existing.length) {
const left = additions[added]
const right = existing[current]
if (left !== undefined && right !== undefined && left.event.seq === right.event.seq) {
throw new Error(`conversation Context ${key} received duplicate Match ${left.event.seq}`)
}
if (right === undefined || (left !== undefined && left.event.seq < right.event.seq)) {
merged.push(left as ConversationMatch)
added++
} else {
merged.push(right)
current++
}
}
return merged
}
/** Event Registry subset consumed by a Session-owned Assembler. */
export interface ConversationEventDefinitions {
/** @returns ordinary Definitions in registration order. */
entries(): readonly ConversationNodeDefinition[]
/** @returns unmatched-event fallback, when registered. */
fallbackEntry(): ConversationNodeDefinition | undefined
}
/** View Registry subset consumed by a Session-owned Assembler. */
export interface ConversationViewDefinitions {
/** @returns view builder factories in registration order. */
entries(): readonly ConversationViewDefinition[]
}
/**
* Session-owned incremental engine that assembles business Contexts from a
* contiguous Event window and materializes registered view snapshots.
*/
export class ConversationNodeAssembler {
private readonly contexts = new Map<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
private readonly inputs = new Map<number, ConversationEventInput>()
private readonly locationIndex = new ConversationLocationIndex()
private readonly dirty = new Set<InternalContext>()
private readonly revised = new Set<InternalContext>()
private readonly dependents = new Map<string, Set<InternalContext>>()
private readonly views = new Map<string, ViewState>()
private hasMore = false
private replacePending = true
private timelineDirty = true
/**
* @param eventDefinitions - live Event Definition registry.
* @param viewDefinitions - live view builder registry.
*/
constructor(
private readonly eventDefinitions: ConversationEventDefinitions,
private readonly viewDefinitions: ConversationViewDefinitions,
) {
this.resetViewBuilders()
}
/**
* Replace the complete loaded window after open, resync, or gap repair.
* @param entries - complete contiguous window.
* @param hasMore - whether older history remains outside the window.
* @returns immediate publication request.
*/
replaceWindow(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication {
this.contexts.clear()
this.contextsByKind.clear()
this.contextsBySeq.clear()
this.inputs.clear()
this.dirty.clear()
this.revised.clear()
this.dependents.clear()
this.hasMore = hasMore
const sorted = [...entries].sort((left, right) => left.event.seq - right.event.seq)
for (const entry of sorted) this.inputs.set(entry.event.seq, entry)
this.locationIndex.rebuild(sorted)
this.timelineDirty = true
for (const entry of sorted) this.matchInput(entry)
this.replayDependencies()
this.revised.clear()
for (const context of this.contexts.values()) this.dirty.add(context)
this.replacePending = true
return 'immediate'
}
/**
* Add one contiguous live tail event without scanning existing Contexts.
* @param input - appended Event and optional wire view.
* @returns highest requested publication cadence.
*/
append(input: ConversationEventInput): ConversationPublication {
if (this.inputs.has(input.event.seq)) return 'none'
this.revised.clear()
this.inputs.set(input.event.seq, input)
let publication: ConversationPublication = 'none'
if (isLocationBoundary(input.event.type)) {
const previousTimeline = this.locationIndex.snapshot()
const changed = this.locationIndex.appendBoundary(input.event)
if (this.locationIndex.snapshot() !== previousTimeline) {
this.timelineDirty = true
publication = 'immediate'
}
this.replayContexts(this.refreshMatchLocations(changed))
if (changed.size > 0) publication = 'immediate'
} else {
this.locationIndex.appendNonBoundary(input.event)
}
publication = maximumPublication(publication, this.matchInput(input))
if (this.replayRevisedDependents()) publication = 'immediate'
this.revised.clear()
return publication
}
/**
* Add an older page while preserving existing Context and view identities.
* @param entries - newly loaded older Events.
* @param hasMore - whether history still precedes the expanded window.
* @returns highest requested publication cadence.
*/
prepend(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication {
this.revised.clear()
let publication: ConversationPublication = 'none'
const previousHasMore = this.hasMore
const fresh = entries
.filter(entry => !this.inputs.has(entry.event.seq))
.sort((left, right) => left.event.seq - right.event.seq)
for (const entry of fresh) this.inputs.set(entry.event.seq, entry)
this.hasMore = hasMore
const previousTimeline = this.locationIndex.snapshot()
const changedLocations = this.locationIndex.rebuild(this.sortedInputs())
if (this.locationIndex.snapshot() !== previousTimeline) this.timelineDirty = true
const affected = this.refreshMatchLocations(changedLocations)
const pending = new Map<string, PendingMatch[]>()
for (const entry of fresh) {
publication = maximumPublication(publication, this.collectInput(entry, pending))
}
this.applyPendingMatches(pending, affected)
this.replayContexts(affected)
if ((this.revised.size > 0 || previousHasMore !== hasMore) && this.replayDependencies()) {
publication = 'immediate'
}
if (changedLocations.size > 0) publication = 'immediate'
this.revised.clear()
return publication
}
/**
* Rebuild against the current Registry set after a low-frequency plugin change.
* @returns immediate publication request.
*/
rebuildRegistry(): ConversationPublication {
this.resetViewBuilders()
return this.replaceWindow(this.sortedInputs(), this.hasMore)
}
/**
* Materialize dirty Contexts and advance every registered view builder.
* @returns whether any view snapshot was rebuilt or incrementally applied.
*/
flush(): boolean {
if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false
if (this.replacePending) {
this.replaceLocationData()
const allByTarget = new Map<string, ConversationViewNode[]>()
for (const target of this.views.keys()) allByTarget.set(target, [])
for (const context of this.contexts.values()) {
for (const target of this.views.keys()) {
const node = this.buildNode(context, target)
context.current.set(target, node)
if (node !== null) allByTarget.get(target)?.push(node)
}
}
for (const view of this.views.values()) {
view.snapshot = view.builder.replace({
nodes: allByTarget.get(view.target) ?? [],
timeline: this.locationIndex.snapshot(),
})
}
this.replacePending = false
this.dirty.clear()
this.timelineDirty = false
return true
}
const upsertsByTarget = new Map<string, ConversationViewNode[]>()
for (const target of this.views.keys()) upsertsByTarget.set(target, [])
if (this.applyDirtyLocationData()) this.timelineDirty = true
for (const context of this.dirty) {
for (const target of this.views.keys()) {
const previous = context.current.get(target) ?? null
const node = this.buildNode(context, target)
if (node === null && previous !== null) {
throw new Error(
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
)
}
context.current.set(target, node)
if (node !== null) upsertsByTarget.get(target)?.push(node)
}
}
this.dirty.clear()
const timelineDirty = this.timelineDirty
this.timelineDirty = false
for (const view of this.views.values()) {
const upserts = upsertsByTarget.get(view.target) ?? []
if (upserts.length === 0 && !timelineDirty) continue
view.snapshot = view.builder.apply({
upserts,
timeline: this.locationIndex.snapshot(),
})
}
return true
}
/**
* Read the latest snapshot of a registered target.
* @param target - registered view target.
* @returns target snapshot, or undefined when no builder is registered.
*/
snapshot(target: string): unknown {
return this.views.get(target)?.snapshot
}
private sortedInputs(): ConversationEventInput[] {
return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq)
}
private matchInput(input: ConversationEventInput): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) =>
this.acceptMatch(definition, id, role, input))
}
private collectInput(
input: ConversationEventInput,
pending: Map<string, PendingMatch[]>,
): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) => {
const key = conversationContextKey(definition.kind, id)
const match: ConversationMatch = {
...input,
role,
location: this.locationIndex.locationOf(input.event),
}
const matches = pending.get(key) ?? []
matches.push({ definition, id, match })
pending.set(key, matches)
return definition.publication?.(match) ?? 'immediate'
})
}
private dispatchInput(
input: ConversationEventInput,
accept: (
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
) => ConversationPublication,
): ConversationPublication {
let matched = false
let publication: ConversationPublication = 'none'
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(input.event)
if (result === null) continue
matched = true
publication = maximumPublication(publication, accept(definition, result.id, result.role))
}
if (!matched) {
const fallback = this.eventDefinitions.fallbackEntry()
const result = fallback?.match(input.event) ?? null
if (fallback !== undefined && result !== null) {
publication = maximumPublication(publication, accept(fallback, result.id, result.role))
}
}
return publication
}
private acceptMatch(
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
input: ConversationEventInput,
): ConversationPublication {
const key = conversationContextKey(definition.kind, id)
let context = this.contexts.get(key)
if (role === 'start' && context?.start !== undefined) {
throw new Error(`conversation Context ${key} received more than one start Match`)
}
if (context === undefined) {
context = {
key,
kind: definition.kind,
id,
definition,
startSeq: undefined,
start: undefined,
matches: [],
state: undefined,
revision: 0,
current: new Map(),
locationData: emptyLocationData(),
dependencies: new Map(),
}
this.contexts.set(key, context)
}
const match: ConversationMatch = {
...input,
role,
location: this.locationIndex.locationOf(input.event),
}
const previous = context.matches.at(-1)
if (previous !== undefined && previous.event.seq >= input.event.seq) {
throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`)
}
if (role === 'start' && context.matches.length > 0) {
throw new Error(`conversation Context ${key} received an update before its start Match`)
}
context.matches.push(match)
if (role === 'start') {
context.startSeq = input.event.seq
context.start = match
this.indexStartedContext(context)
}
const owners = this.contextsBySeq.get(input.event.seq) ?? new Set<InternalContext>()
owners.add(context)
this.contextsBySeq.set(input.event.seq, owners)
if (role === 'start') {
this.replayContext(context)
} else if (context.state !== undefined) {
const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown }
context.state = requireState(definition, 'update', definition.update(typed, match))
context.revision++
this.revised.add(context)
}
this.dirty.add(context)
return definition.publication?.(match) ?? 'immediate'
}
private applyPendingMatches(
pending: ReadonlyMap<string, readonly PendingMatch[]>,
affected: Set<InternalContext>,
): void {
const startsByKind = new Map<string, InternalContext[]>()
for (const [key, entries] of pending) {
const first = entries[0]
if (first === undefined) continue
let context = this.contexts.get(key)
if (context === undefined) {
context = {
key,
kind: first.definition.kind,
id: first.id,
definition: first.definition,
startSeq: undefined,
start: undefined,
matches: [],
state: undefined,
revision: 0,
current: new Map(),
locationData: emptyLocationData(),
dependencies: new Map(),
}
this.contexts.set(key, context)
}
let discoveredStart: ConversationMatch | undefined
const additions = entries
.map((entry) => {
if (entry.definition !== context.definition || entry.id !== context.id) {
throw new Error(`conversation Context ${key} received inconsistent Definition identity`)
}
if (entry.match.role === 'start') {
if (discoveredStart !== undefined || context.start !== undefined) {
throw new Error(`conversation Context ${key} received more than one start Match`)
}
discoveredStart = entry.match
}
const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set<InternalContext>()
owners.add(context)
this.contextsBySeq.set(entry.match.event.seq, owners)
return entry.match
})
.sort((left, right) => left.event.seq - right.event.seq)
context.matches = mergeMatches(context.key, additions, context.matches)
if (discoveredStart !== undefined) {
context.start = discoveredStart
context.startSeq = discoveredStart.event.seq
const starts = startsByKind.get(context.kind) ?? []
starts.push(context)
startsByKind.set(context.kind, starts)
}
if (context.start !== undefined && context.matches[0] !== context.start) {
throw new Error(`conversation Context ${context.key} received an update before its start Match`)
}
affected.add(context)
this.dirty.add(context)
}
for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts)
}
private replayContexts(contexts: ReadonlySet<InternalContext>): void {
const ordered = [...contexts].sort((left, right) =>
(left.startSeq ?? Number.POSITIVE_INFINITY) - (right.startSeq ?? Number.POSITIVE_INFINITY))
for (const context of ordered) {
if (context.start === undefined) {
context.state = undefined
this.dirty.add(context)
continue
}
this.replayContext(context)
}
}
private replayContext(context: InternalContext): void {
const start = context.start
if (start === undefined) {
context.state = undefined
return
}
if (context.matches[0] !== start) {
throw new Error(`conversation Context ${context.key} received an update before its start Match`)
}
const dependencies = new Map<string, Dependency>()
const reader = this.readerFor(start.event.seq, dependencies)
context.state = undefined
context.state = requireState(
context.definition,
'start',
context.definition.start(contextSnapshot(context), start, reader),
)
this.replaceDependencies(context, dependencies)
for (let index = 1; index < context.matches.length; index++) {
const match = context.matches[index]
if (match === undefined || match.role !== 'update') continue
const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown }
context.state = requireState(
context.definition,
'update',
context.definition.update(typed, match),
)
}
context.revision++
this.revised.add(context)
this.dirty.add(context)
}
private replaceDependencies(context: InternalContext, dependencies: Map<string, Dependency>): void {
for (const dependency of context.dependencies.values()) {
if (dependency.key === undefined) continue
const current = this.dependents.get(dependency.key)
current?.delete(context)
if (current?.size === 0) this.dependents.delete(dependency.key)
}
context.dependencies = dependencies
for (const dependency of dependencies.values()) {
if (dependency.key === undefined) continue
const current = this.dependents.get(dependency.key) ?? new Set()
current.add(context)
this.dependents.set(dependency.key, current)
}
}
private replayRevisedDependents(): boolean {
const pending = [...this.revised]
const affected = new Set<InternalContext>()
for (let index = 0; index < pending.length; index++) {
const dependency = pending[index]
if (dependency === undefined) continue
for (const dependent of this.dependents.get(dependency.key) ?? []) {
if (affected.has(dependent)) continue
affected.add(dependent)
pending.push(dependent)
}
}
this.replayContexts(affected)
return affected.size > 0
}
private readerFor(
beforeSeq: number,
dependencies: Map<string, Dependency>,
): ConversationContextReader {
return {
previous: <State>(kind: string): ConversationPreviousContext<State> | undefined => {
const predecessor = this.previousContext(kind, beforeSeq)
dependencies.set(kind, {
kind,
key: predecessor?.key,
revision: predecessor?.revision,
windowGap: predecessor === undefined && this.hasMore,
})
if (predecessor?.state === undefined) return undefined
const seq = startSeq(predecessor)
if (seq === undefined) return undefined
return {
key: predecessor.key,
kind: predecessor.kind,
id: predecessor.id,
startSeq: seq,
state: predecessor.state as Readonly<State>,
matches: predecessor.matches,
}
},
}
}
private previousContext(kind: string, beforeSeq: number): InternalContext | undefined {
const candidates = this.contextsByKind.get(kind) ?? []
const indexBefore = insertionIndex(candidates, beforeSeq)
for (let index = indexBefore - 1; index >= 0; index--) {
const candidate = candidates[index]
if (candidate?.state !== undefined) return candidate
}
return undefined
}
/** Insert one newly discovered start into its Definition's ordered predecessor index. */
private indexStartedContext(context: InternalContext): void {
const seq = context.startSeq
if (seq === undefined) return
const candidates = this.contextsByKind.get(context.kind) ?? []
const previous = candidates.at(-1)
if (previous === undefined || (previous.startSeq as number) < seq) candidates.push(context)
else candidates.splice(insertionIndex(candidates, seq), 0, context)
this.contextsByKind.set(context.kind, candidates)
}
private indexStartedContexts(kind: string, additions: readonly InternalContext[]): void {
if (additions.length === 0) return
const sorted = [...additions].sort((left, right) =>
(left.startSeq as number) - (right.startSeq as number))
const existing = this.contextsByKind.get(kind) ?? []
const merged: InternalContext[] = []
let before = 0
let added = 0
while (before < existing.length || added < sorted.length) {
const left = existing[before]
const right = sorted[added]
if (right === undefined || (left !== undefined && (left.startSeq as number) < (right.startSeq as number))) {
merged.push(left as InternalContext)
before++
} else {
merged.push(right)
added++
}
}
this.contextsByKind.set(kind, merged)
}
private replayDependencies(): boolean {
let replayed = false
const ordered = [...this.contexts.values()]
.filter(context => startSeq(context) !== undefined)
.sort((left, right) => (startSeq(left) as number) - (startSeq(right) as number))
for (const context of ordered) {
if (context.state === undefined || context.dependencies.size === 0) continue
const before = startSeq(context)
if (before === undefined) continue
let changed = false
for (const dependency of context.dependencies.values()) {
const current = this.previousContext(dependency.kind, before)
const windowGap = current === undefined && this.hasMore
if (current?.key !== dependency.key
|| current?.revision !== dependency.revision
|| windowGap !== dependency.windowGap) {
changed = true
break
}
}
if (changed) {
this.replayContext(context)
replayed = true
}
}
return replayed
}
private refreshMatchLocations(changedSeqs: ReadonlySet<number>): Set<InternalContext> {
const affected = new Set<InternalContext>()
if (changedSeqs.size === 0) return affected
for (const seq of changedSeqs) {
for (const context of this.contextsBySeq.get(seq) ?? []) affected.add(context)
}
for (const context of affected) {
let start = context.start
const matches = context.matches.map((match): ConversationMatch => {
if (!changedSeqs.has(match.event.seq)) return match
const refreshed = { ...match, location: this.locationIndex.locationOf(match.event) }
if (match === start) start = refreshed
return refreshed
})
context.matches = matches
context.start = start
}
return affected
}
private buildNode(context: InternalContext, target: string): ConversationViewNode | null {
const node = context.definition.buildViewNode(contextSnapshot(context), target)
if (node === null) return null
if (node.key !== context.key) {
throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`)
}
if (node.target !== target) {
throw new Error(`conversation Definition "${context.kind}" returned target "${node.target}" while building "${target}"`)
}
return node
}
private buildLocationData(
context: InternalContext,
scope: ConversationLocationDataScope,
): ConversationLocationData | null {
if (context.definition.buildLocationData === undefined) return null
const data = context.definition.buildLocationData(contextSnapshot(context), scope)
if (data === null) return null
if (data.kind !== scope) {
throw new Error(
`conversation Definition "${context.kind}" published ${data.kind} data through its ${scope} scope`,
)
}
if (data.key !== context.kind) {
throw new Error(
`conversation Definition "${context.kind}" published Location data key "${data.key}"; expected its owned kind`,
)
}
if (!Number.isSafeInteger(data.turn) || data.turn < 0) {
throw new Error(`conversation Definition "${context.kind}" published invalid turn ${data.turn}`)
}
if (data.kind === 'step' && (!Number.isSafeInteger(data.step) || (data.step as number) < 0)) {
throw new Error(`conversation Definition "${context.kind}" published invalid step ${String(data.step)}`)
}
return data
}
private replaceLocationData(): void {
const entries: { owner: string; data: ConversationLocationData }[] = []
for (const scope of LOCATION_DATA_SCOPES) {
for (const context of this.contexts.values()) {
const data = this.buildLocationData(context, scope)
context.locationData[scope] = data
if (data !== null) entries.push({ owner: context.key, data })
}
// Turn publishers may read Step data from this same flush, so each phase
// installs the cumulative replacement before the next phase builds.
this.locationIndex.replaceData(entries)
}
}
private applyDirtyLocationData(): boolean {
let changed = false
for (const scope of LOCATION_DATA_SCOPES) {
const changes: ConversationLocationDataChange[] = []
for (const context of this.dirty) {
const previous = context.locationData[scope]
const next = this.buildLocationData(context, scope)
context.locationData[scope] = next
if (previous !== next) changes.push({ owner: context.key, previous, next })
}
changed = this.locationIndex.applyData(changes) || changed
}
return changed
}
private resetViewBuilders(): void {
this.views.clear()
for (const definition of this.viewDefinitions.entries()) {
const builder = definition.create()
this.views.set(definition.target, {
target: definition.target,
builder,
snapshot: builder.empty,
})
}
this.replacePending = true
}
}
function isLocationBoundary(type: string): boolean {
return type === 'turn/start' || type === 'turn/end' || type === 'step/start' || type === 'step/end'
}
function requireState(
definition: ConversationNodeDefinition,
phase: 'start' | 'update',
state: unknown,
): unknown {
if (state === undefined) {
throw new Error(`conversation Definition "${definition.kind}" returned undefined from ${phase}()`)
}
return state
}
/** Structural registry pair accepted by Session and SessionManager. */
export interface ConversationRuntime {
readonly events: ConversationEventDefinitions & { subscribe(listener: () => void): () => void }
readonly views: ConversationViewDefinitions & { subscribe(listener: () => void): () => void }
}
@@ -0,0 +1,516 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationEventInput, ConversationLocation, ConversationLocationData,
ConversationLocationDataStore, ConversationStepDataMap, ConversationTimelineSnapshot,
ConversationTurnDataMap, StepLocation, TurnLocation,
} from '../contract/conversation.ts'
interface OwnedLocationData {
readonly owner: string
readonly value: unknown
}
/** One Context's previous and next Location-data publication. */
export interface ConversationLocationDataChange {
readonly owner: string
readonly previous: ConversationLocationData | null
readonly next: ConversationLocationData | null
}
class MutableLocationDataStore {
private entries = new Map<string, OwnedLocationData>()
get(key: string): unknown {
return this.entries.get(key)?.value
}
remove(owner: string, key: string): boolean {
const current = this.entries.get(key)
if (current?.owner !== owner) return false
this.entries.delete(key)
return true
}
set(owner: string, key: string, value: unknown): boolean {
const current = this.entries.get(key)
if (current !== undefined && current.owner !== owner) {
throw new Error(`conversation Location data "${key}" is already owned by ${current.owner}`)
}
if (current?.value === value) return false
this.entries.set(key, { owner, value })
return true
}
replace(entries: ReadonlyMap<string, OwnedLocationData>): boolean {
let changed = this.entries.size !== entries.size
if (!changed) {
for (const [key, value] of entries) {
const current = this.entries.get(key)
if (current?.owner !== value.owner || current.value !== value.value) {
changed = true
break
}
}
}
if (changed) this.entries = new Map(entries)
return changed
}
}
interface Coordinates {
readonly turn?: number
readonly step?: number
readonly session?: true
}
interface StepDraft {
readonly turn: number
readonly step: number
firstSeq: number
start?: SessionEvent<'step/start'>
end?: SessionEvent<'step/end'>
}
interface TurnDraft {
readonly turn: number
firstSeq: number
start?: SessionEvent<'turn/start'>
end?: SessionEvent<'turn/end'>
readonly steps: Map<number, StepDraft>
}
const SESSION_LOCATION = { kind: 'session' } as const
const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const
function payloadCoordinates(event: SessionEvent): Coordinates {
const data = event.data as unknown as { turn?: unknown; step?: unknown }
if (data.turn === null) return { session: true }
const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0
? data.turn as number
: undefined
const step = Number.isSafeInteger(data.step) && (data.step as number) >= 0
? data.step as number
: undefined
return { ...turn === undefined ? {} : { turn }, ...step === undefined ? {} : { step } }
}
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function sameStep(left: StepLocation | undefined, right: StepLocation): boolean {
return left !== undefined
&& left.start === right.start && left.end === right.end && left.status === right.status
&& left.data === right.data
}
function sameTurn(left: TurnLocation | undefined, right: TurnLocation): boolean {
return left !== undefined
&& left.start === right.start && left.end === right.end && left.status === right.status
&& left.data === right.data && sameReferences(left.steps, right.steps)
}
function sameLocation(left: ConversationLocation | undefined, right: ConversationLocation | undefined): boolean {
if (left === undefined || right === undefined || left.kind !== right.kind) return left === right
if (left.kind === 'session' || left.kind === 'unresolved') return true
if (right.kind === 'session' || right.kind === 'unresolved') return false
if (left.kind === 'turn' || right.kind === 'turn') {
return left.kind === 'turn' && right.kind === 'turn' && left.turn === right.turn
}
return left.turn === right.turn && left.step === right.step
}
/** Session-owned Turn/Step timeline and event-to-Location index. */
export class ConversationLocationIndex {
private coordinates = new Map<number, Coordinates>()
private locations = new Map<number, ConversationLocation>()
private seqsByTurn = new Map<number, Set<number>>()
private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
private currentTurn: number | undefined
private currentStep: number | undefined
/**
* Return the current reference-stable timeline.
* @returns current timeline snapshot.
*/
snapshot(): ConversationTimelineSnapshot {
return this.timeline
}
/**
* Replace all Definition-owned Location values while preserving reader identities.
* @param entries - complete current set of Definition-owned Location values.
* @returns whether any published Location data changed.
*/
replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean {
const turns = new Map<number, Map<string, OwnedLocationData>>()
const steps = new Map<string, Map<string, OwnedLocationData>>()
for (const { owner, data } of entries) {
const values = data.kind === 'turn'
? turns.get(data.turn) ?? new Map<string, OwnedLocationData>()
: steps.get(stepDataKey(data.turn, requireStep(data))) ?? new Map<string, OwnedLocationData>()
const current = values.get(data.key)
if (current !== undefined && current.owner !== owner) {
throw new Error(`conversation Location data "${data.key}" is already owned by ${current.owner}`)
}
values.set(data.key, { owner, value: data.value })
if (data.kind === 'turn') turns.set(data.turn, values)
else steps.set(stepDataKey(data.turn, requireStep(data)), values)
}
let changed = false
for (const turn of new Set([...this.turnDataStores.keys(), ...turns.keys()])) {
changed = this.mutableTurnData(turn).replace(turns.get(turn) ?? new Map()) || changed
}
for (const step of new Set([...this.stepDataStores.keys(), ...steps.keys()])) {
changed = this.mutableStepData(step).replace(steps.get(step) ?? new Map()) || changed
}
return changed
}
/**
* Apply changed Context publications without rebuilding Turn/Step membership.
* @param changes - incremental removals and replacements from published Contexts.
* @returns whether any published Location data changed.
*/
applyData(changes: readonly ConversationLocationDataChange[]): boolean {
let changed = false
for (const change of changes) {
const previous = change.previous
if (previous === null) continue
changed = this.storeFor(previous).remove(change.owner, previous.key) || changed
}
for (const change of changes) {
const next = change.next
if (next === null) continue
changed = this.storeFor(next).set(change.owner, next.key, next.value) || changed
}
return changed
}
/**
* Resolve the latest Location for one event.
* @param event - event already ingested into this index.
* @returns current Location, falling back to session when it has no Turn/Step affinity.
*/
locationOf(event: SessionEvent): ConversationLocation {
return this.locations.get(event.seq) ?? SESSION_LOCATION
}
/**
* Rebuild timeline facts after replace/prepend or a boundary append.
* @param entries - complete current window in ascending seq order.
* @returns seqs whose resolved Location changed.
*/
rebuild(entries: readonly ConversationEventInput[]): ReadonlySet<number> {
const previousLocations = this.locations
const turns = new Map<number, TurnDraft>()
const coordinates = new Map<number, Coordinates>()
let currentTurn: number | undefined
let currentStep: number | undefined
const turnDraft = (turn: number, seq: number): TurnDraft => {
let draft = turns.get(turn)
if (draft === undefined) {
draft = { turn, firstSeq: seq, steps: new Map() }
turns.set(turn, draft)
} else {
draft.firstSeq = Math.min(draft.firstSeq, seq)
}
return draft
}
const stepDraft = (turn: number, step: number, seq: number): StepDraft => {
const owner = turnDraft(turn, seq)
let draft = owner.steps.get(step)
if (draft === undefined) {
draft = { turn, step, firstSeq: seq }
owner.steps.set(step, draft)
} else {
draft.firstSeq = Math.min(draft.firstSeq, seq)
}
return draft
}
for (const { event } of entries) {
const explicit = payloadCoordinates(event)
if (event.type === 'turn/start') {
currentTurn = event.data.turn
currentStep = undefined
}
if (event.type === 'step/start') {
currentTurn = event.data.turn
currentStep = event.data.step
}
if (explicit.session !== true && explicit.turn !== undefined) {
if (currentTurn !== explicit.turn) currentStep = undefined
currentTurn = explicit.turn
if (explicit.step !== undefined) currentStep = explicit.step
}
const turn = explicit.session === true ? undefined : explicit.turn ?? currentTurn
const step = explicit.session === true || event.type === 'turn/start' || event.type === 'turn/end'
? undefined
: explicit.step ?? (turn === currentTurn ? currentStep : undefined)
coordinates.set(event.seq, {
...turn === undefined ? {} : { turn },
...turn === undefined || step === undefined ? {} : { step },
})
if (turn !== undefined) turnDraft(turn, event.seq)
if (turn !== undefined && step !== undefined) stepDraft(turn, step, event.seq)
if (event.type === 'turn/start') {
turnDraft(event.data.turn, event.seq).start = event
} else if (event.type === 'turn/end') {
turnDraft(event.data.turn, event.seq).end = event
} else if (event.type === 'step/start') {
stepDraft(event.data.turn, event.data.step, event.seq).start = event
} else if (event.type === 'step/end') {
stepDraft(event.data.turn, event.data.step, event.seq).end = event
}
if (event.type === 'step/end' && currentTurn === event.data.turn && currentStep === event.data.step) {
currentStep = undefined
}
if (event.type === 'turn/end' && currentTurn === event.data.turn) {
currentTurn = undefined
currentStep = undefined
}
}
const previousTurns = this.timeline.turns
const nextTurns = new Map<number, TurnLocation>()
const orderedDrafts = [...turns.values()].sort((left, right) => left.firstSeq - right.firstSeq)
for (const draft of orderedDrafts) {
const previousTurn = previousTurns.get(draft.turn)
const previousSteps = new Map(previousTurn?.steps.map(step => [step.step, step]) ?? [])
const steps = [...draft.steps.values()]
.sort((left, right) => left.firstSeq - right.firstSeq)
.map((candidate): StepLocation => {
const value: StepLocation = {
turn: candidate.turn,
step: candidate.step,
start: candidate.start,
end: candidate.end,
status: candidate.end !== undefined
? 'closed'
: candidate.start === undefined ? 'unknown' : 'open',
data: this.stepData(candidate.turn, candidate.step),
}
const previous = previousSteps.get(candidate.step)
return sameStep(previous, value) ? previous as StepLocation : value
})
const value: TurnLocation = {
turn: draft.turn,
start: draft.start,
end: draft.end,
status: draft.end !== undefined ? 'closed' : draft.start === undefined ? 'unknown' : 'open',
steps,
data: this.turnData(draft.turn),
}
nextTurns.set(draft.turn, sameTurn(previousTurn, value) ? previousTurn as TurnLocation : value)
}
const nextOrder = orderedDrafts.map(draft => draft.turn)
const turnOrder = this.timeline.turnOrder.length === nextOrder.length
&& this.timeline.turnOrder.every((turn, index) => turn === nextOrder[index])
? this.timeline.turnOrder
: nextOrder
let sameMap = previousTurns.size === nextTurns.size
if (sameMap) {
for (const [turn, value] of nextTurns) {
if (previousTurns.get(turn) !== value) {
sameMap = false
break
}
}
}
this.timeline = sameMap && turnOrder === this.timeline.turnOrder
? this.timeline
: { turnOrder, turns: nextTurns }
this.coordinates = coordinates
this.locations = new Map()
this.seqsByTurn = new Map()
for (const { event } of entries) {
const coordinates = this.coordinates.get(event.seq)
if (coordinates?.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
}
this.currentTurn = currentTurn
this.currentStep = currentStep
const changed = new Set<number>()
for (const { event } of entries) {
if (!sameLocation(previousLocations.get(event.seq), this.locations.get(event.seq))) {
changed.add(event.seq)
}
}
return changed
}
/**
* Append one Turn/Step boundary while revisiting only the owning Turn.
* @param event - contiguous tail boundary event.
* @returns seqs whose immutable Location reference changed.
*/
appendBoundary(event: SessionEvent): ReadonlySet<number> {
if (event.type !== 'turn/start' && event.type !== 'turn/end'
&& event.type !== 'step/start' && event.type !== 'step/end') {
throw new Error(`conversation Location boundary expected, received ${event.type}`)
}
const explicit = payloadCoordinates(event)
if (event.type === 'turn/start') {
this.currentTurn = event.data.turn
this.currentStep = undefined
} else if (event.type === 'step/start') {
this.currentTurn = event.data.turn
this.currentStep = event.data.step
}
if (explicit.turn !== undefined) {
if (this.currentTurn !== explicit.turn) this.currentStep = undefined
this.currentTurn = explicit.turn
if (explicit.step !== undefined) this.currentStep = explicit.step
}
const turnNumber = explicit.turn ?? this.currentTurn
if (turnNumber === undefined) throw new Error(`conversation boundary ${event.type} has no turn`)
const stepNumber = event.type === 'turn/start' || event.type === 'turn/end'
? undefined
: explicit.step ?? (turnNumber === this.currentTurn ? this.currentStep : undefined)
this.coordinates.set(event.seq, {
turn: turnNumber,
...stepNumber === undefined ? {} : { step: stepNumber },
})
this.indexTurnSeq(turnNumber, event.seq)
const previousTurn = this.timeline.turns.get(turnNumber)
let steps = previousTurn?.steps ?? []
if (event.type === 'step/start' || event.type === 'step/end') {
const number = event.data.step
const previousStep = steps.find(candidate => candidate.step === number)
const candidate: StepLocation = {
turn: turnNumber,
step: number,
start: event.type === 'step/start' ? event : previousStep?.start,
end: event.type === 'step/end' ? event : previousStep?.end,
status: event.type === 'step/end' || previousStep?.end !== undefined ? 'closed' : 'open',
data: this.stepData(turnNumber, number),
}
const nextStep = sameStep(previousStep, candidate) ? previousStep as StepLocation : candidate
const index = steps.findIndex(step => step.step === number)
steps = index < 0
? [...steps, nextStep]
: steps.map((step, at) => at === index ? nextStep : step)
}
const candidate: TurnLocation = {
turn: turnNumber,
start: event.type === 'turn/start' ? event : previousTurn?.start,
end: event.type === 'turn/end' ? event : previousTurn?.end,
status: event.type === 'turn/end' || previousTurn?.end !== undefined
? 'closed'
: event.type === 'turn/start' || previousTurn?.start !== undefined ? 'open' : 'unknown',
steps,
data: this.turnData(turnNumber),
}
const turn = sameTurn(previousTurn, candidate) ? previousTurn as TurnLocation : candidate
const turns = new Map(this.timeline.turns)
turns.set(turnNumber, turn)
const turnOrder = previousTurn === undefined
? [...this.timeline.turnOrder, turnNumber]
: this.timeline.turnOrder
this.timeline = { turnOrder, turns }
const changed = new Set<number>()
for (const seq of this.seqsByTurn.get(turnNumber) ?? []) {
const previous = this.locations.get(seq)
const next = this.resolve(seq)
this.locations.set(seq, next)
if (!sameLocation(previous, next)) changed.add(seq)
}
if (event.type === 'step/end' && this.currentTurn === event.data.turn && this.currentStep === event.data.step) {
this.currentStep = undefined
}
if (event.type === 'turn/end' && this.currentTurn === event.data.turn) {
this.currentTurn = undefined
this.currentStep = undefined
}
return changed
}
/**
* Index one non-boundary tail event without rescanning the window.
* @param event - contiguous appended event.
*/
appendNonBoundary(event: SessionEvent): void {
const explicit = payloadCoordinates(event)
if (explicit.session === true) {
this.coordinates.set(event.seq, {})
this.locations.set(event.seq, SESSION_LOCATION)
return
}
if (explicit.turn !== undefined) {
if (this.currentTurn !== explicit.turn) this.currentStep = undefined
this.currentTurn = explicit.turn
if (explicit.step !== undefined) this.currentStep = explicit.step
}
const turn = explicit.turn ?? this.currentTurn
const step = explicit.step ?? (turn === this.currentTurn ? this.currentStep : undefined)
this.coordinates.set(event.seq, {
...turn === undefined ? {} : { turn },
...turn === undefined || step === undefined ? {} : { step },
})
if (turn !== undefined) this.indexTurnSeq(turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
}
private indexTurnSeq(turn: number, seq: number): void {
const current = this.seqsByTurn.get(turn) ?? new Set<number>()
current.add(seq)
this.seqsByTurn.set(turn, current)
}
private turnData(turn: number): ConversationLocationDataStore<ConversationTurnDataMap> {
return this.mutableTurnData(turn) as ConversationLocationDataStore<ConversationTurnDataMap>
}
private stepData(turn: number, step: number): ConversationLocationDataStore<ConversationStepDataMap> {
return this.mutableStepData(stepDataKey(turn, step)) as ConversationLocationDataStore<ConversationStepDataMap>
}
private mutableTurnData(turn: number): MutableLocationDataStore {
const current = this.turnDataStores.get(turn) ?? new MutableLocationDataStore()
this.turnDataStores.set(turn, current)
return current
}
private mutableStepData(key: string): MutableLocationDataStore {
const current = this.stepDataStores.get(key) ?? new MutableLocationDataStore()
this.stepDataStores.set(key, current)
return current
}
private storeFor(data: ConversationLocationData): MutableLocationDataStore {
return data.kind === 'turn'
? this.mutableTurnData(data.turn)
: this.mutableStepData(stepDataKey(data.turn, requireStep(data)))
}
private resolve(seq: number): ConversationLocation {
const coordinates = this.coordinates.get(seq)
if (coordinates?.turn === undefined) return SESSION_LOCATION
const turn = this.timeline.turns.get(coordinates.turn)
if (turn === undefined) return UNRESOLVED_LOCATION
if (coordinates.step === undefined) return { kind: 'turn', turn }
const step = turn.steps.find(candidate => candidate.step === coordinates.step)
return step === undefined ? { kind: 'turn', turn } : { kind: 'step', turn, step }
}
}
function stepDataKey(turn: number, step: number): string {
return `${turn}:${step}`
}
function requireStep(data: ConversationLocationData): number {
if (data.kind === 'step' && data.step !== undefined) return data.step
throw new Error(`conversation Step data "${data.key}" requires a step`)
}
@@ -1,7 +1,9 @@
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
// Immutability contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
// Publication contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). Chat node and
// Location stores are stable live readers, so old snapshots are not time-point
// views. callId/approvalId stay plain string here (narrow to real brands when
// convenient).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -13,6 +15,9 @@ import type {
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
import type {
ChatConversationViewNode, ConversationTimelineSnapshot,
} from '../contract/conversation.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -206,7 +211,7 @@ export interface CompactionSummaryNode {
* Fallback for surface events this UI version does not know: the documented
* default arm of `SessionEventMap`, which is merge-extensible, so the
* projection's switch cannot end in `assertNever`. No event produces this node
* today — `isAppendSurfaceEvent` admits only the four types in core's
* today — `isAppendSurfaceEvent` admits only the three types in core's
* `SurfaceEventType`, and each has its own arm — and it exists so widening that
* set core-side degrades to a raw row instead of dropping the event silently.
*/
@@ -222,8 +227,8 @@ export interface UnknownSurfaceNode {
/**
* One slash-command lifecycle folded from the log-only `command/run` /
* `command/done` pair (paired by commandId, mirroring tool call↔result).
* Log-only events are not surface events, so the TranscriptAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* Log-only events are not surface events, so the command Definition indexes
* them separately and the Chat builder orders the resulting node by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/args null), and a run with no done renders as
* still executing.
@@ -311,21 +316,19 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* Input-area shape of an OPEN session, derived at snapshot assembly (the one
* place that knows the predicate — consumers switch, never re-derive):
*
* - `blank`: no activity ever (no nodes, no partial, not running, no pending
* waits, no prompt attempt) — the UI renders the blank-session guidance
* hero.
* - `engaging`: the first prompt was initiated but no content landed yet —
* the UI holds the composer through the accept → running → first-event
* frames. Entered synchronously before prompt()'s first await.
* - `active`: content exists (nodes, partial, running turn, or pending
* waits) — the ordinary conversation view.
* - `blank`: the authoritative blank bit is still set and no prompt was
* attempted — the UI renders the blank-session guidance hero.
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
*
* Monotone within a session object: blank → engaging → active, no returns.
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; bouncing back to the hero would discard the error context).
* semantics; returning to the hero would discard the error context).
* Sessions whose window is not open (`loading`/`error`) are outside phase
* jurisdiction: consumers branch on {@link ConversationSnapshot.openState}
* first (phase still reports `active`-ish facts but must not be rendered).
* first.
*/
export type ComposerPhase = 'blank' | 'engaging' | 'active'
@@ -335,10 +338,76 @@ export interface PromptError {
error: RpcError
}
/**
* Stable live per-key reader. An old ChatSnapshot observes later flushes
* through this store.
*/
export interface ChatNodeStore {
/** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
get(key: string): ChatConversationViewNode | undefined
/** @returns all currently materialized Nodes without imposing render order. */
values(): readonly ChatConversationViewNode[]
}
/**
* Stable live Location index. An old ChatSnapshot observes later membership
* changes through this index.
*/
export interface ChatLocationNodeIndex {
/** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */
getTurn(turn: number): readonly string[]
/** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */
getStep(turn: number, step: number): readonly string[]
}
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
export interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
readonly turnEnds: ReadonlyMap<number, number>
readonly partial: PartialAssistant | null
readonly runningCalls: readonly RunningToolCall[]
}
/** Incremental Chat publication with immutable order and stable live keyed readers. */
export interface ChatSnapshot {
readonly order: readonly string[]
readonly nodes: ChatNodeStore
readonly locations: ChatLocationNodeIndex
readonly timeline: ConversationTimelineSnapshot
readonly legacy: LegacyConversationSlice
}
const EMPTY_LIST: readonly never[] = []
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
order: EMPTY_LIST,
nodes: {
get: () => undefined,
values: () => EMPTY_LIST,
},
locations: {
getTurn: () => EMPTY_LIST,
getStep: () => EMPTY_LIST,
},
timeline: EMPTY_TIMELINE,
legacy: {
nodes: EMPTY_LIST,
turnTimings: new Map(),
turnEnds: new Map(),
partial: null,
runningCalls: EMPTY_LIST,
},
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
/** Final Chat target assembled from independently registered business Definitions. */
chat: ChatSnapshot
/** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */
nodes: readonly ConversationNode[]
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
@@ -10,6 +10,7 @@ import type {
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { ConversationRuntime } from './conversation-assembler.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import type { PendingInteractionStatus } from './pending.ts'
@@ -158,6 +159,7 @@ export class SessionManager {
private readonly api: IApiClient,
restoredSelection?: SessionId,
restoredAddress?: SubagentAddress,
private readonly conversation?: ConversationRuntime,
) {
this.selected = restoredSelection
if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
@@ -282,7 +284,12 @@ export class SessionManager {
const address = this.addresses.get(sessionId)
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') session.handleRunning(child.activity === 'running')
if (child?.kind === 'child') {
// A catalogued child exists only after its delegated session has
// durable history, even though child rows do not carry `blank`.
session.handleBlank(false)
session.handleRunning(child.activity === 'running')
}
}
}
return session
@@ -301,9 +308,15 @@ export class SessionManager {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
},
projections: this.projectionStore(sessionId),
...this.conversation === undefined ? {} : { conversation: this.conversation },
})
}
/** Rebuild every resident Session after one coalesced registry transaction. */
rebuildConversationRegistry(): void {
for (const session of this.sessions.values()) session.rebuildConversationRegistry()
}
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
private projectionStore(sessionId: SessionId): ProjectionValueStore {
let store = this.projectionStores.get(sessionId)
@@ -48,7 +48,7 @@ export class PartialAccumulator {
push(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'block-start': {
this.blocks[chunk.index] = emptyBlock(chunk.blockType)
this.blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
this.changed = true
return true
}
@@ -102,7 +102,12 @@ export class PartialAccumulator {
}
}
function emptyBlock(blockType: string): AssistantBlock {
/**
* Create the empty client projection for one streamed Assistant block kind.
* @param blockType - wire block kind.
* @returns empty projected block ready to receive deltas.
*/
export function emptyAssistantBlock(blockType: string): AssistantBlock {
switch (blockType) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
@@ -0,0 +1,74 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { QueuedMessage } from './conversation.ts'
const QUEUE_PREVIEW_CHARS = 200
function previewOf(content: readonly ContentBlock[]): string {
const flat = content
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
function textOf(content: readonly ContentBlock[]): string | null {
if (!content.every(block => block.type === 'text')) return null
return content.map(block => block.text).join('')
}
type QueueItems = Extract<MuxFrame, { type: 'session/queue' }>['items']
/** Authoritative transient queue projection and durable steering handoff. */
export class SessionQueueMirror {
private current: readonly QueuedMessage[] = []
/**
* Return the current immutable queue projection.
* @returns current queue rows.
*/
snapshot(): readonly QueuedMessage[] {
return this.current
}
/**
* Drop the stale generation before its replacement queue baseline arrives.
* @returns whether any projected queue row was removed.
*/
reset(): boolean {
if (this.current.length === 0) return false
this.current = []
return true
}
/**
* Replace from one authoritative stream queue frame.
* @param items - complete host queue snapshot.
*/
replace(items: QueueItems): void {
this.current = items.map(item => ({
id: item.id,
messageId: item.message.id,
placement: item.placement,
content: item.message.content,
preview: previewOf(item.message.content),
text: textOf(item.message.content),
}))
}
/**
* Retire a transient steering row once its durable message enters the log.
* @param event - newly contiguous durable Session event.
* @returns whether the projection changed.
*/
acceptDurable(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const messageId = event.data.id
const index = this.current.findIndex(item =>
item.placement === 'steering' && item.messageId === messageId)
if (index < 0) return false
this.current = this.current.filter((_item, candidate) => candidate !== index)
return true
}
}
@@ -5,6 +5,9 @@
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-compact/types'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
@@ -109,48 +112,6 @@ export function inspectRequests(
}
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number | null }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number | null; error?: string }
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
@@ -205,10 +166,8 @@ function deriveCallSchemas(
capture(String(event.data.callId), event.data.name)
continue
}
const type = event.type as string
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
const data = event.data as unknown as { subCallId: string; name: string }
capture(data.subCallId, data.name)
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
capture(String(event.data.subCallId), event.data.name)
}
}
return calls
@@ -351,14 +310,14 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
if (sourceEvent.type === 'llm/retry') {
const data = sourceEvent.data
updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
status: 'error',
error: displayFailureMessage(event.data.failure),
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
error: displayFailureMessage(data.failure),
retry: data.retry,
...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
retryDelayMs: data.delayMs,
})
continue
}
@@ -374,8 +333,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
continue
}
const type = sourceEvent.type as string
if (type === 'session/end-seed' && activeCompaction !== undefined) {
if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
@@ -384,37 +342,36 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
activeCompaction = undefined
continue
}
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
if (sourceEvent.type === 'compact/start') {
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: event.seq,
turn: event.data.turn,
startSeq: sourceEvent.seq,
turn: sourceEvent.data.turn,
step: 0,
startedAt: event.time,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
})
continue
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
const data = sourceEvent.data
updateCompaction(activeCompaction, {
resultSeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
resultSeq: sourceEvent.seq,
summary: data.summary,
...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
provider: data.provider,
model: data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
provider: data.provider,
model: data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
...(data.usage === undefined ? {} : { usage: data.usage }),
})
continue
}
@@ -426,12 +383,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
updateCompaction(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
completedAt: sourceEvent.time,
status: sourceEvent.data.error === undefined ? 'complete' : 'error',
...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
})
activeCompaction = undefined
}
@@ -31,6 +31,7 @@ import { createSnapshotStore } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import type { ConversationRuntime } from './conversation-assembler.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { PendingInteractionStatus } from './pending.ts'
@@ -259,16 +260,30 @@ export class SessionsService implements ISessions {
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
* @param conversationRuntime - same-pass registry instances, when runtime apply owns them.
*/
constructor(
private readonly rootCtx: Context,
api: IApiClient,
conversationRuntime?: ConversationRuntime,
) {
this.selection = createSnapshotStore<SessionSelection>(
{},
{ persist: { name: 'dsh.sessions.current' } })
const restored = this.selection.getSnapshot()
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
const conversationEvents = rootCtx.get('conversationEvents')
const conversationViews = rootCtx.get('conversationViews')
const conversation = conversationRuntime ?? (
conversationEvents === undefined || conversationViews === undefined
? undefined
: { events: conversationEvents, views: conversationViews }
)
this.manager = new SessionManager(
api,
restored.sessionId,
restored.subagentAddress,
conversation,
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
@@ -296,6 +311,25 @@ export class SessionsService implements ISessions {
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.provideChannel.currentProvideInfo
let registryRebuildQueued = false
const scheduleRegistryRebuild = (): void => {
if (registryRebuildQueued) return
registryRebuildQueued = true
queueMicrotask(() => {
registryRebuildQueued = false
this.manager.rebuildConversationRegistry()
})
}
if (conversation !== undefined) {
rootCtx.effect(() => {
const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild)
const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild)
return () => {
disposeEvents()
disposeViews()
}
}, 'sessions: conversation registry rebuild')
}
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -2,7 +2,6 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
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,
@@ -12,28 +11,24 @@ import type {
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import { ConversationNodeAssembler } from './conversation-assembler.ts'
import type { ConversationRuntime } from './conversation-assembler.ts'
import type { ConversationEventInput, ConversationPublication } from '../contract/conversation.ts'
import type {
ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError,
} from './conversation.ts'
import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { ToolCallTree } from './tool-call-tree.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
// Browser bundles cannot value-import the host timeout library. This protocol
// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
@@ -55,24 +50,8 @@ export interface SessionOptions {
* private store (bare object-layer construction).
*/
projections?: ProjectionValueStore
}
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
const QUEUE_PREVIEW_CHARS = 200
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
function queuePreviewOf(content: readonly ContentBlock[]): string {
const flat = content
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
/** Recover complete composer text only when editing cannot discard non-text blocks. */
function queueTextOf(content: readonly ContentBlock[]): string | null {
if (!content.every(block => block.type === 'text')) return null
return content.map(block => block.text).join('')
/** Runtime registries used by this Session-owned Conversation assembler. */
conversation?: ConversationRuntime
}
/**
@@ -97,42 +76,13 @@ export class Session implements SessionFace {
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Last entered step per turn, folded from step/start for terminal error placement. */
private lastStepByTurn = new Map<number, number>()
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events and rebuilt with partial/openCalls; the transcript is
* seq-monotonic, so a plain seq merge preserves event order. */
private derivedNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
private callsRev = 0
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Exact turn timing retained from the raw window so presentation never
* infers elapsed time from transcript content. */
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
private turnTimingsRev = 0
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
/** Completed turn boundaries retained from the raw window so presentation
* actions never infer a safe fork point from transcript content alone. */
private turnEnds = new Map<number, number>()
private turnEndsRev = 0
private turnEndsCache: { rev: number; value: ReadonlyMap<number, number> } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
/** Window-derived child-call lifecycle and immutable tree projection. */
private readonly toolCallTree = new ToolCallTree()
private readonly queueMirror = new SessionQueueMirror()
/** Session-owned business Context engine over the contiguous raw window. */
private readonly conversation: ConversationNodeAssembler
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
@@ -142,8 +92,10 @@ export class Session implements SessionFace {
* engaging edge of the phase machine (see ComposerPhase).
*/
private promptAttempted = false
/** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */
private blankBit = false
/** A first accepted prompt stays in the engaging phase until its turn is observable. */
private firstPromptPendingTurn = false
/** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
private blankBit = true
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
@@ -168,9 +120,7 @@ export class Session implements SessionFace {
readonly projections: ProjectionValueStore
private snapshotCache: ConversationSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
private readonly notifier: Notifier
/**
* Agent-scoped cordis context, bound once by SessionsService when it
* mints the scope (the client mirror of the host Agent's loopCtx). The
@@ -193,6 +143,16 @@ export class Session implements SessionFace {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.conversation = options.conversation === undefined
? new ConversationNodeAssembler(
{ entries: () => [], fallbackEntry: () => undefined },
{ entries: () => [] },
)
: new ConversationNodeAssembler(options.conversation.events, options.conversation.views)
this.notifier = new Notifier(() => {
this.conversation.flush()
this.snapshotCache = this.buildSnapshot()
})
this.snapshotCache = this.buildSnapshot()
}
@@ -229,6 +189,7 @@ export class Session implements SessionFace {
// visible on the session area's very first frame when a caller sends
// ahead of navigation (first-send flow).
this.promptAttempted = true
if (this.blankBit) this.firstPromptPendingTurn = true
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
@@ -385,6 +346,7 @@ export class Session implements SessionFace {
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
this.conversation.prepend([], this.hasMore)
return
}
const tail = older[older.length - 1]
@@ -392,6 +354,7 @@ export class Session implements SessionFace {
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
this.hasMore = false
this.conversation.prepend([], false)
return
}
this.events = [...older.map(e => e.event), ...this.events]
@@ -399,8 +362,7 @@ export class Session implements SessionFace {
/* 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.conversation.prepend(older.map(conversationInput), this.hasMore)
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
} finally {
@@ -471,15 +433,7 @@ export class Session implements SessionFace {
return
}
case 'session/queue': {
this.queued = frame.items.map(item => ({
id: item.id,
messageId: item.message.id,
placement: item.placement,
content: item.message.content,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
}))
this.queueRev++
this.queueMirror.replace(frame.items)
this.notifier.markDirty()
return
}
@@ -489,11 +443,7 @@ export class Session implements SessionFace {
// snapshot AFTER the subscribed frame on the same stream, so the
// stale mirror clears here — race-free against onConnected/resync
// timing (clearing there could wipe a baseline that already landed).
if (this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
if (this.queueMirror.reset()) this.notifier.markDirty()
return
}
case 'approval/requested': {
@@ -537,6 +487,7 @@ export class Session implements SessionFace {
this.blankBit = false
this.notifier.markDirty()
}
if (running) this.firstPromptPendingTurn = false
if (this.running === running) return
this.running = running
this.notifier.markDirty()
@@ -600,6 +551,11 @@ export class Session implements SessionFace {
/** No-op because session instances remain resident. */
dispose(): void {}
/** Rebuild the current window after a low-frequency Definition or view registration change. */
rebuildConversationRegistry(): void {
this.scheduleConversation(this.conversation.rebuildRegistry())
}
// ---- 私有 ----
/** Requested-frame arrival: the wait enters the pending map under its own key. */
@@ -661,8 +617,8 @@ export class Session implements SessionFace {
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
this.conversation.replaceWindow(entries.map(conversationInput), hasMore)
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
@@ -671,32 +627,22 @@ export class Session implements SessionFace {
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: ToolEventView): void {
private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.transcript.append(event, view)
this.handoffPendingSteering(event)
this.applyEventSideEffects(event, view)
}
/** Retire the first matching live steering occurrence when its durable message takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'user/message') return
const message = event.data
const index = this.queued.findIndex(item =>
item.placement === 'steering' && item.messageId === message.id)
if (index === -1) return
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
this.queueRev++
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
const publication = this.conversation.append({ event, view })
return queueChanged ? 'immediate' : publication
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* 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. */
* raw range, which lets Conversation Definitions correlate every recorded event between its
* ends and lets a compaction checkpoint resolve its cited summary event. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
@@ -709,12 +655,13 @@ export class Session implements SessionFace {
void this.repairGap()
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
return
}
this.notifier.markDirty()
this.scheduleConversation(this.appendLive(event, view))
}
/** Route assembler cadence into the Session's existing microtask/RAF notifier. */
private scheduleConversation(publication: ConversationPublication): void {
if (publication === 'immediate') this.notifier.markDirty()
else if (publication === 'animation-frame') this.notifier.markFrameDirty()
}
/** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
@@ -738,238 +685,35 @@ export class Session implements SessionFace {
}
}
/** 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 {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)
if (data === null) {
console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
return
}
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
time: event.time,
retryState: 'scheduled',
...data,
})
this.derivedRev++
return
}
// These lifecycle events are declared by a host-only plugin whose Context
// types cannot enter the client program. ToolCallTree owns their structural
// wire narrowing, pairing, and nested snapshot projection.
if (this.toolCallTree.apply(event)) return
switch (event.type) {
case 'turn/start':
this.lastStepByTurn.set(event.data.turn, 0)
this.turnTimings.set(event.data.turn, { startTime: event.time })
this.turnTimingsRev++
return
case 'step/start':
this.lastStepByTurn.set(event.data.turn, event.data.step)
return
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
this.settleScheduledRetry('started', turn)
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
this.partial.push(chunk)
return
}
case 'assistant/message': {
if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) {
this.partial = null // finalize swaps in place (same notification batch, no flicker)
}
return
}
case 'tool/call': {
this.openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null,
subCalls: [],
})
this.callsRev++
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
return
}
case 'turn/end': {
const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0
const timing = this.turnTimings.get(event.data.turn)
if (timing !== undefined) {
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
this.turnTimingsRev++
}
this.turnEnds.set(event.data.turn, event.seq)
this.turnEndsRev++
if (event.data.reason.kind === 'aborted') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = event.data.reason.error
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: lastStep,
message: displayFailureMessage(failure),
code: failure.code,
})
this.derivedRev++
}
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
// from the logged chunks. Content-free partials are dropped outright.
if (this.partial !== null && this.partial.turn === event.data.turn) {
const { blocks } = this.partial.toPartial()
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.derivedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.derivedRev++
}
this.partial = null
}
let callOffset = 0
for (const [callId, call] of this.openCalls) {
if (call.turn !== event.data.turn) continue
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.derivedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null, subCalls: [],
})
this.derivedRev++
}
this.lastStepByTurn.delete(event.data.turn)
return
}
default:
return
}
}
/**
* Settle the newest scheduled retry, optionally restricted to its failed turn.
* @param retryState - next client projection state to publish.
* @param turn - failed turn required for cancellation; omitted for the next retry turn start.
*/
private settleScheduledRetry(
retryState: Exclude<ModelRetryNode['retryState'], 'scheduled'>,
turn?: number,
): void {
const index = this.derivedNodes.findLastIndex(node =>
node.kind === 'model-retry'
&& node.retryState === 'scheduled'
&& (turn === undefined || node.turn === turn))
if (index < 0) return
const node = this.derivedNodes[index]
/* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
if (node?.kind !== 'model-retry') return
this.derivedNodes[index] = { ...node, retryState }
this.derivedRev++
}
/** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild keeps
* paging/stitching consistent, and makes live handling and history replay converge on the same
* retry notices and interrupted nodes. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.lastStepByTurn.clear()
this.callsRev++
this.derivedNodes = []
this.derivedRev++
this.turnTimings = new Map()
this.turnTimingsRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.toolCallTree.reset()
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.applyEventSideEffects(event, this.views[i])
}
}
private windowTailSeq(): number | null {
const tail = this.events[this.events.length - 1]
return tail === undefined ? null : tail.seq
}
private buildSnapshot(): ConversationSnapshot {
const projected = this.transcript.nodes()
// Derived interruption nodes ride fractional seqs while retry notices keep their event seq.
// The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the
// merge on (projected reference, derivedRev) to retain identity across unrelated swaps.
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
nodes = this.derivedNodes.length === 0
? projected
: [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
}
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
}
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
const chat = (this.conversation.snapshot('chat') as ChatSnapshot | undefined) ?? EMPTY_CHAT_SNAPSHOT
const legacy = chat.legacy
return {
sessionId: this.sessionId,
nodes: this.toolCallTree.projectNodes(nodes),
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
chat,
nodes: legacy.nodes,
turnTimings: legacy.turnTimings,
turnEnds: legacy.turnEnds,
partial: legacy.partial,
runningCalls: legacy.runningCalls,
pending: this.pendingCache.value,
queue: this.queueCache.value,
queue: this.queueMirror.snapshot(),
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
// the host's no-turn sessionBlank predicate).
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
(!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
@@ -995,67 +739,18 @@ export class Session implements SessionFace {
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */
function parseRetryEventData(value: unknown): LlmRetryEventData | null {
if (value === null || typeof value !== 'object') return null
const data = value as Record<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
if (!nonNegativeSafeInteger(data.turn)
|| !nonNegativeSafeInteger(data.step)
|| typeof data.provider !== 'string'
|| data.provider.length === 0
|| typeof data.policyKey !== 'string'
|| data.policyKey.length === 0
|| !positiveSafeInteger(data.retry)
|| typeof data.delayMs !== 'number'
|| !Number.isFinite(data.delayMs)
|| data.delayMs < 0
|| data.delayMs > MAX_RETRY_DELAY_MS
|| typeof failureData.message !== 'string'
|| failureData.message.length === 0
|| typeof failureData.code !== 'string'
|| failureData.code.length === 0) return null
if (data.mode === 'normal') {
if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
} else if (data.mode === 'always') {
if ('maxRetries' in data) return null
} else {
return null
}
if (failureData.status !== undefined
&& (typeof failureData.status !== 'number'
|| !Number.isInteger(failureData.status)
|| failureData.status < 100
|| failureData.status > 599)) return null
if (failureData.providerRetryAfterMs !== undefined
&& (typeof failureData.providerRetryAfterMs !== 'number'
|| !Number.isFinite(failureData.providerRetryAfterMs)
|| failureData.providerRetryAfterMs <= 0)) return null
if (failureData.requestId !== undefined
&& (typeof failureData.requestId !== 'string'
|| failureData.requestId.length === 0)) return null
return data as unknown as LlmRetryEventData
}
function nonNegativeSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function positiveSafeInteger(value: unknown): value is number {
return nonNegativeSafeInteger(value) && value > 0
/** Convert one wire history row into the assembler's transport-neutral input. */
function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/**
* The composerPhase judgment the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank engaging active never steps back; a failed first
* prompt stays engaging (retry semantics see ComposerPhase).
* @param hasContent - any conversation material exists (non-command nodes,
* partial, running turn, pending waits; command lifecycle rows alone keep
* the session blank).
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/
@@ -1,8 +1,7 @@
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
type InboxTarget = 'next-turn' | 'next-step'
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
/** Minimal pending identity retained while replaying durable inbox splices. */
interface PendingIdentity {
@@ -45,8 +44,8 @@ export class SteeringHistory {
* @returns true only for a user-origin message previously claimed from `next-step`.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'agent/inbox/spliced') {
this.applySplice(event.data as unknown as InboxSplice)
if (event.type === 'agent/inbox/spliced') {
this.applySplice(event.data)
return false
}
if (event.type !== 'user/message') return false
@@ -1,5 +1,5 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from './conversation.ts'
@@ -55,13 +55,8 @@ export class ToolCallTree {
* @returns Whether the event was consumed as a child-call lifecycle event.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
if (event.type === 'tool/code-dispatch-start') {
const data = event.data
const running: RunningToolCall = {
callId: data.subCallId,
name: data.name,
@@ -78,15 +73,8 @@ export class ToolCallTree {
this.revision++
return true
}
if ((event.type as string) !== 'tool/code-dispatch') return false
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
if (event.type !== 'tool/code-dispatch') return false
const data = event.data
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true
@@ -1,409 +0,0 @@
// TranscriptAdapter: the human transcript projected from the raw event window
// in LOG order. The model-visible surface deliberately shadows replaced ranges,
// so it is the wrong source for conversation a reader already saw; this adapter
// keeps every append-origin event at its own log position and contributes one
// marker node per landed compaction checkpoint. Node order is therefore
// seq-monotonic by construction — no surface fold, no padding sentinels, no
// seq === index assertion to satisfy, and no degradation branch.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Cordis-free leaf subpath (the dsh-commands/brand shape): the Service Definition's
// declaration of the checkpoint source, reachable as a TYPE from this program.
// The package ROOT is not — it reaches dsh-session's root, whose Context merge
// declares the HOST `sessions: SessionStore` against this program's
// `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 { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
import { SteeringHistory } from './steering-history.ts'
import type { AssistantStepMetadata } from './assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
/**
* The compaction capability's checkpoint plugin, pinned to the Service Definition's declaration
* at COMPILE time: renaming it there fails this annotation (`TS2322`). The
* import stays type-only because a value import would fail the client purity
* gate (`packages/client/tsdown.client.ts`) cross-plugin value imports are
* forbidden in a browser bundle while an erased type never reaches it.
*/
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
/** In-window tool/call index entry used to materialize result cards. */
interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
steering: boolean,
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
case 'user/message': {
// Injected context (plugin/goal/skill-invocation source) folds to a
// context node, not a user message; only a direct human prompt is a
// user node. A compaction checkpoint never reaches here
// (isCompactCheckpoint routes it away).
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time),
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types
can be append-origin, and each has a case above; reachable only if core
adds an eligible type. */
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/**
* Whether an event is a landed compaction checkpoint all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
* compaction seam's checkpoint plugin source, that REPLACED a surface range. A
* plugin-sourced `user/message` that appends is injected context (a
* session-reference card), not a compaction; a replacement `tool/result` is an
* in-place prune and a replacement `assistant/message` a generic rewrite, and
* both mark no boundary in the conversation.
* @param event - the raw window event.
* @returns true when the event compacted a surface range.
*/
function isCompactCheckpoint(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const source = event.data.source
return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN
&& isReplacementSurfaceEvent(event)
}
/** Whether an event contributes a node to the human transcript. */
function isTranscriptEvent(event: SessionEvent): boolean {
return isAppendSurfaceEvent(event) || isCompactCheckpoint(event)
}
/**
* Concatenated text of a `compact/summary` payload, or null when it carries no
* usable text. The payload is a `ContentBlock[]` whose union is
* merge-extensible, so a non-text block is skipped rather than discarding the
* text beside it; a payload with no text block at all falls to null through the
* empty check.
*/
function compactSummaryText(event: SessionEvent): string | null {
const summary = (event.data as unknown as { summary?: unknown }).summary
if (!Array.isArray(summary)) return null
let text = ''
for (const block of summary as readonly unknown[]) {
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue
text += candidate.text
}
return text.trim() === '' ? null : text
}
interface CompactSummaryDetails {
readonly summary: string | null
readonly shadowedItemCount: number | null
readonly shadowedTokenCount: number | null
}
/** Recover human-facing summary material from one structurally narrowed wire event. */
function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails {
const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown }
const shadowedSeqs = data.shadowedSeqs
const tokenCount = data.shadowedTokenCount
return {
summary: compactSummaryText(event),
shadowedItemCount: Array.isArray(shadowedSeqs)
&& shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0)
? shadowedSeqs.length
: null,
shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0
? tokenCount as number
: null,
}
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's cited `compact/summary` event (`sourceEventSeqs` names the
* `compact/summary` event), never from the framed checkpoint payload, which is
* an instruction envelope written for the model. A window cut that left the
* summary event outside soft-falls to `summary: null` (a non-expandable marker),
* the same posture as a call-less tool result.
*/
function materializeCompaction(
checkpoint: SessionEvent,
eventIndex: ReadonlyMap<number, SessionEvent>,
): CompactionSummaryNode {
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
let summary: string | null = null
let summaryEventSeq: number | null = null
let shadowedItemCount: number | null = null
let shadowedTokenCount: number | null = null
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
const details = compactSummaryDetails(candidate)
summary = details.summary
summaryEventSeq = candidate.seq
shadowedItemCount = details.shadowedItemCount
shadowedTokenCount = details.shadowedTokenCount
break
}
return {
kind: 'compaction',
seq: checkpoint.seq,
time: checkpoint.time,
summary,
summaryEventSeq,
shadowedItemCount,
shadowedTokenCount,
}
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
export class TranscriptAdapter {
/** Window events by seq, used to find the summary event cited by a checkpoint. */
private eventIndex = new Map<number, SessionEvent>()
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
private projected: ConversationNode[] = []
private callIdx = new Map<string, CallIndexEntry>()
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
private stepTimings = new Map<string, AssistantStepMetadata>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
private readonly steeringHistory = new SteeringHistory()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so it is not a surface
* event and never joins the transcript projection; this index folds the pair
* (done settles its run's node in place) and nodes() merges the products in
* by seq. Window cuts soft-fall like tool pairs: a done with no in-window
* run still builds a node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Projection revision, bumped only when a transcript node or a command node actually
* changed, keying the nodes() result cache: an unchanged projection returns the previous
* ARRAY reference, not just cached elements the snapshot's reference-stability contract
* (§A.9.4) starts here, and a chunk storm bumps nothing at all. */
private rev = 0
private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
/**
* Window rebuild (after open/resync/page prepend): re-index the raw window
* and re-project the transcript.
* @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 {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
this.steeringHistory.reset()
const steeringSeqs = new Set<number>()
this.stepTimings = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event === undefined) continue
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
indexAssistantStepTiming(this.stepTimings, event)
}
// 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)))
}
this.projected = projected
}
/**
* Tail append (live session/event): index the event and, when it belongs to
* the transcript, extend the projection by one copy-on-write node so a
* published array never mutates. An event that changes no node (a chunk
* storm) bumps no revision, so nodes() keeps returning the same array
* reference.
* @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 {
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 (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
}
/**
* The current transcript node array. Same revision -> same array reference
* (memo boundary); node objects are materialized once, so an unchanged node
* keeps its identity across appends.
* @returns transcript nodes in log order, command nodes merged in by seq.
*/
nodes(): readonly ConversationNode[] {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
// Command nodes fold outside the transcript (log-only events); merge by
// seq. Both inputs are seq-ascending (log order and run-index insertion
// order are the same order), so one linear merge keeps flow order.
let nodes = this.projected
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of this.projected) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
this.nodesResult = { rev: this.rev, value: nodes }
return nodes
}
/** Materialize one transcript event against the complete current indexes. */
private materialize(event: SessionEvent, steering: boolean): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(
event,
this.callIdx,
this.resultViews.get(event.seq) ?? null,
steering,
this.stepTimings,
)
}
/**
* Fold one command lifecycle event into its node (run mints, done settles in
* place; done-only soft-falls).
* @returns whether the command index changed, so callers can bump the revision.
*/
private indexCommand(event: SessionEvent): boolean {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args?: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null,
})
return true
}
if ((event.type as string) !== 'command/done') return false
const data = event.data as unknown as {
commandId: CommandId
kind: 'success' | 'error'
text?: string
sourceEventSeq?: number
}
const run = this.commandIdx.get(data.commandId)
const sourceEventSeq = data.kind === 'success'
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
? data.sourceEventSeq as number
: undefined
const outcome = {
kind: data.kind,
...data.text === undefined ? {} : { text: data.text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
}
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return true
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}
+1 -1
View File
@@ -320,7 +320,7 @@ export class SlotsService extends Service {
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
const scope = (this._core.specDynamic(options.name) as SlotSpec<SlotEntryDef>).scope
this._acquire(store, scope)
}
let disposed = false
@@ -4,12 +4,14 @@
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts'
import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
import { Session } from '../src/client/sessions/session.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
@@ -112,6 +114,31 @@ describe('runtime client apply', () => {
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
const bench = await mount()
const sessions = bench.ctx.get('sessions') as SessionsService
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-registry' as never,
payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never,
})
await flushMicrotasks()
expect(sessions.binding('s-registry' as never)).toBeDefined()
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
const definition: ConversationNodeDefinition<null> = {
kind: 'registry-probe',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
bench.ctx.conversationEvents.register(definition)
await flushMicrotasks()
expect(rebuild).toHaveBeenCalledOnce()
rebuild.mockRestore()
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
@@ -1,52 +0,0 @@
/**
* Behavioral half of the compaction-checkpoint drift trap.
*
* `TranscriptAdapter` pins its plugin literal to the Service Definition's declaration at
* compile time through a type-only import of `dsh-compact/checkpoint`, so
* renaming the Service Definition's plugin already fails `tsc`. This spec covers the same
* drift from the other side end to end through the adapter, driving it with a
* checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and
* checking the Service Definition's predicate agrees. Both values come from the
* cordis-free checkpoint leaf, so the client test program never loads the host
* package root or its `Context` merges.
*/
import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
/** A replacement user message stamped with the Service Definition's canonical source. */
function canonicalCheckpoint(seq: number): SessionEvent {
return {
type: 'user/message',
seq,
time: 1_700_000_000_000 + seq,
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}),
} as unknown as SessionEvent
}
describe('compaction checkpoint recognition', () => {
it('recognizes a checkpoint carrying the seam-canonical source', () => {
const adapter = new TranscriptAdapter()
adapter.reset([canonicalCheckpoint(1)])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it("agrees with the seam's own predicate on the source it recognizes", () => {
// Both sides answer the same question about the same value: if the Service Definition
// renames its plugin, this equality is what breaks.
const checkpoint = canonicalCheckpoint(1)
expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true)
expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' })
})
})
@@ -0,0 +1,959 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts'
import type {
ConversationEventInput, ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
} from '../src/client/contract/conversation.ts'
interface ScopeProbeStepData {
readonly value: number
}
interface ScopeProbeTurnData {
readonly valueSeenFromStep: number
}
declare module '../src/client/contract/conversation.ts' {
interface ConversationStepDataMap {
'scope-probe': ScopeProbeStepData
}
interface ConversationTurnDataMap {
'scope-probe': ScopeProbeTurnData
}
}
interface TestSnapshot {
readonly order: readonly string[]
readonly nodes: ReadonlyMap<string, ConversationViewNode>
}
class TestEventDefinitions {
constructor(
readonly definitions: readonly ConversationNodeDefinition[],
readonly fallback?: ConversationNodeDefinition,
) {}
entries(): readonly ConversationNodeDefinition[] {
return this.definitions
}
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
}
class TestViewDefinitions {
constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
entries(): readonly ConversationViewDefinition[] {
return this.definitions
}
}
function testView(
apply = vi.fn(),
): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
return {
target: 'chat',
create: () => {
let current: TestSnapshot = { order: [], nodes: new Map() }
return {
empty: current,
replace: ({ nodes }) => {
current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) }
return current
},
apply: ({ upserts }) => {
apply(upserts)
const nodes = new Map(current.nodes)
const order = [...current.order]
for (const node of upserts) {
if (!nodes.has(node.key)) order.push(node.key)
nodes.set(node.key, node)
}
current = { order, nodes }
return current
},
}
},
}
}
function at(seq: number, type: string, data: unknown): SessionEvent {
return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
}
function input(event: SessionEvent): ConversationEventInput {
return { event, view: undefined }
}
function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
return assembler.snapshot('chat') as TestSnapshot | undefined
}
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
return {
key: context.key,
kind: context.kind,
id: context.id,
target: 'chat',
data,
}
}
describe('ConversationNodeAssembler', () => {
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
const starts = vi.fn((
_context: ConversationNodeContext<{ callSeq: number; results: number }>,
match: ConversationMatch,
) => ({ callSeq: match.event.seq, results: 0 }))
const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({
...context.state,
results: context.state.results + 1,
}))
const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = {
kind: 'tool',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
return null
},
start: starts,
update: updates,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })),
input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })),
], false)
assembler.flush()
starts.mockClear()
assembler.append(input(at(3, 'tool/result', {
turn: 1,
step: 1,
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
})))
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).toHaveBeenCalledOnce()
const snapshot = chatSnapshot(assembler)
expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([
{ callSeq: 1, results: 1 },
{ callSeq: 2, results: 0 },
])
})
it('keeps one Match collection while a long Context appends without replay', () => {
const starts = vi.fn(() => 0)
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
context.state + 1
))
const matchCollections = new Set<readonly ConversationMatch[]>()
const definition: ConversationNodeDefinition<number> = {
kind: 'append-linear',
match: (event) => {
const type: string = event.type
if (type === 'linear/start') return { id: 'one', role: 'start' }
if (type === 'linear/update') return { id: 'one', role: 'update' }
return null
},
start: (context) => {
matchCollections.add(context.matches)
return starts()
},
update: (context) => {
matchCollections.add(context.matches)
return updates(context)
},
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'linear/start', {}))], false)
starts.mockClear()
for (let seq = 2; seq <= 1_001; seq++) {
assembler.append(input(at(seq, 'linear/update', {})))
}
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).toHaveBeenCalledTimes(1_000)
expect(matchCollections.size).toBe(1)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
})
it('merges an older page and replays its affected Context once', () => {
const starts = vi.fn(() => 0)
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
context.state + 1
))
const definition: ConversationNodeDefinition<number> = {
kind: 'prepend-linear',
match: (event) => {
const type: string = event.type
if (type === 'linear/start') return { id: 'one', role: 'start' }
if (type === 'linear/update') return { id: 'one', role: 'update' }
return null
},
start: starts,
update: updates,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
const current = Array.from({ length: 100 }, (_, index) => (
input(at(index + 102, 'linear/update', {}))
))
assembler.replaceWindow(current, true)
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).not.toHaveBeenCalled()
const older = [
input(at(1, 'linear/start', {})),
...Array.from({ length: 100 }, (_, index) => (
input(at(index + 2, 'linear/update', {}))
)),
]
assembler.prepend(older, false)
assembler.flush()
expect(starts).toHaveBeenCalledOnce()
expect(updates).toHaveBeenCalledTimes(200)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200)
})
it('collects an update before its start and replays it once prepend supplies the start', () => {
const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true }))
const definition: ConversationNodeDefinition<{ settled: boolean }> = {
kind: 'tool',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
return null
},
start: () => ({ settled: false }),
update: updates,
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(10, 'tool/result', {
turn: 1,
step: 1,
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
}))], true)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ pendingStart: true })
assembler.prepend([input(at(5, 'tool/call', {
turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}',
}))], false)
assembler.flush()
expect(updates).toHaveBeenCalledOnce()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ settled: true })
})
it('rejects a Definition whose declared start follows an update in log order', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'invalid-lifecycle',
match: event => event.type === 'turn/end'
? { id: 'one', role: 'start' }
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
expect(() => assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })),
], false)).toThrow('received an update before its start Match')
})
it('replays a window-gap reader when prepend supplies a nearer predecessor', () => {
const source: ConversationNodeDefinition<number> = {
kind: 'source',
match: event => event.type === 'user/message'
? { id: String(event.data.id), role: 'start' }
: null,
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
update: context => context.state,
buildViewNode: () => null,
}
const consumerStart = vi.fn((
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: consumerStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([source, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(10, 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
}))], true)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
assembler.prepend([input(at(5, 'user/message', {
id: 'm1', value: 7, content: [], source: { kind: 'user' },
}))], false)
assembler.flush()
expect(consumerStart).toHaveBeenCalledTimes(2)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7)
})
it('keeps the predecessor index ordered across prepend and append', () => {
const source: ConversationNodeDefinition<number> = {
kind: 'source',
match: event => event.type === 'user/message'
? { id: String(event.data.id), role: 'start' }
: null,
start: (_context, match) => match.event.seq,
update: context => context.state,
buildViewNode: () => null,
}
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([source, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
input(at(50, 'assistant/message', {
turn: 1, step: 1, message: { role: 'assistant', content: [] },
})),
], true)
assembler.flush()
assembler.prepend([
input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })),
input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })),
], false)
assembler.flush()
assembler.append(input(at(60, 'user/message', {
id: 'm60', content: [], source: { kind: 'user' },
})))
assembler.append(input(at(70, 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
})))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
.toEqual([40, 60])
})
it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => {
const consumerStart = vi.fn((
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: consumerStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(10, 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
}))], true)
assembler.flush()
expect(assembler.prepend([], false)).toBe('immediate')
assembler.flush()
expect(consumerStart).toHaveBeenCalledTimes(2)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
})
it('replays direct dependents when an append revises their predecessor Context', () => {
const source: ConversationNodeDefinition<number> = {
kind: 'source',
match: (event) => {
if (event.type === 'user/message') return { id: 'one', role: 'start' }
if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' }
return null
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
buildViewNode: () => null,
}
const consumerStart = vi.fn((
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: 'one', role: 'start' }
: null,
start: consumerStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([source, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
], false)
assembler.flush()
expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate')
assembler.flush()
expect(consumerStart).toHaveBeenCalledTimes(2)
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
})
it('replays a transitive dependency closure in start order', () => {
const sourceA: ConversationNodeDefinition<number> = {
kind: 'diamond-a',
match: (event) => {
if (event.type === 'user/message') return { id: 'one', role: 'start' }
if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' }
return null
},
start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
buildViewNode: () => null,
}
const sourceX: ConversationNodeDefinition<number> = {
kind: 'diamond-x',
match: (event) => {
if (event.type === 'turn/start') return { id: 'one', role: 'start' }
if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' }
return null
},
start: () => 10,
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
buildViewNode: () => null,
}
const middle: ConversationNodeDefinition<number> = {
kind: 'diamond-b',
match: event => event.type === 'assistant/message'
? { id: 'one', role: 'start' }
: null,
start: (_context, _match, reader) => (
(reader.previous<number>('diamond-a')?.state ?? 0)
+ (reader.previous<number>('diamond-x')?.state ?? 0)
),
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const consumer: ConversationNodeDefinition<number> = {
kind: 'diamond-c',
match: event => event.type === 'tool/call'
? { id: 'one', role: 'start' }
: null,
start: (_context, _match, reader) => (
(reader.previous<number>('diamond-a')?.state ?? 0) * 100
+ (reader.previous<number>('diamond-b')?.state ?? 0)
),
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([sourceA, sourceX, middle, consumer]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
input(at(2, 'turn/start', { turn: 1 })),
input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
], false)
assembler.append(input(at(5, 'diamond/x', { value: 20 })))
assembler.append(input(at(6, 'diamond/a', { value: 2 })))
assembler.flush()
const value = [...chatSnapshot(assembler)?.nodes.values() ?? []]
.find(candidate => candidate.kind === 'diamond-c')
expect(value?.data).toBe(222)
})
it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
const apply = vi.fn()
const starts = vi.fn((
_context: Parameters<ConversationNodeDefinition<string>['start']>[0],
match: Parameters<ConversationNodeDefinition<string>['start']>[1],
) => match.location.kind === 'step' ? match.location.step.status : 'missing')
const definition: ConversationNodeDefinition<string> = {
kind: 'step',
match: event => event.type === 'step/start'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: starts,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open')
assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 })))
assembler.flush()
expect(starts).toHaveBeenCalledTimes(2)
expect(apply).toHaveBeenCalledOnce()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed')
})
it('lets one Context publish Step and Turn data in phase order', () => {
interface State {
readonly turn: number
readonly step: number
readonly value: number
}
const definition: ConversationNodeDefinition<State> = {
kind: 'scope-probe',
match: (event) => {
if (event.type === 'step/start') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
}
if ((event.type as string) === 'scope-probe/update') {
return { id: '1:1', role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start')
return { turn: match.event.data.turn, step: match.event.data.step, value: 1 }
},
update: (_context, match) => ({
turn: 1,
step: 1,
value: (match.event.data as unknown as { value: number }).value,
}),
buildLocationData: (context, scope) => {
const state = context.state
if (state === undefined) return null
if (scope === 'step') {
return {
kind: 'step',
turn: state.turn,
step: state.step,
key: 'scope-probe',
value: { value: state.value },
}
}
const location = context.start?.location
const stepValue = location?.kind === 'step'
? location.step.data.get('scope-probe')?.value
: undefined
return {
kind: 'turn',
turn: state.turn,
key: 'scope-probe',
value: { valueSeenFromStep: stepValue ?? -1 },
}
},
buildViewNode: (context) => {
const location = context.start?.location
if (location?.kind !== 'step') return null
return node(context, {
step: location.step.data.get('scope-probe')?.value,
turn: location.turn.data.get('scope-probe')?.valueSeenFromStep,
})
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ step: 1, turn: 1 })
assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 })))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toEqual({ step: 2, turn: 2 })
})
it('updates existing turn Locations when their Step membership changes', () => {
const apply = vi.fn()
const definition: ConversationNodeDefinition<null> = {
kind: 'turn-probe',
match: event => event.type === 'turn/start'
? { id: String(event.data.turn), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
? context.start.location.turn.steps.length
: -1),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0)
assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 })))
assembler.flush()
expect(apply).toHaveBeenCalledOnce()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
})
it('publishes a changed timeline even when no business Definition claims the boundary', () => {
const apply = vi.fn()
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([], false)
assembler.flush()
assembler.append(input(at(1, 'turn/start', { turn: 1 })))
assembler.flush()
expect(apply).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toEqual([])
})
it('clears the prior Step at a new Turn and honors explicit session ownership', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'location-probe',
match: (event) => {
if ((event.type as string) === 'command/run') {
return {
id: (event.data as unknown as { commandId: string }).commandId,
role: 'start',
}
}
if ((event.type as string) === 'compact/start') {
return {
id: (event.data as unknown as { compactionId: string }).compactionId,
role: 'start',
}
}
return null
},
start: () => null,
update: context => context.state,
buildViewNode: (context) => {
const location = context.start?.location
const data = location?.kind === 'step'
? `step:${location.turn.turn}:${location.step.step}`
: location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind
return node(context, data)
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
input(at(3, 'turn/start', { turn: 2 })),
input(at(4, 'command/run', { commandId: 'command', name: 'x' })),
input(at(5, 'compact/start', { compactionId: 'compact', turn: null })),
], false)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
.toEqual(['turn:2', 'session'])
})
it('assigns turn boundaries to the Turn even when a Step remains open', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'turn-boundary-probe',
match: event => event.type === 'turn/end'
? { id: String(event.data.turn), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: context => node(context, context.start?.location.kind),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'turn/start', { turn: 1 })),
input(at(2, 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } })))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn')
})
it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'location-probe',
match: event => (event.type as string) === 'tool/code-dispatch-start'
? { id: String(event.seq), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
? `${location.turn.turn}:${location.step.step}`
: location?.kind)
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })),
], true)
assembler.flush()
assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' })))
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
.toEqual(['2:3', '2:3'])
})
it('treats loaded end boundaries as closed when their starts precede the window', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'location-probe',
match: event => event.type === 'tool/call'
? { id: String(event.data.callId), role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: (context) => {
const location = context.start?.location
return node(context, location?.kind === 'step'
? `${location.turn.status}:${location.step.status}`
: location?.kind)
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
input(at(11, 'step/end', { turn: 2, step: 3 })),
input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })),
], true)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
.toBe('closed:closed')
})
it('restarts State creation from undefined when Location changes replay a Context', () => {
const seen = vi.fn((context: Parameters<ConversationNodeDefinition<number>['start']>[0]) => {
expect(context.state).toBeUndefined()
return 1
})
const definition: ConversationNodeDefinition<number> = {
kind: 'replay-probe',
match: event => event.type === 'step/start'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: seen,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false)
assembler.flush()
assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 })))
assembler.flush()
expect(seen).toHaveBeenCalledTimes(2)
})
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed',
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const fallback: ConversationNodeDefinition<string> = {
kind: 'fallback',
match: event => ({ id: String(event.seq), role: 'start' }),
start: fallbackStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallback),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).not.toHaveBeenCalled()
expect(chatSnapshot(assembler)?.order).toEqual([])
})
it('rejects withdrawing a previously materialized Node during an incremental update', () => {
const definition: ConversationNodeDefinition<boolean> = {
kind: 'toggle',
match: (event) => {
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' }
return null
},
start: () => true,
update: () => false,
buildViewNode: context => context.state === true ? node(context, true) : null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
assembler.append(input(at(2, 'toggle/hide', {})))
expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/)
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('fails loud when a Definition returns undefined State', () => {
const startUndefined: ConversationNodeDefinition = {
kind: 'undefined-start',
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: () => undefined,
update: context => context.state,
buildViewNode: () => null,
}
const startAssembler = new ConversationNodeAssembler(
new TestEventDefinitions([startUndefined]),
new TestViewDefinitions([testView()]),
)
expect(() => startAssembler.replaceWindow([
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
], false)).toThrow(/Definition "undefined-start" returned undefined from start/)
const updateUndefined: ConversationNodeDefinition<boolean> = {
kind: 'undefined-update',
match: (event) => {
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' }
return null
},
start: () => true,
update: () => undefined as never,
buildViewNode: context => node(context, context.state),
}
const updateAssembler = new ConversationNodeAssembler(
new TestEventDefinitions([updateUndefined]),
new TestViewDefinitions([testView()]),
)
updateAssembler.replaceWindow([
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
], false)
expect(() => updateAssembler.append(
input(at(2, 'command/done', { commandId: 'one', kind: 'success' })),
)).toThrow(/Definition "undefined-update" returned undefined from update/)
})
it('rejects a duplicate start before mutating the existing Context', () => {
const definition: ConversationNodeDefinition<number> = {
kind: 'single-start',
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: (_context, match) => match.event.seq,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([
input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
], false)
assembler.flush()
expect(() => assembler.append(
input(at(2, 'command/run', { commandId: 'two', name: 'x' })),
)).toThrow(/received more than one start Match/)
assembler.flush()
expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
})
})

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