Merge master into feature/workspace-picker-composer
This commit is contained in:
@@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<nam
|
||||
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
|
||||
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'tool.call.toolview'`).
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
@@ -22,8 +22,8 @@ How live data reaches render code, and what may cross a business boundary:
|
||||
|
||||
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
|
||||
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots).
|
||||
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
|
||||
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
|
||||
|
||||
@@ -54,6 +54,12 @@ Non-negotiables across the layers:
|
||||
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Conversation Node discipline
|
||||
|
||||
- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md).
|
||||
- `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`.
|
||||
- The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks.
|
||||
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
|
||||
@@ -83,7 +89,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
## New plugin package checklist
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: b950772d4cad6d873426f8aee6416fa56afca2ee
|
||||
README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d
|
||||
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
|
||||
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
|
||||
|
||||
@@ -22,6 +22,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
|
||||
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
|
||||
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
|
||||
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
|
||||
@@ -32,8 +33,11 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
|
||||
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
|
||||
|
||||
The subsystem reference is [client-modules.md](../../docs/subsystems/client-modules.md); the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive slot model, and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 UI 服务和特性插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。除 `test-runtime` 外,均为命名成 `@deepseek-ai/dsh-client-<name>` 的**产品**包。
|
||||
dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 UI 服务和功能插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。除 `test-runtime` 外,均为命名成 `@deepseek-ai/dsh-client-<name>` 的**产品**包。
|
||||
|
||||
| 包 | 目的 |
|
||||
|---|---|
|
||||
@@ -14,14 +14,15 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`hmr/`](hmr/README.md) | 在开发期间刷新客户端插件。 |
|
||||
| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 |
|
||||
| [`schema-form/`](schema-form/README.md) | 为设置编辑器提供 schema 驱动的草稿处理。 |
|
||||
| [`test-runtime/`](test-runtime/README.md) | 为客户端特性包提供共享的仓库测试支持。 |
|
||||
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 特性注册和组合扩展 slot 的方式。 |
|
||||
| [`test-runtime/`](test-runtime/README.md) | 为客户端功能包提供共享的仓库测试支持。 |
|
||||
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 |
|
||||
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
|
||||
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
|
||||
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
|
||||
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
|
||||
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
|
||||
@@ -32,8 +33,11 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
|
||||
每个子文档负责自身的契约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
|
||||
子系统参考是 [client-modules.md](../../docs/subsystems/client-modules.md);[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威 slot 模型,[web 客户端架构说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)拥有加载链与对象层。
|
||||
|
||||
@@ -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/connection/README.md
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
|
||||
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { isLoopbackHostname } from './loopback-hostname.ts'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
/** The request facts the fence reads from either HTTP representation. */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
headers: IncomingHttpHeaders | Headers
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
|
||||
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param request - Node HTTP or Fetch request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Central contract re-export point: every contract import inside
|
||||
// web-runtime goes through this single file.
|
||||
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client boundary.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels added for this.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
@@ -12,7 +12,7 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
@@ -23,9 +23,9 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
// transportError lives in the apiproxy api layer (beside RpcResult, its
|
||||
// subject); re-exported here so connection consumers keep one contract
|
||||
// entry point.
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
||||
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
||||
export interface ConnectionConfig {
|
||||
/** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
|
||||
backoffBaseMs?: number
|
||||
@@ -10,9 +10,9 @@ export interface ConnectionConfig {
|
||||
/** Upper bound for the backoff cap in ms. */
|
||||
backoffMaxMs?: number
|
||||
/** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
|
||||
* (audit C2) waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
|
||||
* generation proceeds as connected and the live-gap repair path (audit S3) covers stragglers. */
|
||||
* generation proceeds as connected and the live-gap repair path covers stragglers. */
|
||||
streamOpenTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
|
||||
/** Coarse connection state for the UI: 'connected' after each generation's handshake,
|
||||
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
|
||||
export type ConnectionState = 'connected' | 'reconnecting'
|
||||
|
||||
@@ -125,7 +125,7 @@ export class ConnectionController {
|
||||
})
|
||||
|
||||
try {
|
||||
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
|
||||
// Strict readiness handshake: describe proves unary reachability, onOpen
|
||||
// proves each physical stream is established before any frame —
|
||||
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
|
||||
@@ -30,15 +30,17 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(crypto.randomUUID()), payload }
|
||||
return { rpcId: RpcId(randomUuid()), payload }
|
||||
}
|
||||
|
||||
function text(t: string): ContentBlock[] {
|
||||
@@ -94,7 +96,7 @@ function sgr(code: number, body: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 65, authored to carry every feature
|
||||
* Terminal output sample for fixture turn 66, authored to carry every feature
|
||||
* the terminal card draws that turn 60's two prompt rows cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
@@ -139,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grep result for the search sample (turn 66): matches grouped by
|
||||
* Structured grep result for the search sample (turn 67): matches grouped by
|
||||
* file, authored inline because the client-side fixture cannot import the tool
|
||||
* that produces the canonical value. `truncated` with a larger `total` than the
|
||||
* retained match count exercises the search card's capped indicator; the file
|
||||
@@ -155,19 +157,19 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
path: 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
|
||||
matches: [
|
||||
{ lineNumber: 24, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
|
||||
{ lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
|
||||
{ lineNumber: 45, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
|
||||
{ lineNumber: 130, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
path: 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
|
||||
matches: [
|
||||
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 52, line: ' search={search}' },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
{ lineNumber: 34, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 36, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 56, line: ' search={search}' },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -189,15 +191,15 @@ const SEARCH_MATCHES_TEXT = [
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Structured glob result for the search sample (turn 67): a flat path list,
|
||||
* Structured glob result for the search sample (turn 68): a flat path list,
|
||||
* truncated with a larger `total` so the path card shows its capped indicator.
|
||||
*/
|
||||
const SEARCH_PATHS_FIXTURE = [
|
||||
'packages/client/ui-primitives/src/SearchBlock.tsx',
|
||||
'packages/client/ui-primitives/src/SearchBlock.module.css',
|
||||
'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
'packages/client/ui-conversation/tests/search-card.spec.tsx',
|
||||
'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
|
||||
'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
|
||||
'packages/client/ui-tool/tests/search-card.spec.tsx',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -424,19 +426,19 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
|
||||
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
|
||||
// the presenter reads to emit the two-hunk sample: the card draws one path
|
||||
// header, the first hunk, a `⋯` gap, then the second (the same-file
|
||||
// second-hunk arm turns 62/63 cannot reach).
|
||||
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
|
||||
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const turn = 65
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
|
||||
@@ -454,12 +456,12 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
@@ -474,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
@@ -484,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
@@ -496,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
|
||||
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
|
||||
// truncated). Both ride the keyed SearchRow registration under their own
|
||||
// names; the render-site fallback row is covered by the model derivation
|
||||
// tests, since every fixture search tool has a keyed row. Ordered before the
|
||||
// todo turn for the same standing-plan reason the bash turn is.
|
||||
toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
|
||||
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
|
||||
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
|
||||
// line numbers starting above 1 and a "showing N of M" note (the window is
|
||||
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
|
||||
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
|
||||
// The render-site fallback ROW SHAPE (a read call on the generic flattened
|
||||
// path) is covered by the turn 64 run_code read sub-dispatches, which
|
||||
// path) is covered by the turn 65 run_code read sub-dispatches, which
|
||||
// session.ts folds with resultView: null; the fallback-row + read-CARD
|
||||
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
|
||||
// this fixture. The read render intent is result-side only, so its pending
|
||||
// call stays a generic `kind: 'read'` card; presentResult carries the
|
||||
// structured window.
|
||||
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
|
||||
// Turns 69-70: the web render intent — a web_search whose result view carries
|
||||
// Turns 70-71: the web render intent — a web_search whose result view carries
|
||||
// structured sources plus an answer (the citation list, one source lacking a
|
||||
// title so its hostname labels the link, the capped indicator on), and a
|
||||
// web_fetch whose result view carries the fetched URL and its HTTP status.
|
||||
// Both keep a generic pending call view and add the `web` card only at
|
||||
// result time, which is the contract's result-only web shape. Named after
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 65 is: the standing plan retires at
|
||||
// the todo turn for the same reason turn 66 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -576,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
case 'read':
|
||||
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
|
||||
case 'edit':
|
||||
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
|
||||
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
|
||||
// scattered hunks share one path header and the card draws the `⋯` gap.
|
||||
if (str(args.file_path) === 'src/config.ts') {
|
||||
return {
|
||||
@@ -689,9 +691,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
|
||||
/**
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` set the wanted target (`off` → false, else true);
|
||||
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
|
||||
* boundary (the fixture's step/start parallel).
|
||||
* records named `plan` with recorded input set the wanted target (`off` →
|
||||
* false, else true); `plan/mode` commits and clears it. `wanted` is exposed
|
||||
* for the prompt boundary (the fixture's step/start parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
@@ -700,7 +702,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo
|
||||
const item = event as unknown as { type: string; data?: Record<string, unknown> }
|
||||
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
|
||||
const args = item.data['args']
|
||||
wanted = (typeof args === 'string' ? args : '').trim() !== 'off'
|
||||
if (typeof args !== 'string') continue
|
||||
wanted = args.trim() !== 'off'
|
||||
} else if (item.type === 'plan/mode') {
|
||||
active = item.data?.['active'] === true
|
||||
wanted = null
|
||||
@@ -1007,9 +1010,11 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// The plan unit advances on its two folded event kinds.
|
||||
// The plan unit advances on its two folded event kinds when the command
|
||||
// lifecycle contains the input that represents a plan selection.
|
||||
const commandData = event as unknown as { data: { name?: string; args?: unknown } }
|
||||
if (type === 'plan/mode' || (type === 'command/run'
|
||||
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
|
||||
&& commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
@@ -1279,7 +1284,7 @@ export interface FixtureOptions {
|
||||
|
||||
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
|
||||
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
|
||||
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
|
||||
* piles up for the stream's lifetime). breakNow force-ends the stream without the
|
||||
* client's signal (timing hook: simulated connection loss). */
|
||||
class FxInbox<F> implements StreamConn<F> {
|
||||
private readonly inbox: RpcRequest<F>[] = []
|
||||
@@ -1325,6 +1330,16 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return createFixtureWorld(options).api
|
||||
}
|
||||
|
||||
interface FixtureWorld {
|
||||
readonly api: ApiProxy
|
||||
readonly rpc: ClientConnectionRpc
|
||||
}
|
||||
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
@@ -1332,7 +1347,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
||||
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
@@ -1342,6 +1357,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
|
||||
['DEEPSEEK_API_KEY', true],
|
||||
])
|
||||
/**
|
||||
* Preset compositions the fixture serves. Held as state rather than
|
||||
* constants so the settings editor's save and delete are exercisable: the
|
||||
* roster a GUI journey sees after writing is the text it wrote.
|
||||
*/
|
||||
const fixturePresets = new Map<string, { trust: 'system' | 'user'; content: string }>([
|
||||
['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }],
|
||||
['minimal', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }],
|
||||
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
|
||||
])
|
||||
let fixtureDefaultPreset = 'standard'
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
@@ -1503,37 +1529,147 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return backscanGoal(log) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
const fxMutateGoal = (
|
||||
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
|
||||
ref: { id: string; revision: number },
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
|
||||
const missing = requireSession(request)
|
||||
type FxGoalRef = { id: string; revision: number }
|
||||
type FxGoalView = FxGoalProjection['goal'] & {
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
activation: 'armed' | 'disarmed'
|
||||
}
|
||||
|
||||
const goalFailure = <T>(message: string): RpcResult<T> => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message, details: {} },
|
||||
})
|
||||
|
||||
const requireGoalSession = (id: SessionId): RpcResult<never> | undefined => (
|
||||
summaryOf(id) === undefined
|
||||
? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } }
|
||||
: undefined
|
||||
)
|
||||
|
||||
const goalView = (projection: FxGoalProjection): FxGoalView => ({
|
||||
...projection.goal,
|
||||
roundsStarted: projection.roundsStarted,
|
||||
createdAt: projection.createdAt,
|
||||
updatedAt: projection.updatedAt,
|
||||
activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed',
|
||||
})
|
||||
|
||||
/** Canonical fixture implementation of the generated Goal Remote contract. */
|
||||
const goalRemotes = {
|
||||
create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return goalFailure(`goal "${current.goal.id}" already exists`)
|
||||
}
|
||||
const now = Date.now()
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: {
|
||||
id: `fx-goal-${logOf(id).length}`,
|
||||
revision: 1,
|
||||
objective: request.objective,
|
||||
phase: 'active',
|
||||
maxGoalRounds: request.maxGoalRounds ?? 256,
|
||||
},
|
||||
roundsStarted: 0, createdAt: now, updatedAt: now,
|
||||
})
|
||||
return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } }
|
||||
},
|
||||
edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.objective === undefined ? {} : { objective: request.objective },
|
||||
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds },
|
||||
}))
|
||||
},
|
||||
pause(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
resume(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
complete(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
))
|
||||
},
|
||||
clear(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalRef> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 }
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(),
|
||||
})
|
||||
return { ok: true, value: tombstone }
|
||||
},
|
||||
}
|
||||
|
||||
/** Resolve one current goal revision for a canonical Remote mutation. */
|
||||
function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalProjection> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
return goalFailure('stale or missing goal revision')
|
||||
}
|
||||
return { ok: true, value: current }
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path behind the canonical Remote verbs. */
|
||||
function mutateGoal(
|
||||
id: SessionId,
|
||||
ref: FxGoalRef,
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): RpcResult<FxGoalView> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const goal = next(current)
|
||||
if (goal === undefined) {
|
||||
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
|
||||
return goalFailure(`invalid goal transition from "${current.goal.phase}"`)
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1,
|
||||
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
|
||||
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
return { ok: true, value: goalView(projection) }
|
||||
}
|
||||
|
||||
const mapGoalResult = <T, U>(result: RpcResult<T>, map: (value: T) => U): RpcResult<U> => (
|
||||
result.ok ? { ok: true, value: map(result.value) } : result
|
||||
)
|
||||
|
||||
const goalRefResult = (result: RpcResult<FxGoalView>): RpcResult<{ ref: { id: never; revision: number } }> => (
|
||||
mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } }))
|
||||
)
|
||||
|
||||
const legacyGoalResponse = <P, T>(request: RpcRequest<P>, result: RpcResult<T>): Promise<RpcResponse<T>> => (
|
||||
Promise.resolve({ rpcId: request.rpcId, result })
|
||||
)
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
/** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
|
||||
let historyDelayMs = 0
|
||||
/** One-shot history failure (timing hook: the doomed in-flight request of the S4 reconnect scenario). */
|
||||
/** One-shot history failure (timing hook: a pre-disconnect history request already doomed when reconnect lands). */
|
||||
let failNextHistory = false
|
||||
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
|
||||
const streamBreakers = new Set<() => void>()
|
||||
@@ -1542,8 +1678,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
|
||||
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is
|
||||
// ideally timed. These let
|
||||
// browser acceptance runs create slow-history, lost-frame, and reconnect
|
||||
// windows a real host produces naturally.
|
||||
const timingHooks = {
|
||||
@@ -1773,7 +1909,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
replays.set(id, { timer: setTimeout(tick, 80), finish })
|
||||
}
|
||||
|
||||
return {
|
||||
const api: ApiProxy = {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
search: (request, signal) => {
|
||||
@@ -1864,7 +2000,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
@@ -1973,20 +2109,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
current: modelSelections.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
routable: true,
|
||||
groups: fixtureModelGroups(),
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: (request) => {
|
||||
const selected: ModelTarget = {
|
||||
const selected: ModelSelection = {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
}
|
||||
modelTargets.set(request.payload.sessionId, selected)
|
||||
modelSelections.set(request.payload.sessionId, selected)
|
||||
return ok(request, { selected })
|
||||
},
|
||||
prompt: (request) => {
|
||||
@@ -2025,11 +2164,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== target.model) {
|
||||
const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== selection.model) {
|
||||
append(id, {
|
||||
type: 'request/context',
|
||||
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
|
||||
data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 },
|
||||
})
|
||||
}
|
||||
startReply(
|
||||
@@ -2039,9 +2178,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
? MARKDOWN_FIXTURE
|
||||
: userText === 'report model'
|
||||
? (() => {
|
||||
const target = modelTargets.get(id)
|
||||
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
||||
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
||||
const selection = modelSelections.get(id)
|
||||
return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}`
|
||||
+ (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`)
|
||||
})()
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
@@ -2075,6 +2214,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
@@ -2315,72 +2455,139 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
list: request => ok(request, {
|
||||
presets: [...fixturePresets].map(([id, preset]) => ({
|
||||
id,
|
||||
trust: preset.trust,
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
}),
|
||||
select: (request) => {
|
||||
fixtureDefaultPreset = request.payload.agentPreset
|
||||
return ok(request, { agentPreset: request.payload.agentPreset })
|
||||
},
|
||||
read: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const preset = fixturePresets.get(agentPreset)
|
||||
if (preset === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${agentPreset}"`,
|
||||
details: { agentPreset, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
return ok(request, {
|
||||
agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
})
|
||||
},
|
||||
copy: (request) => {
|
||||
const { from, agentPreset } = request.payload
|
||||
const source = fixturePresets.get(from)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${from}"`,
|
||||
details: { agentPreset: from, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
if (fixturePresets.has(agentPreset)) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent preset "${agentPreset}" already exists`,
|
||||
details: { agentPreset, reason: 'already exists' },
|
||||
})
|
||||
}
|
||||
fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
|
||||
return ok(request, { agentPreset })
|
||||
},
|
||||
// Native opens are deterministic no-op successes in this fixture, so the
|
||||
// open-directory affordance renders and the path-text fallback stays a
|
||||
// component-test concern.
|
||||
openDocument: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing === undefined || existing.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
return ok(request, { opened: true as const })
|
||||
},
|
||||
remove: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing?.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
fixturePresets.delete(agentPreset)
|
||||
return ok(request, {})
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
|
||||
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
// Mutation-only mirror of the host handlers: each verb CAS-checks the
|
||||
// projected current goal, appends the whole-value change (the mux
|
||||
// stream and projection frame ride the shared append path), and
|
||||
// acknowledges with the new ref only.
|
||||
create: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
},
|
||||
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
pause: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
)),
|
||||
resume: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
)),
|
||||
complete: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
)),
|
||||
clear: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear',
|
||||
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { cleared: true as const })
|
||||
},
|
||||
// Compatibility face only: old API Proxy payloads and acknowledgements
|
||||
// adapt to the canonical fixture Remote implementation above.
|
||||
create: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.create(request.payload.sessionId, {
|
||||
objective: request.payload.objective,
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
}),
|
||||
value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }),
|
||||
),
|
||||
),
|
||||
edit: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, {
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
),
|
||||
pause: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
resume: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
complete: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
clear: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.clear(request.payload.sessionId, request.payload.ref),
|
||||
() => ({ cleared: true as const }),
|
||||
),
|
||||
),
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
@@ -2500,8 +2707,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
providers: request => ok(request, {
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false },
|
||||
// One hand-declared route, so a surface reading this fixture meets
|
||||
// the tagged shape rather than only the shipped one.
|
||||
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
|
||||
],
|
||||
}),
|
||||
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
|
||||
@@ -2538,21 +2748,55 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
call(channel, endpoint, payload) {
|
||||
if (channel !== '/api') {
|
||||
return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`))
|
||||
}
|
||||
const args = (payload as {
|
||||
args: {
|
||||
agentId: SessionId
|
||||
ref?: { id: string; revision: number }
|
||||
request?: { objective?: string; maxGoalRounds?: number }
|
||||
}
|
||||
}).args
|
||||
const sessionId = args.agentId
|
||||
switch (endpoint) {
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: args.request?.objective as string,
|
||||
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
|
||||
}))
|
||||
case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {}))
|
||||
case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
|
||||
default:
|
||||
return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`))
|
||||
}
|
||||
},
|
||||
}
|
||||
return { api, rpc }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
|
||||
* overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch
|
||||
* straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four
|
||||
* named full forms, and feeding the same tap as a real carrier. Delete when the fixture moves
|
||||
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
* named full forms, and feeding the same tap as a real carrier. TODO: delete when the fixture
|
||||
* moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api: ApiProxy
|
||||
/** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.api = createFixtureApi(fixtureOptionsFromLocation())
|
||||
const world = createFixtureWorld(fixtureOptionsFromLocation())
|
||||
this.api = world.api
|
||||
this.rpc = world.rpc
|
||||
}
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
@@ -2598,6 +2842,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
@@ -2612,6 +2857,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
case 'command.execute': return this.api.commands.execute(request, signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'agentPreset.list': return this.api.agentPresets.list(request)
|
||||
case 'agentPreset.select': return this.api.agentPresets.select(request)
|
||||
case 'agentPreset.read': return this.api.agentPresets.read(request)
|
||||
case 'agentPreset.copy': return this.api.agentPresets.copy(request)
|
||||
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
|
||||
case 'agentPreset.remove': return this.api.agentPresets.remove(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
case 'goal.pause': return this.api.goals.pause(request)
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createWebConnectionRpc } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -18,7 +20,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
@@ -36,6 +38,7 @@ export {
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -69,11 +74,14 @@ export interface ConnectionHandle {
|
||||
export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
const fixtureClient = fixture ? new FixtureApiClient() : undefined
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
14
packages/client/connection/src/client/random-uuid.ts
Normal file
14
packages/client/connection/src/client/random-uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Browser-safe UUID generation for client-side wire correlation. */
|
||||
|
||||
/**
|
||||
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
|
||||
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
|
||||
*/
|
||||
export function randomUuid(): string {
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
|
||||
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
63
packages/client/connection/src/client/rpc.ts
Normal file
63
packages/client/connection/src/client/rpc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Browser caller for generic Connection unary RPC channels. */
|
||||
|
||||
import {
|
||||
RpcId,
|
||||
serverResponseSchema,
|
||||
type ClientRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/**
|
||||
* Create the browser-backed generic RPC caller.
|
||||
* @returns caller that owns request correlation and response-envelope validation.
|
||||
*/
|
||||
export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
return {
|
||||
async call(channel, endpoint, payload, signal) {
|
||||
assertTarget(channel, endpoint)
|
||||
const rpcId = RpcId(randomUuid())
|
||||
const message: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
const response = await globalThis.fetch(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
...signal === undefined ? {} : { signal },
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
|
||||
}
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
if (full.rpcId !== rpcId) {
|
||||
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
|
||||
}
|
||||
return full.result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBase(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
}
|
||||
|
||||
function assertTarget(channel: string, endpoint: string): void {
|
||||
const segments = endpoint.split('/')
|
||||
if (!CHANNEL_PATTERN.test(channel)
|
||||
|| segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
||||
export interface FetchHandler {
|
||||
/**
|
||||
* Handle one standard Fetch request.
|
||||
* @param request - request produced by the active transport bridge.
|
||||
* @returns complete or streaming Fetch response.
|
||||
*/
|
||||
fetch(request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge one node:http request to the fetch-shaped handler (client close
|
||||
* aborts; SSE bodies stream out chunk by chunk).
|
||||
@@ -12,7 +22,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
* @param res - node:http response the bridge writes and owns to completion.
|
||||
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
|
||||
@@ -7,15 +7,26 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { HostConnectionService } from './rpc-host.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export type {
|
||||
ConnectionRpcAuthority,
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
export { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
@@ -55,6 +66,24 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
* keys, or key state — and a LAN client's model picker legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
// A preset composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance; copy and remove rearrange what the deployment offers, and
|
||||
// openDocument drives the host desktop — all more than the roster beside
|
||||
// them. (Authoring is copy-only, so no method here accepts composition text
|
||||
// or a path; the pin is about who may manage the roster at all.)
|
||||
//
|
||||
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
|
||||
// preset looks like escalation — one of them mounts the toolset that edits the
|
||||
// live runtime — but `session.create` already takes an `agentPreset`, so
|
||||
// pinning only the switch would leave the same capability one method over.
|
||||
// The deeper reason is that the capability is not the preset's to grant: the
|
||||
// deployment's own default already carries `bash` and the filesystem tools, so
|
||||
// any caller that may start a session at all can already run commands as this
|
||||
// process. Pinning the switch would be a fence beside an open gate.
|
||||
'agentPreset.read',
|
||||
'agentPreset.copy',
|
||||
'agentPreset.openDocument',
|
||||
'agentPreset.remove',
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
@@ -83,49 +112,61 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const connection = new HostConnectionService(ctx, trustedHosts)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
if (method !== undefined
|
||||
&& PRIVILEGED_METHODS.has(method)
|
||||
&& !isTrustedApiRequest(request, [])) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
return new Response('upgrade required', {
|
||||
status: 426,
|
||||
headers: { connection: 'Upgrade', upgrade: 'websocket' },
|
||||
})
|
||||
}
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) return new Response('not found', { status: 404 })
|
||||
return toFetchHandler(apiProxy).fetch(request)
|
||||
},
|
||||
})
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
||||
? isTrustedApiRequest(req, [])
|
||||
: isTrustedApiRequest(req, trustedHosts)
|
||||
if (!allowed) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
})
|
||||
}
|
||||
|
||||
224
packages/client/connection/src/rpc-host.ts
Normal file
224
packages/client/connection/src/rpc-host.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcError,
|
||||
type RpcErrorDetailsMap,
|
||||
type RpcId as RpcIdType,
|
||||
type ServerResponse as RpcServerResponse,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { bridge, type FetchHandler } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import type {
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
|
||||
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
interface ConnectionRpcInterceptor {
|
||||
readonly matches: ConnectionRpcEndpointMatcher
|
||||
readonly fetchHandler: FetchHandler
|
||||
readonly options: ConnectionRpcHandlerOptions
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host Connection transport and RPC registrations. */
|
||||
connection: HostConnectionHandle
|
||||
}
|
||||
}
|
||||
|
||||
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
||||
export class HostConnectionService extends Service implements HostConnectionHandle {
|
||||
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
|
||||
|
||||
/**
|
||||
* Provide the Host half over the active HTTP server.
|
||||
* @param ctx - owning Connection plugin context.
|
||||
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
/** Generic channel registry scoped to the Context reading this service. */
|
||||
get rpc(): HostConnectionRpc {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
|
||||
intercept: (channel, matches, handler, options) =>
|
||||
this.registerInterceptor(owner, channel, matches, handler, options),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one shared-channel Fetch handler from its interceptor and fallback.
|
||||
* @param channel - shared channel mounted by Connection.
|
||||
* @param fallback - handler for endpoints not claimed by the interceptor.
|
||||
* @returns Fetch handler that selects exactly one target for each request.
|
||||
*/
|
||||
createSharedFetchHandler(
|
||||
channel: '/api',
|
||||
fallback: FetchHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
fetch: (request) => {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
const interceptor = this.interceptors.get(channel)
|
||||
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
|
||||
return fallback.fetch(request)
|
||||
}
|
||||
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
|
||||
return Promise.resolve(new Response('forbidden', { status: 403 }))
|
||||
}
|
||||
return interceptor.fetchHandler.fetch(request)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private register(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
assertChannel(channel)
|
||||
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
|
||||
const fetchHandler = rpcFetchHandler(channel, handler)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
return owner.effect(
|
||||
() => owner.httpServer.register(route),
|
||||
`client-connection: ${channel} rpc channel`,
|
||||
)
|
||||
}
|
||||
|
||||
private registerInterceptor(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
if (channel !== API_PATH) {
|
||||
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
const interceptor: ConnectionRpcInterceptor = {
|
||||
matches,
|
||||
fetchHandler: rpcFetchHandler(channel, handler),
|
||||
options,
|
||||
}
|
||||
return owner.effect(() => {
|
||||
if (this.interceptors.has(channel)) {
|
||||
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
|
||||
}
|
||||
this.interceptors.set(channel, interceptor)
|
||||
return () => {
|
||||
this.interceptors.delete(channel)
|
||||
}
|
||||
}, `client-connection: ${channel} rpc interceptor`)
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFetchHandler(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
if (request.method !== 'POST' || endpoint === undefined) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/json') {
|
||||
return new Response('content type must be application/json', { status: 415 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return new Response('body is not JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const envelope = clientRequestSchema.safeParse(body)
|
||||
if (!envelope.success) {
|
||||
return invalidEnvelopeResponse(body, envelope.error.issues)
|
||||
}
|
||||
const message: ClientRequest = envelope.data
|
||||
if (message.method !== endpoint) {
|
||||
return errorResponse(message.rpcId, {
|
||||
code: 'bad-request',
|
||||
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
||||
details: { issues: [] },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, message.payload, request.signal)
|
||||
return fullResponse(message.rpcId, result)
|
||||
} catch (error) {
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
|
||||
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
|
||||
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
|
||||
return errorResponse(rpcId, {
|
||||
code: 'bad-request',
|
||||
message: 'invalid client-request message',
|
||||
details: { issues },
|
||||
})
|
||||
}
|
||||
|
||||
function endpointFromPath(channel: string, pathname: string): string | undefined {
|
||||
if (!pathname.startsWith(`${channel}/`)) return undefined
|
||||
const endpoint = pathname.slice(channel.length + 1)
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
return undefined
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
|
||||
return fullResponse(rpcId, { ok: false, error })
|
||||
}
|
||||
|
||||
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
|
||||
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
|
||||
return Response.json(body)
|
||||
}
|
||||
|
||||
function assertChannel(channel: string): void {
|
||||
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
|
||||
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
}
|
||||
77
packages/client/connection/src/rpc.ts
Normal file
77
packages/client/connection/src/rpc.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/** Trust fence applied before a Host RPC channel reaches its handler. */
|
||||
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
|
||||
|
||||
/** Registration policy for one logical RPC channel. */
|
||||
export interface ConnectionRpcHandlerOptions {
|
||||
/** Browser authority accepted by every endpoint in this channel. */
|
||||
readonly authority: ConnectionRpcAuthority
|
||||
}
|
||||
|
||||
/** Handler invoked after Connection has decoded the transport envelope. */
|
||||
export type ConnectionRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<RpcResult<unknown>>
|
||||
|
||||
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
|
||||
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
|
||||
|
||||
/** Host registry for logical RPC channels carried by the current transport. */
|
||||
export interface HostConnectionRpc {
|
||||
/**
|
||||
* Register one absolute channel prefix and its trust policy.
|
||||
* @param channel - absolute logical channel such as `/rpc`.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - channel trust policy.
|
||||
* @returns asynchronous disposer removing the channel and its physical route.
|
||||
*/
|
||||
handle(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
|
||||
/**
|
||||
* Intercept owned endpoints on the shared `/api` channel before its fallback.
|
||||
* @param channel - reserved shared channel; currently `/api`.
|
||||
* @param matches - synchronous endpoint ownership test.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - trust policy for every endpoint claimed by this interceptor.
|
||||
* @returns asynchronous disposer removing the interceptor.
|
||||
*/
|
||||
intercept(
|
||||
channel: '/api',
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
}
|
||||
|
||||
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
|
||||
export interface HostConnectionHandle {
|
||||
/** Generic RPC channel registry. */
|
||||
readonly rpc: HostConnectionRpc
|
||||
}
|
||||
|
||||
/** Client caller for logical RPC channels carried by the current transport. */
|
||||
export interface ClientConnectionRpc {
|
||||
/**
|
||||
* Call one endpoint through an already registered logical channel.
|
||||
* @param channel - absolute logical channel such as `/api`.
|
||||
* @param endpoint - channel-relative endpoint such as `goals/create`.
|
||||
* @param payload - channel-owned request payload.
|
||||
* @param signal - optional caller cancellation.
|
||||
* @returns the existing RPC success/error result; correlation stays inside Connection.
|
||||
*/
|
||||
call(
|
||||
channel: string,
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResult<unknown>>
|
||||
}
|
||||
@@ -203,4 +203,119 @@ describe('connection client apply', () => {
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
|
||||
it('carries RPC calls without requiring secure-context randomUUID', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues(bytes: Uint8Array) {
|
||||
return bytes.fill(0)
|
||||
},
|
||||
})
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: { url: string; body: unknown }[] = []
|
||||
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
|
||||
const body = JSON.parse(init.body) as { rpcId: string }
|
||||
seen.push({ url, body })
|
||||
return Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: body.rpcId,
|
||||
result: { ok: true, value: { ref: 'goal-1' } },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
|
||||
expect(seen[0]?.body).toMatchObject({
|
||||
type: 'client-request',
|
||||
rpcId: '00000000-0000-4000-8000-000000000000',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('validates generic RPC transport failures, correlation, and targets', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const abort = new AbortController()
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
|
||||
.rejects.toThrow('HTTP 503')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
new URL('https://harness.example/api/goals/create'),
|
||||
expect.objectContaining({ signal: abort.signal }),
|
||||
)
|
||||
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: 'different-rpc',
|
||||
result: { ok: true, value: null },
|
||||
}))
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
|
||||
const fetch = vi.mocked(globalThis.fetch)
|
||||
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
|
||||
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
|
||||
for (const [channel, endpoint] of [
|
||||
['api2', 'goals/create'],
|
||||
['/api/path', 'goals/create'],
|
||||
['/api', ''],
|
||||
['/api', '.'],
|
||||
['/api', '..'],
|
||||
['/api', 'goals//create'],
|
||||
['/api', 'goals/create?unsafe'],
|
||||
] as const) {
|
||||
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
|
||||
}
|
||||
})
|
||||
|
||||
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const created = await handle.rpc.call('/api', 'goals/create', {
|
||||
args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
|
||||
})
|
||||
expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
|
||||
if (!created.ok) throw new Error('fixture Goal create failed')
|
||||
const ref = (created.value as { ref: { id: string; revision: number } }).ref
|
||||
const edited = await handle.rpc.call('/api', 'goals/edit', {
|
||||
args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
|
||||
})
|
||||
expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
|
||||
const editedRef = { id: ref.id, revision: 2 }
|
||||
const paused = await handle.rpc.call('/api', 'goals/pause', {
|
||||
args: { agentId: 'fx-alpha', ref: editedRef },
|
||||
})
|
||||
expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
|
||||
const resumed = await handle.rpc.call('/api', 'goals/resume', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
|
||||
})
|
||||
expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
|
||||
const completed = await handle.rpc.call('/api', 'goals/complete', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
|
||||
})
|
||||
expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
|
||||
await expect(handle.rpc.call('/api', 'goals/clear', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
|
||||
})).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
|
||||
await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
|
||||
await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
|
||||
.rejects.toThrow(/endpoint.*unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
@@ -50,20 +50,21 @@ export class FakeApiClient implements IApiClient {
|
||||
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 })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelSelection: ModelSelection }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
onSelectModel: (payload: ModelSelection & { sessionId: SessionId })
|
||||
=> 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 }))
|
||||
@@ -104,7 +105,7 @@ export class FakeApiClient implements IApiClient {
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
selectModel: (payload: ModelSelection & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
@@ -125,6 +126,9 @@ export class FakeApiClient implements IApiClient {
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
|
||||
accepted: true as const,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
@@ -162,11 +166,28 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
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)),
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
const catalog = await api.sessions.models(req({ sessionId }))
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => {
|
||||
let carrierSignal: AbortSignal | undefined
|
||||
const pending = bridge(request, response, {
|
||||
fetch: async (input) => {
|
||||
const fetchRequest = input as Request
|
||||
const fetchRequest = input
|
||||
carrierSignal = fetchRequest.signal
|
||||
resolveStarted()
|
||||
if (!fetchRequest.signal.aborted) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
@@ -17,6 +18,9 @@ function fakeHttpServer(
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
@@ -36,15 +40,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
return request
|
||||
}
|
||||
|
||||
/** JSON POST carrying a complete client-request envelope. */
|
||||
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Raw POST for malformed-body and media-type boundary cases. */
|
||||
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const chunks: Buffer[] = []
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
|
||||
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
|
||||
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
@@ -138,6 +159,10 @@ describe('connection node half', () => {
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
// A composition names the plugins a session runs: reading one is
|
||||
// reconnaissance, and copy/remove/openDocument manage the roster and
|
||||
// drive the host desktop.
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -173,6 +198,211 @@ describe('connection node half', () => {
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
}, { authority: 'trusted-host' })
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')
|
||||
expect(route).toBeDefined()
|
||||
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-dedicated'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
const result = fakeResponse()
|
||||
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
|
||||
expect(result.state.status).toBe(200)
|
||||
expect(JSON.parse(String(result.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-dedicated',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
|
||||
authority: 'trusted-host',
|
||||
})).toThrow(/duplicate route/)
|
||||
await remove()
|
||||
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
},
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('already has an interceptor')
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/rpc' as '/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('invalid shared RPC channel')
|
||||
const route = routes.find(candidate => candidate.path === API_PATH)!
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-shared'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
|
||||
const claimed = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
|
||||
expect(JSON.parse(String(claimed.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-shared',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const unclaimed = fakeResponse()
|
||||
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
|
||||
expect(unclaimed.state.status).toBe(404)
|
||||
|
||||
await remove()
|
||||
const withdrawn = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
|
||||
expect(withdrawn.state.status).toBe(404)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const removeLoopback = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'loopback' },
|
||||
)
|
||||
const loopbackOnly = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
|
||||
expect(loopbackOnly.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
|
||||
if (endpoint === 'fail') throw new Error('handler broke')
|
||||
return { ok: true, value: null }
|
||||
}, {
|
||||
authority: 'trusted-host',
|
||||
})
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')!
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
|
||||
const methodMismatch = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
|
||||
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
|
||||
}), methodMismatch.response)
|
||||
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
|
||||
rpcId: 'rpc-bad',
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
|
||||
for (const [request, status] of [
|
||||
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
|
||||
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(request, response.response)
|
||||
expect(response.state.status).toBe(status)
|
||||
}
|
||||
|
||||
for (const [body, rpcId] of [
|
||||
[{ rpcId: 'retained-id' }, 'retained-id'],
|
||||
[{ rpcId: 42 }, 'invalid-request'],
|
||||
[null, 'invalid-request'],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
|
||||
expect(JSON.parse(String(response.state.body))).toMatchObject({
|
||||
rpcId,
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
}
|
||||
|
||||
const failed = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
|
||||
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
|
||||
}), failed.response)
|
||||
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
|
||||
|
||||
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
|
||||
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})
|
||||
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
|
||||
const publicResponse = fakeResponse()
|
||||
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
|
||||
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
|
||||
}), publicResponse.response)
|
||||
expect(publicResponse.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await remove()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
@@ -226,13 +456,19 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
// `agentPreset.list` joins the model catalog for the same reason: ids and
|
||||
// trust only, and a LAN client's preset picker needs it. `select` is
|
||||
// reachable too: `session.create` already takes an `agentPreset`, and the
|
||||
// deployment's own default already carries bash, so pinning the switch
|
||||
// would be a fence beside an open gate.
|
||||
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
@@ -28,10 +28,10 @@
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
"path": "../../interaction/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
|
||||
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
|
||||
README.zh.md: fc4100c48e5db9ed6781117dd652232bbd7c7aaa
|
||||
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it reloads the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* fiber in place. Every graph entry is a plugin bundle
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all rostered plugin packages share these reload semantics;
|
||||
* normal packages (react family, cordis, shell, pure libs) are not entries
|
||||
@@ -30,13 +30,12 @@
|
||||
* Failure window: if prefetch rejects after invalidate, the module is left
|
||||
* unregistered while the OLD fiber keeps running untouched (teardown never
|
||||
* started) — degraded but recoverable, the next rebuilt frame retries from
|
||||
* scratch. Consistent with the v1 no-rollback policy below. Known dev-only
|
||||
* scratch. Consistent with the no-rollback policy below. Known dev-only
|
||||
* race: a rebuilt frame overlapping a still-in-flight boot arrival shares
|
||||
* that arrival's task and may materialize the pre-rebuild bytes; the next
|
||||
* rebuilt frame self-heals.
|
||||
*
|
||||
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path —
|
||||
* confirmed against vendor sources:
|
||||
* Why not the naive `entry.fiber.dispose()` → `entry.refresh()` path:
|
||||
* 1. `Entry.fiber` is never cleared on dispose (vendor/loader/src/config/
|
||||
* entry.ts assigns it only in `_init`), so `refresh()` hits its
|
||||
* `if (this.fiber) return` guard and no-ops.
|
||||
@@ -46,7 +45,7 @@
|
||||
* `disabled: true` — permanently.
|
||||
* vendor/hmr's reload skeleton documents the fix: delete the runtime record
|
||||
* FIRST (`registry.delete` → case 4 returns early, the entry stays enabled),
|
||||
* then rebuild. We additionally clear `entry.fiber` ourselves so
|
||||
* then rebuild. `entry.fiber` is additionally cleared so
|
||||
* `entry.refresh()` re-imports and re-plugins through the Loader's own
|
||||
* `_init` (entry-resolved config, automatic `fiber.entry` rebinding) instead
|
||||
* of hand-rolling `registry.plugin`. Client entries have exactly one fiber
|
||||
@@ -58,7 +57,7 @@
|
||||
* apply opens a fresh channel. Frames arriving during the gap are lost —
|
||||
* acceptable for the dev channel, the next rebuild renotifies.
|
||||
*
|
||||
* Failure policy (v1): no rollback. An import failure leaves the entry
|
||||
* Failure policy: no rollback. An import failure leaves the entry
|
||||
* fiberless (the next rebuilt frame retries from scratch); an apply failure
|
||||
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
|
||||
*/
|
||||
@@ -136,7 +135,7 @@ export function apply(ctx: Context): void {
|
||||
// re-plugins under the entry context. Import failures are logged by
|
||||
// Entry._init and leave the entry fiberless (retryable).
|
||||
await entry.refresh()
|
||||
// Surface apply failures loudly (v1: no rollback, FAILED state stays).
|
||||
// Surface apply failures loudly (no rollback, FAILED state stays).
|
||||
await entry.fiber?.await()
|
||||
}
|
||||
|
||||
@@ -152,7 +151,7 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
break
|
||||
case 'graph':
|
||||
// Connect-time snapshot, unused in v1. The loader's cached graph rev
|
||||
// Connect-time snapshot, unused. The loader's cached graph rev
|
||||
// goes stale after rebuilds — harmless, since prefetch hits the
|
||||
// network anyway (host serves bundles no-cache); graph rev refresh
|
||||
// lands with the reconnect-handshake mechanism.
|
||||
|
||||
@@ -172,7 +172,7 @@ export class LocaleService {
|
||||
* the typed form: each dictionary is checked against the namespace's
|
||||
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
|
||||
* compile error), and every shipped locale is required (bilingual balance
|
||||
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
|
||||
* enforced at registration). Duplicate (ns, locale) throws (single occupant; a
|
||||
* namespace's texts have one owner). Registration bumps the revision so
|
||||
* mounted outlets pick up late-arriving dictionaries.
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('LocaleService', () => {
|
||||
expect(t('own')).toBe('自有')
|
||||
// common itself must not recurse: a miss inside common echoes the key.
|
||||
// (Wide-string ns hits the untyped bind overload — the typed one rejects
|
||||
// unknown keys at compile time, which is the point of the seam.)
|
||||
// unknown keys at compile time, which is the point of the typed registry contract.)
|
||||
expect(svc.bind('common' as string)('nope')).toBe('nope')
|
||||
})
|
||||
|
||||
|
||||
@@ -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/modules/README.md
|
||||
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
|
||||
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd
|
||||
README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060
|
||||
README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` through its `internal` contract — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包(package)的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并通过其 `internal` 约定注入该包(package)的 `ClientModuleLoader`;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Browser half (the standard `./client` export): the module-system class and
|
||||
* wire contract, plus the enrollment plugin face. The module system itself is
|
||||
* built by the shell kernel BEFORE cordis exists (the bootstrap exception,
|
||||
* design §4.7 — the mechanism that loads plugins cannot arrive through
|
||||
* built by the shell kernel BEFORE cordis exists (the bootstrap exception —
|
||||
* the mechanism that loads plugins cannot arrive through
|
||||
* itself); the plugin face only enrolls that pre-existing instance by
|
||||
* providing it as `ctx.modules`. The kernel statically registers this module,
|
||||
* so the graph row for this package never triggers a real fetch — arrival is
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Client module system: the browser peer of Node's internal ESM loader, built
|
||||
* as a lazy CJS table. The vendored cordis Loader consumes this object
|
||||
* through its `internal` seam (the only call site is `EntryTree.import` →
|
||||
* through its `internal` contract (the only call site is `EntryTree.import` →
|
||||
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
|
||||
* waiting, update/refresh) entirely on the vendored side while this package
|
||||
* owns code arrival.
|
||||
*
|
||||
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
|
||||
* Lazy CJS model: executing a plugin bundle only REGISTERS its
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
@@ -25,7 +25,7 @@
|
||||
* imports are a build error anyway.
|
||||
*
|
||||
* This file is the browser-safe contract face (zero node imports): the
|
||||
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
|
||||
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
|
||||
* {@link ClientModuleSystem}. The package root is the host-side service that
|
||||
* composes the wire.
|
||||
*/
|
||||
@@ -35,13 +35,13 @@ import type { ClientModuleSystem } from './system.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
|
||||
/** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */
|
||||
modules: ClientModuleLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One composed client entry pushed by the host (web2 §0 graph row). Wire
|
||||
* One composed client entry pushed by the host (a graph row). Wire
|
||||
* single source: the host node half (package root) produces this same shape.
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
@@ -143,7 +143,7 @@ export function parseBootManifest(wire: unknown): BootManifest {
|
||||
return { rev: graph.rev, modules, plugins }
|
||||
}
|
||||
|
||||
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
|
||||
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff). */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
|
||||
id: string
|
||||
@@ -159,7 +159,7 @@ export interface ClientPluginHandoff {
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
|
||||
__DSH_BOOT__?: unknown
|
||||
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */
|
||||
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor. */
|
||||
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
|
||||
/**
|
||||
* Kernel handoff slot: the shell kernel stores the instance here right
|
||||
@@ -170,7 +170,7 @@ export interface DshWindow {
|
||||
__DSH_MODULES__?: ClientModuleSystem
|
||||
}
|
||||
|
||||
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
|
||||
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph boundary, flat today). */
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
@@ -178,14 +178,14 @@ export interface ClientModuleRecord {
|
||||
surface: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
|
||||
/** Observed `require()` edges (module-graph boundary; only table words can appear today). */
|
||||
edges: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal-seam subset the vendored Loader and the client HMR plugin
|
||||
* The internal-contract subset the vendored Loader and the client HMR plugin
|
||||
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
|
||||
* as `ctx.modules` (contract C5).
|
||||
* as `ctx.modules`.
|
||||
*/
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
@@ -193,12 +193,12 @@ export interface ClientModuleLoader {
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* Internal contract consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* `specifier` through the branch order documented on the module, fetching
|
||||
* and executing a bundle when needed.
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - import attributes (unused; interface parity with Node's seam).
|
||||
* @param attrs - Import attributes (unused; interface parity with Node's loader contract).
|
||||
* @returns the module's export surface.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
@@ -232,6 +232,6 @@ export interface ClientModuleSystemOptions {
|
||||
modules: BootModuleRow[]
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
|
||||
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* contract. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the public interfaces in `./manifest.ts`; this file owns the
|
||||
* state tables and the load/materialize machinery.
|
||||
*/
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
|
||||
} from './manifest.ts'
|
||||
|
||||
/** Default bundle-load seam: same-origin external classic script. */
|
||||
/** Default bundle-load hook: same-origin external classic script. */
|
||||
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
el.async = true
|
||||
@@ -53,8 +53,8 @@ const claimStyles = (id: string): string[] => {
|
||||
/**
|
||||
* The client module system: state tables plus the arrival/materialization
|
||||
* machinery implementing {@link ClientModuleLoader} (whose members carry the
|
||||
* seam contract docs). Construction indexes the boot rows and installs the
|
||||
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
|
||||
* contract documentation). Construction indexes the boot rows and installs the
|
||||
* `window.__ModuleLoader__` registration sink — once per page.
|
||||
*/
|
||||
export class ClientModuleSystem implements ClientModuleLoader {
|
||||
readonly version = 'client'
|
||||
@@ -72,7 +72,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
|
||||
/**
|
||||
* Build the module system over the parsed boot rows.
|
||||
* @param options - module rows, module-table staticModules, and bundle-load seam.
|
||||
* @param options - Module rows, module-table staticModules, and bundle-load hook.
|
||||
*/
|
||||
constructor(options: ClientModuleSystemOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* set with all current entries and flushes synchronously, so first scan and
|
||||
* steady state share one implementation. Package metadata (including the
|
||||
* negative "not a client package" verdict) is cached per name and never
|
||||
* expires — plugin-set changes take effect on restart per the config-source
|
||||
* ruling; bundle content changes reach the graph only through
|
||||
* expires — plugin-set changes take effect on restart; bundle content
|
||||
* changes reach the graph only through
|
||||
* {@link ClientModuleHostService.rebuilt}.
|
||||
* @module @deepseek-ai/dsh-client-modules
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
* default transport seam, and the loud failure modes (duplicate
|
||||
* default transport hook, and the loud failure modes (duplicate
|
||||
* registration, cycles, table misses, double boot).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -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: 8ac29a4258bbd7456b20c61e547d48c570e84d27
|
||||
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
|
||||
README.md: 0a7d9975093da558af623ee9940f4be398526821
|
||||
README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
@@ -22,6 +22,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
@@ -32,19 +34,23 @@ 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 comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. 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
|
||||
|
||||
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
|
||||
|
||||
## Code Mode sub-dispatch index
|
||||
## Code Mode child-call tree
|
||||
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
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
|
||||
|
||||
@@ -52,7 +58,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
|
||||
|
||||
## 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
|
||||
|
||||
@@ -60,7 +66,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
|
||||
|
||||
@@ -68,10 +74,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` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
@@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
@@ -32,19 +34,23 @@ 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 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
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)。
|
||||
|
||||
## 请求检查
|
||||
|
||||
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
## Code Mode 子调用树
|
||||
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
每个 `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 标题投影
|
||||
|
||||
@@ -52,7 +58,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
|
||||
|
||||
@@ -74,4 +80,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 永远无法匹配。
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -41,17 +43,22 @@
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
|
||||
export type AgentContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'>
|
||||
}
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
@@ -29,7 +35,7 @@ export interface AgentScopeHandle {
|
||||
* through it (passing it as the dispatch subject routes to this agent's
|
||||
* tagged listeners plus every untagged one).
|
||||
*/
|
||||
ctx: Context
|
||||
ctx: AgentContext
|
||||
/** Backing fiber (dispose tears down every scope-owned registration). */
|
||||
fiber: Fiber
|
||||
}
|
||||
@@ -48,15 +54,16 @@ function agentScope(): void {}
|
||||
*/
|
||||
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
|
||||
const fiber = ctx.plugin(agentScope)
|
||||
const scoped = fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}) as AgentContext
|
||||
return {
|
||||
fiber,
|
||||
ctx: fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}),
|
||||
ctx: scoped,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentContext } from '../agents/scope.ts'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
import type { SessionFace } from './session.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
export type { AgentContext } from '../agents/scope.ts'
|
||||
|
||||
/** The sessions-service face injected as `ctx.sessions`. */
|
||||
export interface ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
|
||||
@@ -59,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
|
||||
/**
|
||||
@@ -95,9 +107,9 @@ export interface ISessions {
|
||||
* @param id - session id.
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined
|
||||
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)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,41 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { SessionHistoryService } from './session-history/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.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'
|
||||
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
|
||||
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
// materialization/projection implementation; no test-side mirror to drift).
|
||||
export { SessionProvideChannel } from './sessions/provide.ts'
|
||||
@@ -21,12 +43,13 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { resolveWorkspacePath } from './workspaces/path.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { AgentContext, ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
@@ -45,11 +68,17 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
|
||||
CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
||||
RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
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'
|
||||
@@ -65,7 +94,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,
|
||||
@@ -75,15 +105,15 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
/** Client Agent scope identity; the agent and session share one wire id. */
|
||||
agent: TypeRTContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call as the chat flow renders it: still-running (spinner card) or
|
||||
* settled (result node). The fold produces both shapes; toolview components
|
||||
* narrow on the discriminant fields.
|
||||
*/
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/**
|
||||
@@ -161,6 +191,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. */
|
||||
@@ -170,16 +204,23 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
/** Required services: the wire handle and Client TypeRT registry. */
|
||||
export const inject = ['connection', 'typert']
|
||||
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
*/
|
||||
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),
|
||||
})
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
@@ -7,7 +6,7 @@ import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
|
||||
AssistantRequestConfig, AssistantTiming, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
@@ -20,6 +19,7 @@ import type { ConversationPromptSnapshot } from '../sessions/request-inspection.
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
|
||||
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
@@ -41,7 +41,6 @@ export interface ConversationHistoryProjection {
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
@@ -177,6 +176,7 @@ function materializeNode(
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -188,74 +188,22 @@ function materializeNode(
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
interface TransientProjection extends Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
|
||||
'interruptedNodes' | 'partial' | 'runningCalls'
|
||||
> {
|
||||
toolCallTree: ToolCallTree
|
||||
}
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
const toolCallTree = new ToolCallTree()
|
||||
|
||||
for (const entry of entries) {
|
||||
const { event } = entry
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
// The independent replay emits the same public running-call shape as
|
||||
// Chat without reading or mutating Session's live index.
|
||||
/* jscpd:ignore-start */
|
||||
codeDispatches.set(data.parentCallId, [...siblings, {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
}])
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
// History independently reproduces the public settled-call shape instead
|
||||
// of consuming Session's live code-dispatch projection.
|
||||
/* jscpd:ignore-start */
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
}
|
||||
codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if (toolCallTree.apply(event)) continue
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
@@ -280,6 +228,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
step: event.data.step,
|
||||
time: event.time,
|
||||
callView: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
subCalls: [],
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
break
|
||||
@@ -317,6 +266,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
@@ -331,7 +281,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
codeDispatches,
|
||||
toolCallTree,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,9 +412,17 @@ export function projectConversationHistory(
|
||||
}
|
||||
}
|
||||
|
||||
const transient = projectTransient(entries)
|
||||
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
|
||||
const projectedContexts = contexts.map((context): ConversationContext => {
|
||||
const nodes = transient.toolCallTree.projectNodes(context.nodes)
|
||||
return nodes === context.nodes ? context : { ...context, nodes }
|
||||
})
|
||||
return {
|
||||
eventNodes,
|
||||
contexts,
|
||||
...projectTransient(entries),
|
||||
eventNodes: projectedEventNodes,
|
||||
contexts: projectedContexts,
|
||||
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
|
||||
partial: transient.partial,
|
||||
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
// A user-explicit skill invocation names the skill it injected.
|
||||
case 'skill-invocation':
|
||||
return { role: 'inject', label: readString(record, 'name') ?? kind }
|
||||
// Documented default arm of the merge-extensible source map: an unknown
|
||||
// producer still identifies itself by its own durable kind.
|
||||
default:
|
||||
|
||||
@@ -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. */
|
||||
@@ -174,6 +179,8 @@ export interface ToolResultNode {
|
||||
callView: ToolCallView | null
|
||||
/** Host-computed render intent from this tool/result's wire view; null = same default. */
|
||||
resultView: ToolResultView | null
|
||||
/** Child calls owned by this call, in dispatch order. */
|
||||
subCalls: readonly ToolCallBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,16 +196,22 @@ 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 event is outside the window. */
|
||||
summaryEventSeq: number | null
|
||||
/** 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 the summary event is unavailable or malformed. */
|
||||
shadowedTokenCount: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -214,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.
|
||||
@@ -230,10 +243,18 @@ export interface CommandNode {
|
||||
commandId: CommandId
|
||||
/** Command name (run payload's structured field); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||
/**
|
||||
* Verbatim rawInput after the name, including separator whitespace; null
|
||||
* when omitted by the command or when the run fell outside the window.
|
||||
*/
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
outcome: {
|
||||
kind: 'success' | 'error'
|
||||
text?: string
|
||||
/** Earlier authoritative domain event for a richer client-computed presentation. */
|
||||
sourceEventSeq?: number
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
@@ -249,21 +270,6 @@ export type ConversationNode =
|
||||
| CompactionSummaryNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
* One `run_code` sub-dispatch materialized in the native call-block shapes so
|
||||
* every consumer (tool rows, details panel) renders it through the exact
|
||||
* components that render a native call: a started-but-unsettled sub-call is a
|
||||
* {@link RunningToolCall} (rows derive the running state from the shape,
|
||||
* exactly as for native calls) and its `tool/code-dispatch` settlement
|
||||
* replaces it in place with the {@link ToolResultNode} form. Never part of
|
||||
* the transcript `nodes` flow — sub-calls live under their parent via
|
||||
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
|
||||
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
|
||||
* and its JSON-stringified logged arguments; `content`/`isError` are the
|
||||
* settled sub-call's complete logged outcome.
|
||||
*/
|
||||
export type CodeSubCall = RunningToolCall | ToolResultNode
|
||||
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
callId: string
|
||||
@@ -275,8 +281,12 @@ export interface RunningToolCall {
|
||||
time: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
/** Child calls owned by this call, in dispatch order. */
|
||||
subCalls: readonly ToolCallBlock[]
|
||||
}
|
||||
|
||||
/** One running or settled call, recursively owning its child calls. */
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
|
||||
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedMessage {
|
||||
@@ -306,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'
|
||||
|
||||
@@ -330,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 }>
|
||||
@@ -341,13 +415,6 @@ export interface ConversationSnapshot {
|
||||
turnEnds: ReadonlyMap<number, number>
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
/**
|
||||
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
|
||||
* order. Populated from in-window `tool/code-dispatch` events (live and
|
||||
* replay identically); the per-parent array reference is stable across
|
||||
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Authoritative transient inbox snapshot, including queued and steering placements. */
|
||||
queue: readonly QueuedMessage[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
|
||||
ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
@@ -34,7 +34,6 @@ export interface SessionHistoryInspection {
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +111,6 @@ export function createHistoryInspection(
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get codeDispatches() {
|
||||
return conversationProjection().codeDispatches
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -29,8 +28,9 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.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'
|
||||
@@ -127,7 +133,7 @@ export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
/** The outward session face only — feature code never sees the concrete class. */
|
||||
readonly session: SessionFace
|
||||
readonly ctx: Context
|
||||
readonly ctx: AgentContext
|
||||
}
|
||||
|
||||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||||
@@ -182,7 +188,7 @@ function increasedForkTitle(title: string): string {
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
ctx: AgentContext
|
||||
binding: SessionBinding
|
||||
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
|
||||
session: Session
|
||||
@@ -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).
|
||||
@@ -483,12 +526,12 @@ export class SessionsService implements ISessions {
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
scope(id: SessionId): AgentContext | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,26 +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 {
|
||||
CodeSubCall, 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 { 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. */
|
||||
@@ -53,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,49 +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
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
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
|
||||
@@ -143,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
|
||||
@@ -156,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
|
||||
@@ -169,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
|
||||
@@ -194,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.
|
||||
@@ -230,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 {
|
||||
@@ -283,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 }
|
||||
@@ -302,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)
|
||||
}
|
||||
@@ -359,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
|
||||
@@ -370,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]
|
||||
@@ -384,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 {
|
||||
@@ -397,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
|
||||
@@ -456,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
|
||||
}
|
||||
@@ -474,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': {
|
||||
@@ -517,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()
|
||||
@@ -585,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 {
|
||||
@@ -615,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
|
||||
@@ -637,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. */
|
||||
@@ -646,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 = []
|
||||
@@ -656,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 })
|
||||
@@ -694,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> {
|
||||
@@ -723,297 +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
|
||||
}
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
// wire consumer narrows them structurally — the same posture as every
|
||||
// other cross-wire event payload.
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
// A started sub-dispatch enters the index as a RunningToolCall — the
|
||||
// exact shape a native in-flight call renders from — under its parent
|
||||
// run_code callId; it never joins the surface flow.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const running: CodeSubCall = {
|
||||
callId: data.subCallId, name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
// Settlement replaces the running entry in place (same array position,
|
||||
// so parallel sub-calls keep their start order) with the
|
||||
// ToolResultNode form; a settle with no observed start (history window
|
||||
// cut mid-pair, or a pre-start-event log) appends directly.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
// Duration source: the paired start's time when observed; null =
|
||||
// unknown (settle-only window), matching the native tool-result
|
||||
// contract so views never present a fabricated zero duration.
|
||||
callTime: started === undefined ? null : started.time,
|
||||
content: data.content, isError: data.isError,
|
||||
callView: null, resultView: null,
|
||||
}
|
||||
this.codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
|
||||
)
|
||||
this.dispatchesRev++
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
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.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
|
||||
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
|
||||
}
|
||||
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,
|
||||
turnTimings: this.turnTimingsCache.value,
|
||||
turnEnds: this.turnEndsCache.value,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
chat,
|
||||
nodes: legacy.nodes,
|
||||
turnTimings: legacy.turnTimings,
|
||||
turnEnds: legacy.turnEnds,
|
||||
partial: legacy.partial,
|
||||
runningCalls: legacy.runningCalls,
|
||||
pending: this.pendingCache.value,
|
||||
codeDispatches: this.dispatchesCache.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,
|
||||
@@ -1039,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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Pure subagent-lineage aggregation over the retained session-list mirror.
|
||||
* Ordinary forks terminate propagation so each visible session owns only its
|
||||
* uninterrupted subagent subtree.
|
||||
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
|
||||
*/
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionSummary } from './service.ts'
|
||||
|
||||
/** Descendant counts projected for one possible parent session. */
|
||||
export interface SubagentDescendantSummary {
|
||||
/** All descendants connected through uninterrupted subagent-origin lineage. */
|
||||
readonly count: number
|
||||
/** Descendants whose exact session summary is currently running. */
|
||||
readonly runningCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every subagent descendant under each ancestor it reaches through an
|
||||
* uninterrupted subagent-origin chain. Cycles fail soft and orphan owners
|
||||
* remain harmless map keys until their summaries arrive.
|
||||
* @param summaries - retained session summaries keyed by id.
|
||||
* @returns descendant totals and running totals keyed by possible parent id.
|
||||
*/
|
||||
export function indexSubagentDescendants(
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>,
|
||||
): ReadonlyMap<SessionId, SubagentDescendantSummary> {
|
||||
const indexed = new Map<SessionId, { count: number; runningCount: number }>()
|
||||
for (const descendant of Object.values(summaries)) {
|
||||
if (descendant.origin !== 'subagent') continue
|
||||
const seen = new Set<SessionId>()
|
||||
let current: SessionSummary | undefined = descendant
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
&& !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const aggregate = indexed.get(current.parentId)
|
||||
if (aggregate === undefined) {
|
||||
indexed.set(current.parentId, {
|
||||
count: 1,
|
||||
runningCount: descendant.running ? 1 : 0,
|
||||
})
|
||||
} else {
|
||||
aggregate.count += 1
|
||||
if (descendant.running) aggregate.runningCount += 1
|
||||
}
|
||||
current = summaries[current.parentId]
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
200
packages/client/runtime/src/client/sessions/tool-call-tree.ts
Normal file
200
packages/client/runtime/src/client/sessions/tool-call-tree.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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'
|
||||
|
||||
interface ProjectedBlock {
|
||||
source: ToolCallBlock
|
||||
children: readonly ToolCallBlock[]
|
||||
value: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Fixed wire-safety ceiling for every recursive Tool call consumer. */
|
||||
export const MAX_TOOL_CALL_TREE_DEPTH = 256
|
||||
|
||||
function sameReferences<T>(
|
||||
left: readonly T[],
|
||||
right: readonly T[],
|
||||
): boolean {
|
||||
return left.length === right.length
|
||||
&& left.every((block, index) => block === right[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns Code Dispatch pairing and projects its private parent index into the
|
||||
* recursive Tool call contract exposed by conversation snapshots.
|
||||
*/
|
||||
export class ToolCallTree {
|
||||
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
|
||||
private readonly depthByCall = new Map<string, number>()
|
||||
private readonly projectedByCall = new Map<string, ProjectedBlock>()
|
||||
private revision = 0
|
||||
private nodesCache: {
|
||||
source: readonly ConversationNode[]
|
||||
revision: number
|
||||
value: readonly ConversationNode[]
|
||||
} | null = null
|
||||
private runningCache: {
|
||||
source: readonly RunningToolCall[]
|
||||
revision: number
|
||||
value: readonly RunningToolCall[]
|
||||
} | null = null
|
||||
|
||||
/** Forget all event-derived child calls before replaying a new window. */
|
||||
reset(): void {
|
||||
this.childrenByParent.clear()
|
||||
this.depthByCall.clear()
|
||||
this.projectedByCall.clear()
|
||||
this.revision++
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event when it belongs to the Code Dispatch lifecycle.
|
||||
* @param event - Session event from the current live or history window.
|
||||
* @returns Whether the event was consumed as a child-call lifecycle event.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if (event.type === 'tool/code-dispatch-start') {
|
||||
const data = event.data
|
||||
const running: RunningToolCall = {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
||||
if (!this.acceptEdge(data.parentCallId, data.subCallId)) return true
|
||||
this.childrenByParent.set(data.parentCallId, [...siblings, running])
|
||||
this.revision++
|
||||
return true
|
||||
}
|
||||
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
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
this.childrenByParent.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
this.revision++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach recursively projected children to all settled roots in a node list.
|
||||
* @param nodes - Cache-stable base conversation nodes.
|
||||
* @returns The original list when no root changed, otherwise a structurally shared list.
|
||||
*/
|
||||
projectNodes(nodes: readonly ConversationNode[]): readonly ConversationNode[] {
|
||||
if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
|
||||
return this.nodesCache.value
|
||||
}
|
||||
const projected = nodes.map((node): ConversationNode => {
|
||||
if (node.kind !== 'tool-result') return node
|
||||
return this.projectBlock(node) as ToolResultNode
|
||||
})
|
||||
const value = sameReferences(nodes, projected) ? nodes : projected
|
||||
this.nodesCache = { source: nodes, revision: this.revision, value }
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach recursively projected children to all running root calls.
|
||||
* @param calls - Cache-stable base running calls.
|
||||
* @returns The original list when no root changed, otherwise a structurally shared list.
|
||||
*/
|
||||
projectRunningCalls(calls: readonly RunningToolCall[]): readonly RunningToolCall[] {
|
||||
if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
|
||||
return this.runningCache.value
|
||||
}
|
||||
const projected = calls.map(call => this.projectBlock(call) as RunningToolCall)
|
||||
const value = sameReferences(calls, projected) ? calls : projected
|
||||
this.runningCache = { source: calls, revision: this.revision, value }
|
||||
return value
|
||||
}
|
||||
|
||||
private projectBlock(block: ToolCallBlock): ToolCallBlock {
|
||||
const children = this.childrenByParent.get(block.callId) ?? block.subCalls
|
||||
const projectedChildren = children.map(child => this.projectBlock(child))
|
||||
const childValue = sameReferences(children, projectedChildren)
|
||||
? children
|
||||
: projectedChildren
|
||||
const cached = this.projectedByCall.get(block.callId)
|
||||
if (cached?.source === block && sameReferences(cached.children, childValue)) {
|
||||
return cached.value
|
||||
}
|
||||
const value: ToolCallBlock = block.subCalls === childValue
|
||||
? block
|
||||
: { ...block, subCalls: childValue }
|
||||
this.projectedByCall.set(block.callId, {
|
||||
source: block,
|
||||
children: childValue,
|
||||
value,
|
||||
})
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an edge only when every recursive consumer can traverse it safely.
|
||||
* Host-minted ids exclude cycles and current bindings emit one level; a
|
||||
* malformed wire/history edge is consumed without hiding the rest of the session.
|
||||
*/
|
||||
private acceptEdge(parentCallId: string, subCallId: string): boolean {
|
||||
if (this.wouldCreateCycle(parentCallId, subCallId)) return false
|
||||
const pending = [{
|
||||
callId: subCallId,
|
||||
depth: (this.depthByCall.get(parentCallId) ?? 1) + 1,
|
||||
}]
|
||||
const updates = new Map<string, number>()
|
||||
for (const candidate of pending) {
|
||||
const knownDepth = updates.get(candidate.callId)
|
||||
?? this.depthByCall.get(candidate.callId)
|
||||
?? 1
|
||||
if (candidate.depth <= knownDepth) continue
|
||||
if (candidate.depth > MAX_TOOL_CALL_TREE_DEPTH) return false
|
||||
updates.set(candidate.callId, candidate.depth)
|
||||
for (const child of this.childrenByParent.get(candidate.callId) ?? []) {
|
||||
pending.push({ callId: child.callId, depth: candidate.depth + 1 })
|
||||
}
|
||||
}
|
||||
for (const [callId, depth] of updates) this.depthByCall.set(callId, depth)
|
||||
return true
|
||||
}
|
||||
|
||||
private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
|
||||
if (parentCallId === subCallId) return true
|
||||
const pending = [subCallId]
|
||||
const visited = new Set(pending)
|
||||
for (const callId of pending) {
|
||||
for (const child of this.childrenByParent.get(callId) ?? []) {
|
||||
if (child.callId === parentCallId) return true
|
||||
if (visited.has(child.callId)) continue
|
||||
visited.add(child.callId)
|
||||
pending.push(child.callId)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,355 +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 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,
|
||||
}
|
||||
}
|
||||
/* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
for (const seq of sources ?? []) {
|
||||
const candidate = eventIndex.get(seq)
|
||||
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
|
||||
summary = compactSummaryText(candidate)
|
||||
break
|
||||
}
|
||||
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
|
||||
}
|
||||
|
||||
/** 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, 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 }
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
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.
|
||||
@@ -19,7 +19,7 @@ import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never }
|
||||
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
|
||||
const ROOT_INSTANCE_KEY = 'root'
|
||||
|
||||
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
|
||||
// takes the scope key (per-session localStorage suffix) and instances expose
|
||||
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
|
||||
// these local structural faces bridge until fw-slots lifts them.
|
||||
/** Canonical type-erased store handle used by the runtime lifecycle map. */
|
||||
type EngineStoreHandle = Exclude<StoreDecl, StoreFactory>
|
||||
|
||||
/** Store handle face as the engine actually ships it (scope-key-aware create). */
|
||||
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
|
||||
|
||||
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
|
||||
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
|
||||
/** Canonical engine instance derived from the handle's create contract. */
|
||||
type EngineStoreInstance = ReturnType<EngineStoreHandle['create']>
|
||||
|
||||
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
|
||||
interface StoreAxisRecord {
|
||||
@@ -325,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
|
||||
|
||||
13
packages/client/runtime/src/client/workspaces/path.ts
Normal file
13
packages/client/runtime/src/client/workspaces/path.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolve a workspace-relative path into the Host-facing spelling used by openPath.
|
||||
* @param cwd - session workspace root, when known.
|
||||
* @param path - absolute or workspace-relative path.
|
||||
* @returns an absolute path when a workspace root is available, otherwise the original path.
|
||||
*/
|
||||
export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
|
||||
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
||||
if (cwd === undefined || cwd === '') return path
|
||||
const base = cwd.replace(/[/\\]+$/, '')
|
||||
const rel = path.replace(/^[/\\]+/, '')
|
||||
return `${base}/${rel}`
|
||||
}
|
||||
@@ -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,11 +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'
|
||||
@@ -22,17 +25,22 @@ interface Bench {
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
@@ -46,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')
|
||||
@@ -106,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,49 +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 }])
|
||||
})
|
||||
|
||||
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 } }),
|
||||
@@ -90,9 +90,22 @@ export const ev = {
|
||||
} }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
/** A compaction's log-only `compact/summary` provenance record. */
|
||||
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
|
||||
commandDone: (
|
||||
seq: number,
|
||||
commandId: string,
|
||||
kind: 'success' | 'error' = 'success',
|
||||
text?: string,
|
||||
sourceEventSeq?: number,
|
||||
): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: {
|
||||
commandId,
|
||||
kind,
|
||||
...text === undefined ? {} : { text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
} }),
|
||||
/** 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),
|
||||
@@ -127,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 })
|
||||
@@ -73,6 +73,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -81,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 }))
|
||||
@@ -139,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'] = {
|
||||
@@ -197,11 +202,28 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
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',
|
||||
@@ -168,6 +168,37 @@ describe('projectConversationHistory', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects nested dispatches onto settled and interrupted history calls', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.turnStart(0, 1),
|
||||
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
|
||||
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
|
||||
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
|
||||
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
|
||||
ev.toolResult(6, 1, 'settled', 'done'),
|
||||
ev.turnEnd(7, 1),
|
||||
ev.turnStart(8, 2),
|
||||
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
|
||||
ev.turnEnd(11, 2, 'aborted'),
|
||||
].map(event => ({ event })))
|
||||
|
||||
const settled = {
|
||||
callId: 'settled',
|
||||
subCalls: [{
|
||||
callId: 'settled:code:1',
|
||||
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
|
||||
}],
|
||||
}
|
||||
expect(projection.eventNodes).toMatchObject([settled])
|
||||
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
|
||||
expect(projection.interruptedNodes).toMatchObject([{
|
||||
callId: 'interrupted',
|
||||
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
|
||||
@@ -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: {} }
|
||||
|
||||
53
packages/client/runtime/tests/subagent-lineage.spec.ts
Normal file
53
packages/client/runtime/tests/subagent-lineage.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
parentId?: SessionId,
|
||||
origin?: 'subagent',
|
||||
running = false,
|
||||
): SessionSummary {
|
||||
return {
|
||||
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
}
|
||||
}
|
||||
|
||||
function index(...summaries: SessionSummary[]) {
|
||||
return indexSubagentDescendants(Object.fromEntries(
|
||||
summaries.map(item => [item.id, item]),
|
||||
))
|
||||
}
|
||||
|
||||
describe('indexSubagentDescendants', () => {
|
||||
it('counts every nested descendant and its exact running state', () => {
|
||||
const owner = summary('owner')
|
||||
const child = summary('child', owner.id, 'subagent')
|
||||
const grandchild = summary('grandchild', child.id, 'subagent', true)
|
||||
|
||||
const result = index(owner, child, grandchild)
|
||||
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
})
|
||||
|
||||
it('stops at ordinary forks and fails soft on cycles and missing parents', () => {
|
||||
const owner = summary('owner')
|
||||
const child = summary('child', owner.id, 'subagent', true)
|
||||
const fork = summary('fork', child.id)
|
||||
const forkChild = summary('fork-child', fork.id, 'subagent', true)
|
||||
const orphan = summary('orphan', sid('missing'), 'subagent', true)
|
||||
const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent')
|
||||
const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent')
|
||||
|
||||
const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB)
|
||||
expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
|
||||
expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 })
|
||||
expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 })
|
||||
})
|
||||
})
|
||||
89
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal file
89
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
|
||||
import {
|
||||
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
|
||||
} from '../src/client/sessions/tool-call-tree.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
|
||||
|
||||
const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
|
||||
at(seq, 'tool/code-dispatch-start', {
|
||||
parentCallId, subCallId, name: 'run_code', arguments: {},
|
||||
})
|
||||
|
||||
const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
|
||||
at(seq, 'tool/code-dispatch', {
|
||||
parentCallId, subCallId, name: 'run_code', arguments: {},
|
||||
isError: false, content: [],
|
||||
})
|
||||
|
||||
const root = (callId: string): RunningToolCall => ({
|
||||
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
|
||||
time: 1_700_000_000_000, callView: null, subCalls: [],
|
||||
})
|
||||
|
||||
describe('ToolCallTree', () => {
|
||||
it('rejects a self-parenting dispatch edge', () => {
|
||||
const tree = new ToolCallTree()
|
||||
const roots = [root('root')]
|
||||
|
||||
expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
|
||||
expect(tree.projectRunningCalls(roots)).toBe(roots)
|
||||
})
|
||||
|
||||
it('rejects a settling edge that would close a multi-call cycle', () => {
|
||||
const tree = new ToolCallTree()
|
||||
tree.apply(start(0, 'a', 'b'))
|
||||
tree.apply(start(1, 'b', 'c'))
|
||||
|
||||
expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
|
||||
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
|
||||
callId: 'a',
|
||||
subCalls: [{
|
||||
callId: 'b',
|
||||
subCalls: [{ callId: 'c', subCalls: [] }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('accepts an acyclic graph with a shared descendant', () => {
|
||||
const tree = new ToolCallTree()
|
||||
tree.apply(start(0, 'a', 'b'))
|
||||
tree.apply(start(1, 'a', 'c'))
|
||||
tree.apply(start(2, 'b', 'd'))
|
||||
tree.apply(start(3, 'c', 'd'))
|
||||
|
||||
expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
|
||||
expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
|
||||
callId: 'root',
|
||||
subCalls: [{
|
||||
callId: 'a',
|
||||
subCalls: [{ callId: 'b' }, { callId: 'c' }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects an edge beyond the recursive depth safety limit', () => {
|
||||
const tree = new ToolCallTree()
|
||||
for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
|
||||
tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
|
||||
}
|
||||
|
||||
expect(tree.apply(start(
|
||||
MAX_TOOL_CALL_TREE_DEPTH,
|
||||
`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
|
||||
`call-${MAX_TOOL_CALL_TREE_DEPTH}`,
|
||||
))).toBe(true)
|
||||
|
||||
let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
|
||||
let depth = 1
|
||||
while (current.subCalls.length > 0) {
|
||||
current = current.subCalls[0]!
|
||||
depth++
|
||||
}
|
||||
expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
|
||||
expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
|
||||
})
|
||||
})
|
||||
@@ -1,523 +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('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' },
|
||||
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
|
||||
])
|
||||
})
|
||||
|
||||
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()).toEqual([
|
||||
{ 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: '可用摘要' },
|
||||
])
|
||||
})
|
||||
|
||||
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 }])
|
||||
})
|
||||
|
||||
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('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('renders the /compact row alongside the marker its own command produced', () => {
|
||||
// The row that reports the compaction is a command node; dropping command
|
||||
// folding would delete it together with every other slash-command row.
|
||||
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', '已压缩'),
|
||||
])
|
||||
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: '已压缩' } })
|
||||
})
|
||||
})
|
||||
|
||||
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,11 +1,12 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
@@ -16,17 +17,22 @@ interface Bench {
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
@@ -24,16 +24,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"
|
||||
@@ -43,6 +49,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -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/schema-form/README.md
|
||||
README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac
|
||||
README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46
|
||||
README.md: ef1d2f9d8ce936fe60d38849f975dc8c0a08ded4
|
||||
README.zh.md: aff77bc4c31a9aaa13977480d32551536ddceadd
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the seam's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
|
||||
Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the Service Definition's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
|
||||
|
||||
## Contract
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 Service Definition 的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。
|
||||
|
||||
## 契约
|
||||
## 约定
|
||||
|
||||
编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**(`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点(object 属性按名称解析,dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会大声降级,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。
|
||||
|
||||
@@ -20,4 +20,4 @@
|
||||
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一受信任 host 时才安全;该协议没有跨信任边界使用的惰性表示。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。
|
||||
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)记录该权衡。
|
||||
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) 记录该权衡。
|
||||
|
||||
6
packages/client/schema-form/tsdown.config.ts
Normal file
6
packages/client/schema-form/tsdown.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clientLibrary } from '../tsdown.client.ts'
|
||||
|
||||
export default clientLibrary(
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
)
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
|
||||
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
|
||||
README.zh.md: a86b9e469a5632886891628267002a14588afeaa
|
||||
README.zh.md: 1df28f7b25c35333e91476e10480c22a728cdab3
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
|
||||
面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。功能套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
|
||||
|
||||
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
|
||||
局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
|
||||
|
||||
不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。
|
||||
|
||||
## Model Experience
|
||||
## 模型体验
|
||||
|
||||
无;本包是浏览器侧测试基础设施,无一物到达模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;本包既不组装也不发送 provider 请求。
|
||||
无;本包既不组装也不发送提供方请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。
|
||||
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Fixture overrides for the session behavior face: any subset of the
|
||||
@@ -45,12 +46,12 @@ export interface SessionFixture {
|
||||
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [],
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
|
||||
@@ -22,7 +22,9 @@ import { act, render, within } from '@testing-library/react'
|
||||
import type { RenderResult } from '@testing-library/react'
|
||||
import type { queries } from '@testing-library/dom'
|
||||
import type { BoundFunctions } from '@testing-library/dom'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
|
||||
@@ -40,7 +42,7 @@ export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fix
|
||||
export { makeTranslate } from './translate.ts'
|
||||
export { usePinnedBrowserLanguages } from './locale-env.ts'
|
||||
|
||||
/** Erased register face for the internal root call (the public declare seam holds the typing). */
|
||||
/** Erased register face for the internal root call (the public declaration contract holds the typing). */
|
||||
type ErasedRegister = (options: object, component: unknown) => () => void
|
||||
|
||||
/**
|
||||
@@ -80,7 +82,7 @@ export interface FeatureHandle {
|
||||
/**
|
||||
* Owner-props cell behind the auto frame: one external store the frame
|
||||
* subscribes to, so {@link SlotTestRuntime.renderSlot} and
|
||||
* {@link SlotView.update} drive React through the standard uSES seam.
|
||||
* {@link SlotView.update} drive React through the standard uSES boundary.
|
||||
*/
|
||||
class OwnerPropsCell {
|
||||
private readonly owners = new Map<string, object>()
|
||||
@@ -142,11 +144,11 @@ export class TestRoot {
|
||||
*/
|
||||
async declare<const D extends ChildrenDecl>(
|
||||
children: D,
|
||||
frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
|
||||
frame: SlotComponent<ComposedProps<'root', never, keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
|
||||
): Promise<void> {
|
||||
await this.stabilize(() => {
|
||||
// Erased hop (same pattern as SlotsService's own implementation arm);
|
||||
// the declare signature above is the typed seam.
|
||||
// the declaration signature above is the typed contract.
|
||||
this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame)
|
||||
})
|
||||
}
|
||||
@@ -218,6 +220,8 @@ export class SlotTestRuntime {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber.await()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
|
||||
}
|
||||
|
||||
@@ -267,7 +271,7 @@ export class SlotTestRuntime {
|
||||
|
||||
/**
|
||||
* Render the root slot tree through the ctx-level entry (the shell's own
|
||||
* seam): `ctx.slots.renderSlot('root', {})` under Testing Library.
|
||||
* entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
|
||||
* @returns the Testing Library view.
|
||||
*/
|
||||
renderRoot(): RenderResult {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Context } from 'cordis'
|
||||
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
|
||||
SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -134,7 +134,7 @@ interface SessionRecord {
|
||||
summary: SessionSummary
|
||||
snapshot: SnapshotStore<ConversationSnapshot>
|
||||
session: FixtureSession
|
||||
scope: Context | undefined
|
||||
scope: AgentContext | undefined
|
||||
scopeFiber: { dispose(): Promise<void> } | undefined
|
||||
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
|
||||
provideInfo: SessionProvideInfo | undefined
|
||||
@@ -144,7 +144,7 @@ interface SessionRecord {
|
||||
export interface TestSessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: FixtureSession
|
||||
readonly ctx: Context
|
||||
readonly ctx: AgentContext
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,7 +345,7 @@ export class TestSessions implements ISessions {
|
||||
* @param id - session id.
|
||||
* @returns the scoped context, or undefined for unknown sessions.
|
||||
*/
|
||||
scope(id: string): Context | undefined {
|
||||
scope(id: string): AgentContext | undefined {
|
||||
const record = this.records.get(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (record.scope === undefined) {
|
||||
@@ -368,7 +368,7 @@ export class TestSessions implements ISessions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context (service-method seam mirror).
|
||||
* Read the session scope tag off a context (service-method boundary mirror).
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
@@ -430,6 +430,14 @@ export class TestSessions implements ISessions {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Apply a confirmed preset switch into the fixture list, as production does. */
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.list.update((draft) => {
|
||||
const summary = draft.byId[sessionId]
|
||||
if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset }
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear the current selection (recorded; the production no-session flow). */
|
||||
clear(): void {
|
||||
this.calls.push({ method: 'clear', args: [] })
|
||||
|
||||
@@ -18,6 +18,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
|
||||
'trt.chat': { kind: 'single'; scope: 'session' }
|
||||
'trt.rows': { kind: 'list'; scope: 'root' }
|
||||
'trt.rows.hole': { kind: 'single'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +221,13 @@ describe('sessions', () => {
|
||||
.toMatchObject({ displayTitle: 'renamed', running: true })
|
||||
runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
|
||||
await runtime.sessions.refreshSubagents('s2' as SessionId)
|
||||
// The confirmed-switch write-back lands on the row it names and ignores
|
||||
// one the fixture never added, exactly as production's list upsert does.
|
||||
runtime.sessions.noteAgentPreset('s1' as SessionId, 'minimal')
|
||||
runtime.sessions.noteAgentPreset('missing' as SessionId, 'minimal')
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
|
||||
.toMatchObject({ agentPreset: 'minimal' })
|
||||
runtime.sessions.open('s1' as SessionId)
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
||||
@@ -419,7 +427,7 @@ describe('feature mount and disposal', () => {
|
||||
await feature.dispose()
|
||||
await feature.dispose() // idempotent
|
||||
expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
|
||||
expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined()
|
||||
expect(runtime.slots.spec('trt.rows.hole')).toBeUndefined()
|
||||
expect(runtime.ctx.get('feature-service')).toBeUndefined()
|
||||
expect(view.queryByTestId('row')).toBeNull()
|
||||
await runtime.dispose()
|
||||
|
||||
6
packages/client/test-runtime/tsdown.config.ts
Normal file
6
packages/client/test-runtime/tsdown.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clientLibrary } from '../tsdown.client.ts'
|
||||
|
||||
export default clientLibrary(
|
||||
'@deepseek-ai/dsh-client-test-runtime',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
)
|
||||
@@ -9,6 +9,7 @@
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
@@ -31,6 +32,15 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
*/
|
||||
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
||||
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
||||
|
||||
/**
|
||||
* Workspace mode replaces an empty config array with the root defaults. A
|
||||
* falsey entry instead removes this package before entry resolution.
|
||||
*/
|
||||
const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
|
||||
|
||||
/**
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
@@ -58,19 +68,85 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
|
||||
/**
|
||||
* Build the tsdown config for one UI plugin package: the node-half lib build
|
||||
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
|
||||
* the root workspace shape, so the lib half must be restated here — dropping
|
||||
* it leaves the package without lib/index.js and the host Loader cannot
|
||||
* import its node half.
|
||||
* plus the browser client bundle. Client packages emit both halves during the
|
||||
* Client pass by default; packages needed for Host reflection may opt into the
|
||||
* earlier Host pass. A package-level tsdown.config.ts REPLACES the root
|
||||
* workspace shape, so the lib half must be restated here — dropping it leaves
|
||||
* the package without lib/index.js and the host Loader cannot import its node
|
||||
* half.
|
||||
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
|
||||
* handoff and onto the injected style tags.
|
||||
* @param libEntry - node-half entries, spelled at the call site so the
|
||||
* package-invariants gate can see `lib/types/invariant.js` in each package's
|
||||
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
|
||||
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
|
||||
* @param options - phase placement, lib overrides, and companion Node configs.
|
||||
* @returns ENV-selected tsdown config for the current build face.
|
||||
*/
|
||||
export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
|
||||
return [{
|
||||
export function clientBundle(
|
||||
id: string,
|
||||
libEntry: readonly string[],
|
||||
options: ClientBundleOptions = {},
|
||||
): BuildFaceConfig {
|
||||
const lib = clientLibraryConfig(id, libEntry, options.lib)
|
||||
return ({ env }) => {
|
||||
const face = buildFace(env?.DSH_BUILD_FACE)
|
||||
const client = clientConfig(id, face === undefined
|
||||
? 'src/client/index.ts'
|
||||
: 'lib/types/client/index.js')
|
||||
const node = [lib, ...(options.companions ?? [])]
|
||||
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
|
||||
if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
|
||||
return [...node, client]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Client-only Node library during the Client pass.
|
||||
* @param id - Package name used in tsdown diagnostics.
|
||||
* @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
|
||||
* @returns ENV-selected tsdown config for the Client build face.
|
||||
*/
|
||||
export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
|
||||
const lib = clientLibraryConfig(id, libEntry)
|
||||
return clientOnly([lib])
|
||||
}
|
||||
|
||||
/**
|
||||
* Select arbitrary package-local configs only during the Client pass.
|
||||
* @param configs - Node-side configs emitted after Client tsc.
|
||||
* @returns ENV-selected tsdown config for the Client build face.
|
||||
*/
|
||||
export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
|
||||
return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
|
||||
? [SKIP_WORKSPACE_BUILD]
|
||||
: [...configs]
|
||||
}
|
||||
|
||||
interface ClientBundleOptions {
|
||||
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
|
||||
readonly hostPhase?: boolean
|
||||
/** Additional Node-side configs emitted alongside the package library. */
|
||||
readonly companions?: readonly UserConfig[]
|
||||
/** Overrides for the package's primary Node-side library config. */
|
||||
readonly lib?: UserConfig
|
||||
}
|
||||
|
||||
type BuildFace = 'host' | 'client' | undefined
|
||||
|
||||
type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
|
||||
|
||||
function buildFace(value: unknown): BuildFace {
|
||||
if (value === undefined || value === 'host' || value === 'client') return value
|
||||
throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
|
||||
}
|
||||
|
||||
function clientLibraryConfig(
|
||||
id: string,
|
||||
libEntry: readonly string[],
|
||||
overrides: UserConfig = {},
|
||||
): UserConfig {
|
||||
return {
|
||||
name: id,
|
||||
entry: [...libEntry],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
@@ -79,8 +155,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}, {
|
||||
entry: { client: 'src/client/index.ts' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function clientConfig(id: string, entry: string): UserConfig {
|
||||
return {
|
||||
name: `${id}/client`,
|
||||
entry: { client: entry },
|
||||
// Browser bundle lands next to the node half (single lib/ artifact dir;
|
||||
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
|
||||
// off — a default clean would wipe the node-half output emitted above.
|
||||
@@ -126,9 +208,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
resolveId(source: string) {
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
@@ -136,7 +218,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
name: 'dsh-css-modules-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.module.css')) return null
|
||||
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
|
||||
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
|
||||
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
@@ -148,7 +230,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
code: source,
|
||||
cssModules: { pattern: `[hash]_[local]` },
|
||||
cssModules: { pattern: '[hash]_[local]' },
|
||||
minify: true,
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
@@ -157,13 +239,13 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
return [
|
||||
`const css = ${JSON.stringify(code.toString())};`,
|
||||
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
||||
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
|
||||
` const tag = document.createElement('style');`,
|
||||
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
` tag.dataset.plugin = ${JSON.stringify(id)};`,
|
||||
` tag.dataset.pluginCss = tagId;`,
|
||||
` tag.textContent = css;`,
|
||||
` document.head.appendChild(tag);`,
|
||||
`}`,
|
||||
' tag.dataset.pluginCss = tagId;',
|
||||
' tag.textContent = css;',
|
||||
' document.head.appendChild(tag);',
|
||||
'}',
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
},
|
||||
@@ -176,8 +258,18 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
// without exposing that tree as an HTTP route.
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
footer: 'return module.exports; } });',
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an emitted JS asset import against its source-tree counterpart. */
|
||||
function sourceAssetPath(source: string, importer: string): string {
|
||||
const emitted = resolvePath(dirname(importer), source)
|
||||
if (existsSync(emitted)) return emitted
|
||||
const marker = `${sep}lib${sep}types${sep}`
|
||||
const boundary = emitted.indexOf(marker)
|
||||
if (boundary < 0) return emitted
|
||||
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user