Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706
# Conflicts: # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md # .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml # apps/web/tests/scaffold.ts # docs/module-graph.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/package.json # packages/client/test-runtime/README.i18n.yaml # packages/client/test-runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-theme/README.i18n.yaml # packages/client/ui-theme/README.md # packages/client/ui-theme/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.zh.md # pnpm-lock.yaml
This commit is contained in:
@@ -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: 1f8a03ad74e56b18a6985a7048f7651fd1430b38
|
||||
README.zh.md: da52f1d214329237a9fa915f3d1a2e6665771754
|
||||
README.md: 452f82f9c626fae5b2c2efc913eb9865fe593c3a
|
||||
README.zh.md: abde13ea66fe807a0e5b85c5eb4ef8efcbeb093c
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
|
||||
@@ -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: `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 seam's own 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 `compact/summary` provenance; a window cut that left the provenance 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
|
||||
|
||||
@@ -64,7 +68,7 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
|
||||
|
||||
## Session model selection
|
||||
|
||||
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
|
||||
Each resident `Session` owns a `modelSelection` snapshot containing the current `ModelSelection`, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the selection reported by the Host without replacing unchanged selection substructure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -72,10 +76,10 @@ None, as the session object layer selects the provider/model route used by a lat
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
|
||||
Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
|
||||
@@ -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) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `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 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 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 Dispatch 的 start/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
|
||||
|
||||
@@ -78,4 +82,4 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
|
||||
|
||||
- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。
|
||||
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。
|
||||
|
||||
@@ -32,10 +32,11 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "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:^",
|
||||
@@ -43,6 +44,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"
|
||||
|
||||
265
packages/client/runtime/src/client/contract/conversation.ts
Normal file
265
packages/client/runtime/src/client/contract/conversation.ts
Normal file
@@ -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}`
|
||||
}
|
||||
@@ -62,6 +62,15 @@ export interface ISessions {
|
||||
* @returns completion of the current or newly started refresh.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Record the composition one session now runs. The agent-preset seat calls
|
||||
* this after a successful blank-session switch, so the header label moves
|
||||
* with the composition instead of waiting for the next full list refresh.
|
||||
* @param sessionId - the switched session.
|
||||
* @param agentPreset - the preset id the host confirmed.
|
||||
*/
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
@@ -100,7 +109,7 @@ export interface ISessions {
|
||||
*/
|
||||
scope(id: SessionId): AgentContext | undefined
|
||||
/**
|
||||
* Read the Agent scope tag off a context (service-method seam: fetch
|
||||
* Read the Agent scope tag off a context (service-method boundary: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions).
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
|
||||
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative
|
||||
* shell over it: {@link defineStore} bakes an init/persist/actions literal
|
||||
* into a {@link StoreHandle}, the registration-side store seat of the slot
|
||||
* terminal design (§4). Lives in the React-free runtime (store-migration
|
||||
* ruling: the data layer owns its engine; web-react is shell-only React
|
||||
* into a {@link StoreHandle}, the registration-side store seat of slot
|
||||
* terminals. Lives in the React-free runtime (the data layer owns its
|
||||
* engine; web-react is shell-only React
|
||||
* glue): engine products are bare observables — subscribe/getSnapshot/
|
||||
* update/set, NO selector hook. Hook synthesis is web-react's (the one
|
||||
* uSES bridge, cached per source at the binding site).
|
||||
|
||||
@@ -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)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -51,11 +70,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'
|
||||
@@ -71,7 +96,8 @@ export { PendingWait } from './sessions/pending.ts'
|
||||
export type {
|
||||
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
|
||||
} from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// Projection value store (push model; see the session-projection subsystem
|
||||
// page, docs/subsystems/session-projection.md): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
|
||||
@@ -167,6 +193,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. */
|
||||
@@ -184,8 +214,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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Context provenance projection: the role and the human-facing producer name
|
||||
// Context source projection: the role and the human-facing producer name
|
||||
// of one logged non-user `user/message`, read from its durable `source` alone.
|
||||
// The client keeps no table of known plugin ids — a renamed or newly mounted
|
||||
// producer must never need a client release to stay identifiable, and a resumed
|
||||
|
||||
@@ -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. */
|
||||
@@ -191,14 +196,14 @@ export interface CompactionSummaryNode {
|
||||
seq: number
|
||||
/** Unix epoch ms of the checkpoint event. */
|
||||
time: number
|
||||
/** Summary text from the checkpoint's `compact/summary` provenance; null when
|
||||
* the window cut left that provenance outside (the marker is then not expandable). */
|
||||
/** Summary text from the checkpoint's cited `compact/summary` event; null when
|
||||
* the window cut left that event outside (the marker is then not expandable). */
|
||||
summary: string | null
|
||||
/** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */
|
||||
/** Seq of the loaded `compact/summary` event, or null when that event is outside the window. */
|
||||
summaryEventSeq: number | null
|
||||
/** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */
|
||||
/** Number of surface items replaced, or null when the summary event is unavailable or malformed. */
|
||||
shadowedItemCount: number | null
|
||||
/** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */
|
||||
/** Estimated token price of the replaced items, or null when the summary event is unavailable or malformed. */
|
||||
shadowedTokenCount: number | null
|
||||
}
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface SessionListEntry {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Agent preset the session's agent was composed from (summary passthrough). */
|
||||
agentPreset?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** User interaction currently blocking this session, derived from live mux frames. */
|
||||
|
||||
@@ -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'
|
||||
@@ -97,12 +98,12 @@ function questionInteractionStatus(
|
||||
return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question'
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
/** Instance cluster + frame entry + the session list. */
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<SessionId, Session>()
|
||||
/** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history
|
||||
* cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates
|
||||
* compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */
|
||||
* compact by identity. Instantiation replays and clears it, while removal drops it. */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/** Outstanding answerable interactions per session, keyed by their stable request identity.
|
||||
* Manager-owned rather than read off Session instances because the sidebar must light up for
|
||||
@@ -141,9 +142,9 @@ export class SessionManager {
|
||||
private selected: SessionId | undefined
|
||||
|
||||
private listSnapshotCache: SessionListSnapshot
|
||||
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
|
||||
/** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
|
||||
* object when every field matches — wire refreshes mint all-new summary objects, so identity
|
||||
* must be recovered by value or every SessionListItem memo misses on every refresh (audit S5). */
|
||||
* must be recovered by value or every SessionListItem memo misses on every refresh. */
|
||||
private entryCache = new Map<SessionId, SessionListEntry>()
|
||||
private itemsCache: readonly SessionListEntry[] = []
|
||||
private readonly notifier = new Notifier(() => {
|
||||
@@ -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)
|
||||
@@ -242,7 +244,7 @@ export class SessionManager {
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
* Drop a session instance (scope-prune companion, decision 12: instance
|
||||
* Drop a session instance (scope-prune companion: instance
|
||||
* and scope share one lifecycle). The host session log is the durable
|
||||
* truth — a later get() lazily rebuilds and open() backfills history.
|
||||
* @param sessionId - the session to drop.
|
||||
@@ -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)
|
||||
@@ -523,6 +536,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
|
||||
} })
|
||||
} else {
|
||||
const publishedSessionId = workspaceAttachSessionId(result.error)
|
||||
@@ -588,6 +602,17 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'upsert', summary })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a host-confirmed composition switch (see ISessions.noteAgentPreset).
|
||||
* @param sessionId - the switched session.
|
||||
* @param agentPreset - the preset id the host confirmed.
|
||||
*/
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
|
||||
} })
|
||||
}
|
||||
|
||||
/** Apply immediately and retain for replay when a list response is in flight. */
|
||||
private recordMutation(mutation: SessionListMutation): void {
|
||||
this.listMutations?.push(mutation)
|
||||
@@ -743,6 +768,7 @@ export class SessionManager {
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
|
||||
@@ -956,7 +982,7 @@ export class SessionManager {
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
// value; there is no dedicated title frame).
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
@@ -1027,15 +1053,21 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
...(existing.origin === undefined && mutation.summary.origin !== undefined
|
||||
? { origin: mutation.summary.origin } : {}),
|
||||
// Newest wins, not fill-only: a blank-session preset switch replaces
|
||||
// the creation-time value, and every producer of this field (the
|
||||
// create echo, the select echo, a list row) reports the CURRENT one.
|
||||
...(mutation.summary.agentPreset !== undefined
|
||||
? { agentPreset: mutation.summary.agentPreset } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
||||
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
|
||||
&& filled.origin === existing.origin && filled.blank === existing.blank
|
||||
&& filled.agentPreset === existing.agentPreset) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
|
||||
case 'status':
|
||||
// running:true doubles as the cross-端 blank flip (a blank session
|
||||
// running:true doubles as the cross-client blank flip (a blank session
|
||||
// never runs, so the first running frame proves a message landed).
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
||||
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
|
||||
|
||||
@@ -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: '' }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Generic per-session projection value store (session-projection RFC, push
|
||||
* model): the host is the only computation site; the client holds finished
|
||||
* Generic per-session projection value store (push model; see the
|
||||
* session-projection subsystem page, docs/subsystems/session-projection.md):
|
||||
* the host is the only computation site; the client holds finished
|
||||
* whole values per key — `key → { value, seq }` — seeded by the history tail
|
||||
* page's projections block and updated by `session/projection` push frames,
|
||||
* under the single rule **higher seq wins**. No client-side domain folding
|
||||
@@ -12,15 +13,17 @@ import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
// The single projection type table, typed end to end (host unit, wire block,
|
||||
// client store, React hook) — the interface package's pure-type outlet
|
||||
// client store, React hook) — the Service Definition package's pure-type outlet
|
||||
// (`/types`, zero imports), never the package root: the root's dsh-agent →
|
||||
// dsh-session chain would drag the host `Context.sessions` merge into the
|
||||
// client program (one program must not hold both sides). No second
|
||||
// client-side "views" table (user ruling, RFC Alternatives).
|
||||
// client-side "views" table (rejected in the Alternatives of
|
||||
// .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
||||
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* The fifth framework hook seat (session-projection RFC): key-addressed
|
||||
* The fifth framework hook seat (see the session-projection subsystem page,
|
||||
* docs/subsystems/session-projection.md): key-addressed
|
||||
* projection reader delivered through the standard kit. `undefined` uniformly
|
||||
* means capability absent — host unit unmounted, or no baseline/frame has
|
||||
* carried the key yet. The selector overload mirrors useSession (per-key uSES
|
||||
|
||||
74
packages/client/runtime/src/client/sessions/queue-mirror.ts
Normal file
74
packages/client/runtime/src/client/sessions/queue-mirror.ts
Normal file
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, breadcrumb-route projection.
|
||||
*
|
||||
@@ -31,6 +30,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'
|
||||
@@ -45,6 +45,12 @@ export interface SessionSummary {
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from; absent when the
|
||||
* deployment composes no presets. The session header labels what the
|
||||
* session actually runs rather than the deployment's current default.
|
||||
*/
|
||||
agentPreset?: string
|
||||
parentId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
@@ -259,16 +265,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 +316,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)
|
||||
}
|
||||
|
||||
@@ -359,6 +398,10 @@ export class SessionsService implements ISessions {
|
||||
return this.manager.refreshSubagents(parentSessionId)
|
||||
}
|
||||
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.manager.noteAgentPreset(sessionId, agentPreset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
@@ -488,7 +531,7 @@ export class SessionsService implements ISessions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* Read the Agent scope tag off a context. Service-method boundary: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
@@ -503,7 +546,7 @@ export class SessionsService implements ISessions {
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* hop every scoped consumer (event listeners, per-session controllers)
|
||||
* takes from ctx-space into object-space (the client mirror of host
|
||||
* `agent.session`). Same service-method seam as
|
||||
* `agent.session`). Same service-method boundary as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||||
@@ -571,7 +614,7 @@ export class SessionsService implements ISessions {
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host or selected
|
||||
* prune share one predicate: listed on the host or selected
|
||||
* through a retained subagent address. Breadcrumb-only ancestors remain
|
||||
* summary data and do not keep scopes alive.
|
||||
*/
|
||||
@@ -590,7 +633,7 @@ export class SessionsService implements ISessions {
|
||||
ctx,
|
||||
binding,
|
||||
session,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
// Sources are bare observables; React binds selector hooks at its own boundary.
|
||||
provideInfo: this.provideChannel.materializeInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
@@ -629,6 +672,7 @@ export class SessionsService implements ISessions {
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||||
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined) {
|
||||
@@ -694,7 +738,7 @@ export class SessionsService implements ISessions {
|
||||
}
|
||||
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): the scope
|
||||
* One teardown for the whole per-session axis: the scope
|
||||
* fiber (cascading every actx-registered effect: input shell, slash
|
||||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||||
* stores, and the Session instance itself — the host session log is the
|
||||
|
||||
@@ -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,27 +11,23 @@ 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 { 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. */
|
||||
@@ -54,24 +49,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,46 +71,17 @@ export class Session implements SessionFace {
|
||||
private openError: RpcError | null = null
|
||||
private openPromise: Promise<void> | null = null
|
||||
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
|
||||
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
|
||||
* a pre-disconnect open whose history request is already doomed. Stale doOpen
|
||||
* 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
|
||||
@@ -141,8 +91,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
|
||||
@@ -154,8 +106,9 @@ export class Session implements SessionFace {
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
/**
|
||||
* Per-session projection value store (session-projection RFC, push model):
|
||||
* finished whole values computed on the host, seeded by the tail page's
|
||||
* Per-session projection value store (push model; see the session-projection
|
||||
* subsystem page, docs/subsystems/session-projection.md): finished whole
|
||||
* values computed on the host, seeded by the tail page's
|
||||
* projections block and updated by `session/projection` frames under the
|
||||
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
|
||||
* (the useProjection resolution face); the conversation snapshot never
|
||||
@@ -167,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
|
||||
@@ -192,13 +143,23 @@ 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the Agent-scoped context minted by SessionsService (single write;
|
||||
* a second bind is a wiring error and throws). Direction stays one-way at
|
||||
* the seam: consumers still reach the Session via `sessions.sessionOf`,
|
||||
* this binding boundary: consumers still reach the Session via `sessions.sessionOf`,
|
||||
* while the Session holds its own dispatch point (host Agent.loopCtx
|
||||
* mirror).
|
||||
* @param actx - the agent's scoped context.
|
||||
@@ -228,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 {
|
||||
@@ -281,17 +243,22 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* Stop the active turn while the Host preserves pending inbox work; failures
|
||||
* land in promptError (same error-strip display slot).
|
||||
* land in promptError (same error-strip display slot). A continuable
|
||||
* subagent address routes through `subagent.interrupt`, whose durable
|
||||
* parent-address authority works without a live parent Agent; a one-shot
|
||||
* address stays uncancellable (the UI offers no stop action, so this arm is
|
||||
* defensive).
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
if (this.address !== undefined) {
|
||||
const address = this.address
|
||||
if (address !== undefined && address.mode === 'one-shot') {
|
||||
const result: RpcResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-delivery-unavailable',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
details: { childSessionId: address.childSessionId },
|
||||
},
|
||||
}
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
@@ -300,7 +267,9 @@ export class Session implements SessionFace {
|
||||
}
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
result = address !== undefined
|
||||
? (await this.api.subagents.interrupt(address)).result
|
||||
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
@@ -357,7 +326,7 @@ export class Session implements SessionFace {
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
this.loadingOlder = true
|
||||
@@ -368,13 +337,15 @@ 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]
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||
// 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]
|
||||
@@ -382,8 +353,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 {
|
||||
@@ -395,7 +365,7 @@ export class Session implements SessionFace {
|
||||
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
|
||||
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
* the fresh generation into 'error'. */
|
||||
async resync(): Promise<void> {
|
||||
// The queue mirror is NOT cleared here: onConnected (which drives resync)
|
||||
// races the mux frames — the fresh generation's baseline may have landed
|
||||
@@ -454,15 +424,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
|
||||
}
|
||||
@@ -472,11 +434,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': {
|
||||
@@ -515,11 +473,12 @@ export class Session implements SessionFace {
|
||||
*/
|
||||
handleRunning(running: boolean): void {
|
||||
// Turn-start conversion: a blank session never runs, so the first
|
||||
// running:true proves another端's first message landed (设计稿 2.2).
|
||||
// running:true proves another side's first message landed.
|
||||
if (running && this.blankBit) {
|
||||
this.blankBit = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (running) this.firstPromptPendingTurn = false
|
||||
if (this.running === running) return
|
||||
this.running = running
|
||||
this.notifier.markDirty()
|
||||
@@ -583,7 +542,12 @@ 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())
|
||||
}
|
||||
|
||||
// ---- Private ----
|
||||
|
||||
/** Requested-frame arrival: the wait enters the pending map under its own key. */
|
||||
private mint(wait: PendingInteraction): void {
|
||||
@@ -613,7 +577,7 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
// Gap detection: baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
|
||||
@@ -635,7 +599,7 @@ export class Session implements SessionFace {
|
||||
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop.
|
||||
* A carried projections block seeds the value store (higher seq wins, so a stale
|
||||
* baseline cannot overwrite a newer push frame); the window events themselves are
|
||||
* never folded — the host is the only computation site. */
|
||||
@@ -644,8 +608,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 = []
|
||||
@@ -654,32 +618,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
|
||||
* a seq gap -> buffer + tail-page repull instead of appending a hole (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 own provenance. */
|
||||
* 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 })
|
||||
@@ -692,15 +646,16 @@ 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))
|
||||
}
|
||||
|
||||
/** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
|
||||
/** 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: repull the tail page and stitch the liveBuffer through the shared
|
||||
* installWindow path. No openState transition — the UI keeps the current window (no loading
|
||||
* flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */
|
||||
private async repairGap(): Promise<void> {
|
||||
@@ -721,238 +676,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,
|
||||
@@ -978,67 +730,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 seam's own
|
||||
// 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 seam's checkpoint plugin, pinned to the seam's own 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 own provenance (`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
|
||||
* provenance 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: provenance lookup for a checkpoint's summary. */
|
||||
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).
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* the load-time validations, and the unload cascade). This layer owns what
|
||||
* needs the runtime: the 'slots/changed' event bridge, register and
|
||||
* declaration injection through the caller's ctx.effect (fiber unload
|
||||
* collects both), the renderer install seam (install()/renderSlot('root') +
|
||||
* collects both), the renderer installation contract (install()/renderSlot('root') +
|
||||
* the SlotRendererHost face), and the store INSTANCE axis — handle x scope
|
||||
* key -> create/cache, dropped with the last holding entry, session instances
|
||||
* cleared (with persisted state) on scope death.
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,7 @@ export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize this local Workspace through the Host create seam.
|
||||
* Materialize this local Workspace through the Host create API.
|
||||
* Re-entry shares the in-flight completion; a materialized instance returns undefined.
|
||||
* @returns the Host result, or undefined when this Workspace is already materialized.
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
@@ -52,7 +54,7 @@ describe('runtime client apply', () => {
|
||||
const bench = await mount()
|
||||
expect(bench.ctx.get('slots') !== undefined).toBe(true)
|
||||
// The built-in 'root' declaration ships with this package's SlotsService
|
||||
// (the SlotMap 'root' merge lives here since the slot-parity rework).
|
||||
// (the SlotMap 'root' merge lives here).
|
||||
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const sessions = bench.ctx.get('sessions')
|
||||
const workspaces = bench.ctx.get('workspaces')
|
||||
@@ -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 seam's own declaration at
|
||||
* compile time through a type-only import of `dsh-compact/checkpoint`, so
|
||||
* renaming the seam'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 seam's own 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 seam's own 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 seam
|
||||
// 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' })
|
||||
})
|
||||
})
|
||||
959
packages/client/runtime/tests/conversation-assembler.spec.ts
Normal file
959
packages/client/runtime/tests/conversation-assembler.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
126
packages/client/runtime/tests/conversation-registry.spec.ts
Normal file
126
packages/client/runtime/tests/conversation-registry.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
||||
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
return {
|
||||
kind,
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
|
||||
return {
|
||||
target,
|
||||
create: () => ({
|
||||
empty: null,
|
||||
replace: () => null,
|
||||
apply: () => null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function bootRegistries(): Promise<{
|
||||
ctx: Context
|
||||
events: ConversationEventRegistry
|
||||
views: ConversationViewRegistry
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
const events = ctx.get('conversationEvents') as ConversationEventRegistry
|
||||
const views = ctx.get('conversationViews') as ConversationViewRegistry
|
||||
return { ctx, events, views }
|
||||
}
|
||||
|
||||
describe('Conversation registries', () => {
|
||||
it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const definition = eventDefinition('message')
|
||||
const dispose = events.register(definition)
|
||||
|
||||
expect(events.entries()).toEqual([definition])
|
||||
expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const fallback = eventDefinition('unknown')
|
||||
const dispose = events.registerFallback(fallback)
|
||||
|
||||
expect(events.fallbackEntry()).toBe(fallback)
|
||||
expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicate view targets and disposes a view registration once', async () => {
|
||||
const { views } = await bootRegistries()
|
||||
const definition = viewDefinition('chat')
|
||||
const dispose = views.register(definition)
|
||||
|
||||
expect(views.entries()).toEqual([definition])
|
||||
expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes Event, fallback, and view contributions with their caller fiber', async () => {
|
||||
const { ctx, events, views } = await bootRegistries()
|
||||
const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => {
|
||||
featureCtx.conversationEvents.register(eventDefinition('message'))
|
||||
featureCtx.conversationEvents.registerFallback(eventDefinition('unknown'))
|
||||
featureCtx.conversationViews.register(viewDefinition('chat'))
|
||||
})
|
||||
await feature.await()
|
||||
|
||||
expect(events.entries()).toHaveLength(1)
|
||||
expect(events.fallbackEntry()).toBeDefined()
|
||||
expect(views.entries()).toHaveLength(1)
|
||||
|
||||
await feature.dispose()
|
||||
expect(events.entries()).toEqual([])
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('coalesces registry changes into one rebuild of every resident Session', async () => {
|
||||
const { ctx, events, views } = await bootRegistries()
|
||||
const api = new FakeApiClient()
|
||||
const sessionId = 'resident' as SessionId
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
sessions.scope(sessionId)
|
||||
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
||||
|
||||
events.register(eventDefinition('message'))
|
||||
views.register(viewDefinition('chat'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(rebuild).toHaveBeenCalledOnce()
|
||||
rebuild.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -54,12 +54,12 @@ export const ev = {
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
}),
|
||||
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
|
||||
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
@@ -105,7 +105,7 @@ export const ev = {
|
||||
...text === undefined ? {} : { text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
} }),
|
||||
/** A compaction's log-only `compact/summary` provenance record. */
|
||||
/** A compaction's log-only `compact/summary` record. */
|
||||
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
|
||||
at(seq, { type: 'compact/summary', data: {
|
||||
summary: text(summary),
|
||||
@@ -140,7 +140,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
|
||||
]
|
||||
}
|
||||
|
||||
/** Wrap raw events as view-less history entries (the wire shape history now returns). */
|
||||
/** Wrap raw events as view-less history entries (the wire shape history returns). */
|
||||
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
|
||||
return events.map(event => ({ event }))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -64,7 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
readonly defaultModel: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
@@ -82,7 +82,7 @@ export class FakeApiClient implements IApiClient {
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: { provider: string; model: string }) =>
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
|
||||
= () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
@@ -204,6 +208,22 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
describe('projectConversationHistory', () => {
|
||||
it('names an injected context node from its durable source, like the live adapter', () => {
|
||||
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
||||
// the provenance projection is pinned on both sides independently.
|
||||
// the source projection is pinned on both sides independently.
|
||||
const injected = at(0, {
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
|
||||
@@ -842,7 +842,7 @@ describe('connected generation', () => {
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const openedSession = manager.get(S1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Projection value store (session-projection RFC, push model): the single
|
||||
* Projection value store (push model; session-projection subsystem page:
|
||||
* docs/subsystems/session-projection.md): the single
|
||||
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
|
||||
* newer push frame; a replayed frame cannot regress), capability absence as
|
||||
* undefined, generation truncation, and the Session/manager wiring (tail-page
|
||||
@@ -14,7 +15,7 @@ import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
// Test-domain keys merged into the projection map (the interface package's
|
||||
// Test-domain keys merged into the projection map (the Service Definition package's
|
||||
// pure-type outlet), the same way domain host plugins merge theirs.
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
|
||||
@@ -158,7 +158,6 @@ describe('queue snapshot intake', () => {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
|
||||
|
||||
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
|
||||
{ id: 's-later', body: '', placement: 'steering', message },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* SlotsService terminal-design account (design.md §11-3 main landing):
|
||||
* SlotsService terminal-design account:
|
||||
* built-in 'root', the three load-time throws (duplicate declaration /
|
||||
* undeclared contribution / cross-scope store handle), the renderer install
|
||||
* seam (double install / not installed / non-root key), store instance
|
||||
* undeclared contribution / cross-scope store handle), the renderer installation
|
||||
* contract (double install / not installed / non-root key), store instance
|
||||
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
@@ -92,13 +92,13 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
|
||||
return host
|
||||
}
|
||||
|
||||
/** Minimal independent Workspace list source for the renderer host seam. */
|
||||
/** Minimal independent Workspace list source for the renderer host contract. */
|
||||
function fakeWorkspaces() {
|
||||
const state = { items: [], phase: 'ready' as const }
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + current provide projection). */
|
||||
/** Minimal sessions face for the host contract (list observable + current provide projection). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* TranscriptAdapter over the raw append-only window: log-ordered projection of
|
||||
* append-origin events, one marker per landed compaction, replacement copies
|
||||
* hidden, command-lifecycle folding, node/array identity, call pairing, and
|
||||
* host-provided wire views.
|
||||
*/
|
||||
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage } 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'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
/** A `compact/summary` provenance event (log-only, no surfaceOp). */
|
||||
function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
|
||||
return at(seq, {
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
summary,
|
||||
shadowedRange: { start: 1, end: 3 },
|
||||
shadowedSeqs: [1, 3],
|
||||
shadowedTokenCount: 100,
|
||||
provider: 'fake',
|
||||
model: 'compact-1',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** The replacement user message a compaction backend lands (the checkpoint). */
|
||||
function checkpoint(
|
||||
seq: number,
|
||||
summarySeq: number,
|
||||
{ start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
|
||||
start?: number
|
||||
end?: number
|
||||
sourceEventSeqs?: number[]
|
||||
} = {},
|
||||
): SessionEvent {
|
||||
return at(seq, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs,
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('TranscriptAdapter', () => {
|
||||
it('projects a window starting past seq 0 at its own log positions', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
|
||||
})
|
||||
|
||||
it('appends incrementally keeping old node references (materialize-once identity)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
const first = adapter.nodes()
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second).toHaveLength(3)
|
||||
expect(second[0]).toBe(first[0])
|
||||
expect(second[1]).toBe(first[1])
|
||||
expect(second).not.toBe(first) // a real change swaps the array
|
||||
})
|
||||
|
||||
it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
const settled = adapter.nodes()
|
||||
adapter.append(ev.chunkStart(6, 1))
|
||||
expect(adapter.nodes()).toBe(settled)
|
||||
adapter.append(ev.chunkText(7, 1, '流式'))
|
||||
expect(adapter.nodes()).toBe(settled)
|
||||
adapter.append(ev.assistant(8, 1, '流式完成'))
|
||||
const finalized = adapter.nodes()
|
||||
expect(finalized).not.toBe(settled)
|
||||
expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
|
||||
})
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(7, 0, 'c1', '结果'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
|
||||
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies steering on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'live steer' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([])
|
||||
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }))
|
||||
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }))
|
||||
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
|
||||
})
|
||||
|
||||
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
|
||||
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
adapter.reset([
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, inserted: [queued],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [canceled],
|
||||
} }),
|
||||
at(4, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
} }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
|
||||
at(6, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [context],
|
||||
} }),
|
||||
at(7, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
|
||||
])
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
it('materializes a skill-invocation injection as a named instructions context', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
|
||||
source: { kind: 'user' },
|
||||
}) }),
|
||||
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
|
||||
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
|
||||
}) }),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
// The gesture stays a user bubble; the injected body folds to a context
|
||||
// row named after the skill, presented as instructions.
|
||||
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
provenance: { role: 'inject', label: 'hidden-demo' },
|
||||
form: 'instructions',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a compact/* provenance record) and a future type core
|
||||
// has not admitted contribute no node.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 1),
|
||||
at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
|
||||
compactSummary(2),
|
||||
ev.user(3, '唯一的一条'),
|
||||
ev.turnEnd(4, 1),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
|
||||
})
|
||||
|
||||
describe('compaction markers', () => {
|
||||
it('keeps the original messages and full tool output, hiding replacement copies', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '原始问题'),
|
||||
ev.assistant(1, 0, '原始回答'),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{}'),
|
||||
ev.toolResult(5, 0, 'c1', '完整工具输出'),
|
||||
// A pruned tool/result copy: rewrites one node for the model, marks nothing.
|
||||
at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
|
||||
} }),
|
||||
compactSummary(7),
|
||||
checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
|
||||
// A regenerated assistant/message: also a silent model-only rewrite.
|
||||
at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
|
||||
turn: 0, step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '通用 replacement 副本' }],
|
||||
source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
|
||||
}),
|
||||
} }),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([
|
||||
['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
|
||||
])
|
||||
expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
|
||||
expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
|
||||
})
|
||||
|
||||
it('adds one marker per landed compaction, in log order', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, 'a'),
|
||||
compactSummary(1, [{ type: 'text', text: 'first' }]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
ev.user(3, 'b'),
|
||||
compactSummary(4, [{ type: 'text', text: 'second' }]),
|
||||
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
|
||||
])
|
||||
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
{
|
||||
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
|
||||
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
|
||||
// The pagination hole A1 left open: quota is no longer spent on
|
||||
// replacement copies, so a page can carry a checkpoint whose
|
||||
// surfaceOp.start lies below the window head. The old surface fold threw
|
||||
// on the missing range and degraded with a console error; a log-ordered
|
||||
// projection has no range to resolve.
|
||||
const adapter = new TranscriptAdapter()
|
||||
const noise = { error: console.error, warn: console.warn }
|
||||
const logged: unknown[] = []
|
||||
console.error = (...args: unknown[]) => logged.push(args)
|
||||
console.warn = (...args: unknown[]) => logged.push(args)
|
||||
try {
|
||||
adapter.reset([
|
||||
compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
|
||||
checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
|
||||
ev.user(82, '压缩后的新问题'),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
|
||||
} finally {
|
||||
console.error = noise.error
|
||||
console.warn = noise.warn
|
||||
}
|
||||
expect(logged).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
|
||||
// A session-reference card carries the same plugin source shape; only the
|
||||
// replacement marker makes an event a checkpoint.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: 'instructions',
|
||||
}])
|
||||
})
|
||||
|
||||
it('ignores a foreign plugin s replacement user/message', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '保留'),
|
||||
at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
|
||||
content: [{ type: 'text', text: '别的插件重写' }],
|
||||
source: { kind: 'plugin', plugin: 'not-compact' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['absent provenance', undefined],
|
||||
['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
|
||||
['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
|
||||
['an empty summary array', compactSummary(1, [])],
|
||||
['a non-array summary', compactSummary(1, 'plain string')],
|
||||
])('degrades %s to a non-expandable marker', (_label, summary) => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
...(summary === undefined ? [] : [summary]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
|
||||
// ContentBlock is merge-extensible and the payload type is ContentBlock[],
|
||||
// so a non-text block must not discard recoverable text beside it.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the summary null when the checkpoint records no provenance at all', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([at(2, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it('skips a non-summary provenance seq before reaching the real one', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '被压缩的问题'),
|
||||
at(1, { type: 'compact/start', data: { turn: 0 } }),
|
||||
compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
|
||||
])
|
||||
expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
|
||||
})
|
||||
|
||||
it('resolves the summary once an older page supplies the provenance', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
|
||||
adapter.reset([landed])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
|
||||
adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
|
||||
})
|
||||
|
||||
it('creates the marker on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
|
||||
adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
|
||||
const nodes = adapter.nodes()
|
||||
// The compacted history is still there; the marker is one more row after it.
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
|
||||
expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns call:null for a tool-result whose call fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
|
||||
})
|
||||
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }),
|
||||
error: { name: 'Boom', code: 'boom' },
|
||||
} }),
|
||||
])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
|
||||
it('attaches wire views to the materialized result node', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
|
||||
adapter.reset([
|
||||
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
|
||||
ev.toolResult(1, 1, 'c1', 'listing'),
|
||||
], [callView, resultView] as never)
|
||||
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches views on the live append path and defaults to null without views', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
|
||||
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
|
||||
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
|
||||
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callView: { title: '回声' }, resultView: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' },
|
||||
})
|
||||
})
|
||||
|
||||
describe('command lifecycle nodes', () => {
|
||||
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '先说话'),
|
||||
ev.commandRun(1, 'cmd-1', 'plan'),
|
||||
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||
ev.assistant(3, 0, '然后回答'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
|
||||
outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a run with no done as still executing (outcome null)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
|
||||
})
|
||||
|
||||
it('represents command input omitted by the host as null', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'command', name: 'feedback', args: null, outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({
|
||||
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
|
||||
outcome: { kind: 'error', text: '失败了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a live-appended done in place, keeping the node at the run seq', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'q', 'a'))
|
||||
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
|
||||
const running = adapter.nodes().find(n => n.kind === 'command')
|
||||
expect(running).toMatchObject({ outcome: null })
|
||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||
const settled = adapter.nodes().find(n => n.kind === 'command')
|
||||
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
|
||||
// Settlement replaced the node object rather than mutating the published one.
|
||||
expect(settled).not.toBe(running)
|
||||
})
|
||||
|
||||
it('tails command nodes whose seq is past every transcript node', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
|
||||
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '压缩前的问题'),
|
||||
ev.commandRun(1, 'cmd-compact', 'compact'),
|
||||
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
|
||||
})
|
||||
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant timing', () => {
|
||||
const base = 1_700_000_000_000
|
||||
|
||||
it('derives step timing across a window rebuild (start + first token + completion)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 0),
|
||||
ev.user(1, '问'),
|
||||
ev.stepStart(2, 0),
|
||||
ev.chunkStart(3, 0),
|
||||
ev.chunkText(4, 0, '答'),
|
||||
ev.chunkText(5, 0, '案'),
|
||||
ev.assistant(6, 0, '答案'),
|
||||
ev.turnEnd(7, 0),
|
||||
])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the same timing on the live append path, first token winning once', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问')])
|
||||
adapter.append(ev.stepStart(1, 0))
|
||||
adapter.append(ev.chunkText(2, 0, '首'))
|
||||
adapter.append(ev.chunkText(3, 0, '次'))
|
||||
adapter.append(ev.assistant(4, 0, '首次'))
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
|
||||
@@ -27,16 +27,22 @@
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
"path": "../../session/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
|
||||
Reference in New Issue
Block a user