Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
#	apps/web/tests/scaffold.ts
#	docs/module-graph.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/package.json
#	packages/client/test-runtime/README.i18n.yaml
#	packages/client/test-runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-theme/README.i18n.yaml
#	packages/client/ui-theme/README.md
#	packages/client/ui-theme/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 12:50:43 +08:00
3485 changed files with 86192 additions and 25255 deletions

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169

View File

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

View File

@@ -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,15 +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) | 编排 Tool 调用树和按 Tool 键控的视图。 |
| [`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) | 提供会话感知的命令发现与分发。 |
@@ -33,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)拥有加载链与对象层。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0
README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5

View File

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

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约类型、`AbstractApiClient` seam,以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `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 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 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 发送 unaryrespond并为 `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 下行

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ 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'
@@ -96,7 +96,7 @@ function sgr(code: number, body: string): string {
}
/**
* Terminal output sample for fixture turn 65, authored to carry every feature
* Terminal output sample for fixture turn 66, authored to carry every feature
* the terminal card draws that turn 60's two prompt rows cannot reach:
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
@@ -141,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
}
/**
* Structured grep result for the search sample (turn 66): matches grouped by
* Structured grep result for the search sample (turn 67): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
@@ -191,7 +191,7 @@ const SEARCH_MATCHES_TEXT = [
].join('\n')
/**
* Structured glob result for the search sample (turn 67): a flat path list,
* Structured glob result for the search sample (turn 68): a flat path list,
* truncated with a larger `total` so the path card shows its capped indicator.
*/
const SEARCH_PATHS_FIXTURE = [
@@ -426,19 +426,19 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
// the presenter reads to emit the two-hunk sample: the card draws one path
// header, the first hunk, a `⋯` gap, then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
// keyed registration a top-level bash row uses).
{
const turn = 64
const turn = 65
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
@@ -456,12 +456,12 @@ function buildAlphaLog(): SessionEvent[] {
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
push({
type: 'tool/code-dispatch-start',
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
})
push({
type: 'tool/code-dispatch',
data: {
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
},
})
@@ -476,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
// in_progress: this fixture chooses the parallel policy, so both surfaces
// must render a parallel plan rather than the first active item alone.
@@ -486,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] {
{ content: '跑后台构建', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
// whose prompt label is its last segment, and a non-zero exit authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
@@ -498,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] {
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
// truncated). Both ride the keyed SearchRow registration under their own
// names; the render-site fallback row is covered by the model derivation
// tests, since every fixture search tool has a keyed row. Ordered before the
// todo turn for the same standing-plan reason the bash turn is.
toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
// The render-site fallback ROW SHAPE (a read call on the generic flattened
// path) is covered by the turn 64 run_code read sub-dispatches, which
// path) is covered by the turn 65 run_code read sub-dispatches, which
// session.ts folds with resultView: null; the fallback-row + read-CARD
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
// this fixture. The read render intent is result-side only, so its pending
// call stays a generic `kind: 'read'` card; presentResult carries the
// structured window.
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
// Turns 69-70: the web render intent — a web_search whose result view carries
// Turns 70-71: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
// Both keep a generic pending call view and add the `web` card only at
// result time, which is the contract's result-only web shape. Named after
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the todo turn for the same reason turn 66 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -578,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
case 'read':
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
case 'edit':
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
if (str(args.file_path) === 'src/config.ts') {
return {
@@ -1284,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>[] = []
@@ -1347,7 +1347,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
{ 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' },
]))
@@ -1357,6 +1357,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// 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
@@ -1658,7 +1669,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
/** 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>()
@@ -1667,8 +1678,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
/** 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 = {
@@ -1989,7 +2000,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
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.
@@ -2098,7 +2109,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
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.
@@ -2107,14 +2118,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
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) => {
@@ -2153,11 +2164,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// 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(
@@ -2167,9 +2178,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
? 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 的流式回复,用于验证打字机增长与定稿切换。`,
)
@@ -2203,6 +2214,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
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 }),
@@ -2443,6 +2455,88 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
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)
@@ -2690,8 +2784,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
* 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
@@ -2748,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)
@@ -2762,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)

View File

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

View File

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

View File

@@ -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,11 +50,11 @@ 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({
@@ -63,8 +63,8 @@ export class FakeApiClient implements IApiClient {
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 }))
@@ -105,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)),
@@ -126,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'] = {
@@ -169,6 +172,22 @@ export class FakeApiClient implements IApiClient {
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) =>
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
read: (payload: { agentPreset: string }) =>
this.record('agentPreset.read', payload, Promise.resolve(ok({
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
}))),
copy: (payload: { agentPreset: string }) =>
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}

View File

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

View File

@@ -159,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(
@@ -452,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.

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
浏览器侧订阅系统 SSEServer-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 通道。

View File

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

View File

@@ -196,7 +196,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.

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd
README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060
README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110

View File

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

View File

@@ -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 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 1f8a03ad74e56b18a6985a7048f7651fd1430b38
README.zh.md: da52f1d214329237a9fa915f3d1a2e6665771754
README.md: 452f82f9c626fae5b2c2efc913eb9865fe593c3a
README.zh.md: abde13ea66fe807a0e5b85c5eb4ef8efcbeb093c

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
@@ -36,11 +36,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## The human transcript
## Conversation assembly
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path.
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target.
The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md).
## Request inspection
@@ -48,7 +52,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
## Code Mode child-call tree
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract.
## Session title projection
@@ -56,7 +60,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start
## Model retry projection
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error.
The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.
## Session forking
@@ -64,7 +68,7 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
Each resident `Session` owns a `modelSelection` snapshot containing the current `ModelSelection`, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the selection reported by the Host without replacing unchanged selection substructure.
## Model Experience
@@ -72,10 +76,10 @@ None, as the session object layer selects the provider/model route used by a lat
#### KV Cache effect
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches.

View File

@@ -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 scopehost 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 scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
@@ -36,11 +36,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering中途引导不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果claim 竞态则会返回 `queue-item-not-found`
## 面向人的 transcript文本记录
## Conversation 组装
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)
每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State折叠有关联的 update再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识
Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id并保证 update 能按日志 `seq` 回放renderer 只消费最终 Node data 与受限 Location value不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chatcompaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`Session 则把该 legacy slice 镜像到顶层 `nodes``partial``runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。
Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key结构变化才重建顺序和 Location 成员关系prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State但最多每个 animation frame 请求一次物化final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。
## 请求检查
@@ -48,7 +52,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Code Mode 子调用树
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime`ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会`callTime: null` 直接追加绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result把 Code Dispatchstart/settlement 记录折叠进该 root Context并投影为一棵 keyed 递归树child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍`callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约
## Session 标题投影
@@ -56,7 +60,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久重试提示。该提示在后续重试轮次开始前为 `scheduled`源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry``llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、TurnStep 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。
## 会话 fork
@@ -78,4 +82,4 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
- **`loader.unload` 是 stub**:它会抛出 not-implemented客户端没有从 fiber dispose 到注册与样式移除的卸载链。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘postmortem所记录的问题。
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。

View File

@@ -32,10 +32,11 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -43,6 +44,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"zustand": "~4.4.7"

View 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}`
}

View File

@@ -62,6 +62,15 @@ export interface ISessions {
* @returns completion of the current or newly started refresh.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void>
/**
* Record the composition one session now runs. The agent-preset seat calls
* this after a successful blank-session switch, so the header label moves
* with the composition instead of waiting for the next full list refresh.
* @param sessionId - the switched session.
* @param agentPreset - the preset id the host confirmed.
*/
noteAgentPreset(sessionId: SessionId, agentPreset: string): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
@@ -100,7 +109,7 @@ export interface ISessions {
*/
scope(id: SessionId): AgentContext | undefined
/**
* Read the Agent scope tag off a context (service-method seam: fetch
* Read the Agent scope tag off a context (service-method boundary: fetch
* bundles must reach scope resolution through ctx.sessions).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,27 @@ import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
import { ConversationEventRegistry } from './conversation/event-registry.ts'
import { ConversationViewRegistry } from './conversation/view-registry.ts'
export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
export { SlotsService } from './slots.ts'
export { ConversationEventRegistry } from './conversation/event-registry.ts'
export { ConversationViewRegistry } from './conversation/view-registry.ts'
export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts'
export { ConversationLocationIndex } from './sessions/conversation-location-index.ts'
export { conversationContextKey } from './contract/conversation.ts'
export type {
ChatConversationViewNode, ConversationContextReader, ConversationEventInput,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
ConversationStepDataMap,
ConversationLocation, ConversationMatch, ConversationMatchResult,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation,
} from './contract/conversation.ts'
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
@@ -51,11 +70,17 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts'
export { emptyAssistantBlock } from './sessions/partial.ts'
export { isTokenDelta } from './sessions/assistant-timing.ts'
export { contextForm, contextProvenance } from './sessions/context-provenance.ts'
export { displayFailureMessage } from './sessions/failure-display.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
@@ -71,7 +96,8 @@ export { PendingWait } from './sessions/pending.ts'
export type {
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
} from './sessions/pending.ts'
// Projection value store (session-projection RFC, push model): host-computed
// Projection value store (push model; see the session-projection subsystem
// page, docs/subsystems/session-projection.md): host-computed
// whole values per key; domains ship projection support with zero client code.
export type {
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
@@ -167,6 +193,10 @@ declare module 'cordis' {
}
interface Context {
slots: import('./slots.ts').SlotsService
/** Event-to-business-Context Definition registry. */
conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry
/** Per-target Conversation snapshot builder registry. */
conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** Read-only history sources isolated from Chat sessions and workspace state. */
@@ -184,8 +214,12 @@ export const inject = ['connection', 'typert']
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const conversation = {
events: new ConversationEventRegistry(ctx),
views: new ConversationViewRegistry(ctx),
}
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const sessions = new SessionsService(ctx, connection.api, conversation)
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
// Immutability contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
// Publication contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). Chat node and
// Location stores are stable live readers, so old snapshots are not time-point
// views. callId/approvalId stay plain string here (narrow to real brands when
// convenient).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -13,6 +15,9 @@ import type {
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
import type {
ChatConversationViewNode, ConversationTimelineSnapshot,
} from '../contract/conversation.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -191,14 +196,14 @@ export interface CompactionSummaryNode {
seq: number
/** Unix epoch ms of the checkpoint event. */
time: number
/** Summary text from the checkpoint's `compact/summary` provenance; null when
* the window cut left that provenance outside (the marker is then not expandable). */
/** Summary text from the checkpoint's cited `compact/summary` event; null when
* the window cut left that event outside (the marker is then not expandable). */
summary: string | null
/** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */
/** Seq of the loaded `compact/summary` event, or null when that event is outside the window. */
summaryEventSeq: number | null
/** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */
/** Number of surface items replaced, or null when the summary event is unavailable or malformed. */
shadowedItemCount: number | null
/** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */
/** Estimated token price of the replaced items, or null when the summary event is unavailable or malformed. */
shadowedTokenCount: number | null
}
@@ -206,7 +211,7 @@ export interface CompactionSummaryNode {
* Fallback for surface events this UI version does not know: the documented
* default arm of `SessionEventMap`, which is merge-extensible, so the
* projection's switch cannot end in `assertNever`. No event produces this node
* today — `isAppendSurfaceEvent` admits only the four types in core's
* today — `isAppendSurfaceEvent` admits only the three types in core's
* `SurfaceEventType`, and each has its own arm — and it exists so widening that
* set core-side degrades to a raw row instead of dropping the event silently.
*/
@@ -222,8 +227,8 @@ export interface UnknownSurfaceNode {
/**
* One slash-command lifecycle folded from the log-only `command/run` /
* `command/done` pair (paired by commandId, mirroring tool call↔result).
* Log-only events are not surface events, so the TranscriptAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* Log-only events are not surface events, so the command Definition indexes
* them separately and the Chat builder orders the resulting node by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/args null), and a run with no done renders as
* still executing.
@@ -311,21 +316,19 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* Input-area shape of an OPEN session, derived at snapshot assembly (the one
* place that knows the predicate — consumers switch, never re-derive):
*
* - `blank`: no activity ever (no nodes, no partial, not running, no pending
* waits, no prompt attempt) — the UI renders the blank-session guidance
* hero.
* - `engaging`: the first prompt was initiated but no content landed yet —
* the UI holds the composer through the accept → running → first-event
* frames. Entered synchronously before prompt()'s first await.
* - `active`: content exists (nodes, partial, running turn, or pending
* waits) — the ordinary conversation view.
* - `blank`: the authoritative blank bit is still set and no prompt was
* attempted — the UI renders the blank-session guidance hero.
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
*
* Monotone within a session object: blank → engaging → active, no returns.
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; bouncing back to the hero would discard the error context).
* semantics; returning to the hero would discard the error context).
* Sessions whose window is not open (`loading`/`error`) are outside phase
* jurisdiction: consumers branch on {@link ConversationSnapshot.openState}
* first (phase still reports `active`-ish facts but must not be rendered).
* first.
*/
export type ComposerPhase = 'blank' | 'engaging' | 'active'
@@ -335,10 +338,76 @@ export interface PromptError {
error: RpcError
}
/**
* Stable live per-key reader. An old ChatSnapshot observes later flushes
* through this store.
*/
export interface ChatNodeStore {
/** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
get(key: string): ChatConversationViewNode | undefined
/** @returns all currently materialized Nodes without imposing render order. */
values(): readonly ChatConversationViewNode[]
}
/**
* Stable live Location index. An old ChatSnapshot observes later membership
* changes through this index.
*/
export interface ChatLocationNodeIndex {
/** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */
getTurn(turn: number): readonly string[]
/** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */
getStep(turn: number, step: number): readonly string[]
}
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
export interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
readonly turnEnds: ReadonlyMap<number, number>
readonly partial: PartialAssistant | null
readonly runningCalls: readonly RunningToolCall[]
}
/** Incremental Chat publication with immutable order and stable live keyed readers. */
export interface ChatSnapshot {
readonly order: readonly string[]
readonly nodes: ChatNodeStore
readonly locations: ChatLocationNodeIndex
readonly timeline: ConversationTimelineSnapshot
readonly legacy: LegacyConversationSlice
}
const EMPTY_LIST: readonly never[] = []
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
order: EMPTY_LIST,
nodes: {
get: () => undefined,
values: () => EMPTY_LIST,
},
locations: {
getTurn: () => EMPTY_LIST,
getStep: () => EMPTY_LIST,
},
timeline: EMPTY_TIMELINE,
legacy: {
nodes: EMPTY_LIST,
turnTimings: new Map(),
turnEnds: new Map(),
partial: null,
runningCalls: EMPTY_LIST,
},
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
/** Final Chat target assembled from independently registered business Definitions. */
chat: ChatSnapshot
/** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */
nodes: readonly ConversationNode[]
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,8 +1,7 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
* session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, breadcrumb-route projection.
*
@@ -31,6 +30,7 @@ import { createSnapshotStore } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import type { ConversationRuntime } from './conversation-assembler.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { PendingInteractionStatus } from './pending.ts'
@@ -45,6 +45,12 @@ export interface SessionSummary {
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
/**
* Agent preset this session's agent was composed from; absent when the
* deployment composes no presets. The session header labels what the
* session actually runs rather than the deployment's current default.
*/
agentPreset?: string
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
@@ -259,16 +265,30 @@ export class SessionsService implements ISessions {
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
* @param conversationRuntime - same-pass registry instances, when runtime apply owns them.
*/
constructor(
private readonly rootCtx: Context,
api: IApiClient,
conversationRuntime?: ConversationRuntime,
) {
this.selection = createSnapshotStore<SessionSelection>(
{},
{ persist: { name: 'dsh.sessions.current' } })
const restored = this.selection.getSnapshot()
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
const conversationEvents = rootCtx.get('conversationEvents')
const conversationViews = rootCtx.get('conversationViews')
const conversation = conversationRuntime ?? (
conversationEvents === undefined || conversationViews === undefined
? undefined
: { events: conversationEvents, views: conversationViews }
)
this.manager = new SessionManager(
api,
restored.sessionId,
restored.subagentAddress,
conversation,
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
@@ -296,6 +316,25 @@ export class SessionsService implements ISessions {
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.provideChannel.currentProvideInfo
let registryRebuildQueued = false
const scheduleRegistryRebuild = (): void => {
if (registryRebuildQueued) return
registryRebuildQueued = true
queueMicrotask(() => {
registryRebuildQueued = false
this.manager.rebuildConversationRegistry()
})
}
if (conversation !== undefined) {
rootCtx.effect(() => {
const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild)
const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild)
return () => {
disposeEvents()
disposeViews()
}
}, 'sessions: conversation registry rebuild')
}
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -359,6 +398,10 @@ export class SessionsService implements ISessions {
return this.manager.refreshSubagents(parentSessionId)
}
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
this.manager.noteAgentPreset(sessionId, agentPreset)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state (new-session affordance and the workspace preselection flow).
@@ -488,7 +531,7 @@ export class SessionsService implements ISessions {
}
/**
* Read the Agent scope tag off a context. Service-method seam: fetch
* Read the Agent scope tag off a context. Service-method boundary: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
@@ -503,7 +546,7 @@ export class SessionsService implements ISessions {
* Resolve the business Session behind an Agent-scoped context — the one
* hop every scoped consumer (event listeners, per-session controllers)
* takes from ctx-space into object-space (the client mirror of host
* `agent.session`). Same service-method seam as
* `agent.session`). Same service-method boundary as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
@@ -571,7 +614,7 @@ export class SessionsService implements ISessions {
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host or selected
* prune share one predicate: listed on the host or selected
* through a retained subagent address. Breadcrumb-only ancestors remain
* summary data and do not keep scopes alive.
*/
@@ -590,7 +633,7 @@ export class SessionsService implements ISessions {
ctx,
binding,
session,
// Sources are bare observables; React binds selector hooks at its own seam.
// Sources are bare observables; React binds selector hooks at its own boundary.
provideInfo: this.provideChannel.materializeInfo(binding),
}
this.scopes.set(id, record)
@@ -629,6 +672,7 @@ export class SessionsService implements ISessions {
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
}
}
if (current !== undefined && currentAddress !== undefined) {
@@ -694,7 +738,7 @@ export class SessionsService implements ISessions {
}
/**
* One teardown for the whole per-session axis (decision 12): the scope
* One teardown for the whole per-session axis: the scope
* fiber (cascading every actx-registered effect: input shell, slash
* controller, popup, plugin stores, listeners), the session-keyed slot
* stores, and the Session instance itself — the host session log is the

View File

@@ -2,7 +2,6 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
@@ -12,27 +11,23 @@ import type {
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import { ConversationNodeAssembler } from './conversation-assembler.ts'
import type { ConversationRuntime } from './conversation-assembler.ts'
import type { ConversationEventInput, ConversationPublication } from '../contract/conversation.ts'
import type {
ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError,
} from './conversation.ts'
import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { ToolCallTree } from './tool-call-tree.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
// Browser bundles cannot value-import the host timeout library. This protocol
// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
@@ -54,24 +49,8 @@ export interface SessionOptions {
* private store (bare object-layer construction).
*/
projections?: ProjectionValueStore
}
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
const QUEUE_PREVIEW_CHARS = 200
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
function queuePreviewOf(content: readonly ContentBlock[]): string {
const flat = content
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
/** Recover complete composer text only when editing cannot discard non-text blocks. */
function queueTextOf(content: readonly ContentBlock[]): string | null {
if (!content.every(block => block.type === 'text')) return null
return content.map(block => block.text).join('')
/** Runtime registries used by this Session-owned Conversation assembler. */
conversation?: ConversationRuntime
}
/**
@@ -92,46 +71,17 @@ export class Session implements SessionFace {
private openError: RpcError | null = null
private openPromise: Promise<void> | null = null
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
* a pre-disconnect open whose history request is already doomed. Stale doOpen
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Last entered step per turn, folded from step/start for terminal error placement. */
private lastStepByTurn = new Map<number, number>()
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events and rebuilt with partial/openCalls; the transcript is
* seq-monotonic, so a plain seq merge preserves event order. */
private derivedNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
private callsRev = 0
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Exact turn timing retained from the raw window so presentation never
* infers elapsed time from transcript content. */
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
private turnTimingsRev = 0
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
/** Completed turn boundaries retained from the raw window so presentation
* actions never infer a safe fork point from transcript content alone. */
private turnEnds = new Map<number, number>()
private turnEndsRev = 0
private turnEndsCache: { rev: number; value: ReadonlyMap<number, number> } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
/** Window-derived child-call lifecycle and immutable tree projection. */
private readonly toolCallTree = new ToolCallTree()
private readonly queueMirror = new SessionQueueMirror()
/** Session-owned business Context engine over the contiguous raw window. */
private readonly conversation: ConversationNodeAssembler
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
@@ -141,8 +91,10 @@ export class Session implements SessionFace {
* engaging edge of the phase machine (see ComposerPhase).
*/
private promptAttempted = false
/** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */
private blankBit = false
/** A first accepted prompt stays in the engaging phase until its turn is observable. */
private firstPromptPendingTurn = false
/** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
private blankBit = true
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
@@ -154,8 +106,9 @@ export class Session implements SessionFace {
private subscribedLastSeq: number | null = null
/**
* Per-session projection value store (session-projection RFC, push model):
* finished whole values computed on the host, seeded by the tail page's
* Per-session projection value store (push model; see the session-projection
* subsystem page, docs/subsystems/session-projection.md): finished whole
* values computed on the host, seeded by the tail page's
* projections block and updated by `session/projection` frames under the
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
* (the useProjection resolution face); the conversation snapshot never
@@ -167,9 +120,7 @@ export class Session implements SessionFace {
readonly projections: ProjectionValueStore
private snapshotCache: ConversationSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
private readonly notifier: Notifier
/**
* Agent-scoped cordis context, bound once by SessionsService when it
* mints the scope (the client mirror of the host Agent's loopCtx). The
@@ -192,13 +143,23 @@ export class Session implements SessionFace {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.conversation = options.conversation === undefined
? new ConversationNodeAssembler(
{ entries: () => [], fallbackEntry: () => undefined },
{ entries: () => [] },
)
: new ConversationNodeAssembler(options.conversation.events, options.conversation.views)
this.notifier = new Notifier(() => {
this.conversation.flush()
this.snapshotCache = this.buildSnapshot()
})
this.snapshotCache = this.buildSnapshot()
}
/**
* Bind the Agent-scoped context minted by SessionsService (single write;
* a second bind is a wiring error and throws). Direction stays one-way at
* the seam: consumers still reach the Session via `sessions.sessionOf`,
* this binding boundary: consumers still reach the Session via `sessions.sessionOf`,
* while the Session holds its own dispatch point (host Agent.loopCtx
* mirror).
* @param actx - the agent's scoped context.
@@ -228,6 +189,7 @@ export class Session implements SessionFace {
// visible on the session area's very first frame when a caller sends
// ahead of navigation (first-send flow).
this.promptAttempted = true
if (this.blankBit) this.firstPromptPendingTurn = true
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
@@ -281,17 +243,22 @@ export class Session implements SessionFace {
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagent.interrupt`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
@@ -300,7 +267,9 @@ export class Session implements SessionFace {
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
result = address !== undefined
? (await this.api.subagents.interrupt(address)).result
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}
@@ -357,7 +326,7 @@ export class Session implements SessionFace {
return promise
}
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
this.loadingOlder = true
@@ -368,13 +337,15 @@ export class Session implements SessionFace {
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
this.conversation.prepend([], this.hasMore)
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
// Continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
this.hasMore = false
this.conversation.prepend([], false)
return
}
this.events = [...older.map(e => e.event), ...this.events]
@@ -382,8 +353,7 @@ export class Session implements SessionFace {
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
this.conversation.prepend(older.map(conversationInput), this.hasMore)
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
} finally {
@@ -395,7 +365,7 @@ export class Session implements SessionFace {
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
* the fresh generation into 'error'. */
async resync(): Promise<void> {
// The queue mirror is NOT cleared here: onConnected (which drives resync)
// races the mux frames — the fresh generation's baseline may have landed
@@ -454,15 +424,7 @@ export class Session implements SessionFace {
return
}
case 'session/queue': {
this.queued = frame.items.map(item => ({
id: item.id,
messageId: item.message.id,
placement: item.placement,
content: item.message.content,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
}))
this.queueRev++
this.queueMirror.replace(frame.items)
this.notifier.markDirty()
return
}
@@ -472,11 +434,7 @@ export class Session implements SessionFace {
// snapshot AFTER the subscribed frame on the same stream, so the
// stale mirror clears here — race-free against onConnected/resync
// timing (clearing there could wipe a baseline that already landed).
if (this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
if (this.queueMirror.reset()) this.notifier.markDirty()
return
}
case 'approval/requested': {
@@ -515,11 +473,12 @@ export class Session implements SessionFace {
*/
handleRunning(running: boolean): void {
// Turn-start conversion: a blank session never runs, so the first
// running:true proves another's first message landed (设计稿 2.2).
// running:true proves another side's first message landed.
if (running && this.blankBit) {
this.blankBit = false
this.notifier.markDirty()
}
if (running) this.firstPromptPendingTurn = false
if (this.running === running) return
this.running = running
this.notifier.markDirty()
@@ -583,7 +542,12 @@ export class Session implements SessionFace {
/** No-op because session instances remain resident. */
dispose(): void {}
// ---- 私有 ----
/** Rebuild the current window after a low-frequency Definition or view registration change. */
rebuildConversationRegistry(): void {
this.scheduleConversation(this.conversation.rebuildRegistry())
}
// ---- Private ----
/** Requested-frame arrival: the wait enters the pending map under its own key. */
private mint(wait: PendingInteraction): void {
@@ -613,7 +577,7 @@ export class Session implements SessionFace {
return
}
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
// Gap detection: baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
@@ -635,7 +599,7 @@ export class Session implements SessionFace {
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
* back into liveBuffer where nothing ever drains it — a silent drop loop.
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
@@ -644,8 +608,8 @@ export class Session implements SessionFace {
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
this.conversation.replaceWindow(entries.map(conversationInput), hasMore)
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
@@ -654,32 +618,22 @@ export class Session implements SessionFace {
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: ToolEventView): void {
private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.transcript.append(event, view)
this.handoffPendingSteering(event)
this.applyEventSideEffects(event, view)
}
/** Retire the first matching live steering occurrence when its durable message takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'user/message') return
const message = event.data
const index = this.queued.findIndex(item =>
item.placement === 'steering' && item.messageId === message.id)
if (index === -1) return
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
this.queueRev++
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
const publication = this.conversation.append({ event, view })
return queueChanged ? 'immediate' : publication
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* a seq gap -> buffer + tail-page repull instead of appending a hole (a gap is an
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its own provenance. */
* raw range, which lets Conversation Definitions correlate every recorded event between its
* ends and lets a compaction checkpoint resolve its cited summary event. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
@@ -692,15 +646,16 @@ export class Session implements SessionFace {
void this.repairGap()
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
return
}
this.notifier.markDirty()
this.scheduleConversation(this.appendLive(event, view))
}
/** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
/** Route assembler cadence into the Session's existing microtask/RAF notifier. */
private scheduleConversation(publication: ConversationPublication): void {
if (publication === 'immediate') this.notifier.markDirty()
else if (publication === 'animation-frame') this.notifier.markFrameDirty()
}
/** Resync-lite: repull the tail page and stitch the liveBuffer through the shared
* installWindow path. No openState transition — the UI keeps the current window (no loading
* flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */
private async repairGap(): Promise<void> {
@@ -721,238 +676,35 @@ export class Session implements SessionFace {
}
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)
if (data === null) {
console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
return
}
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
time: event.time,
retryState: 'scheduled',
...data,
})
this.derivedRev++
return
}
// These lifecycle events are declared by a host-only plugin whose Context
// types cannot enter the client program. ToolCallTree owns their structural
// wire narrowing, pairing, and nested snapshot projection.
if (this.toolCallTree.apply(event)) return
switch (event.type) {
case 'turn/start':
this.lastStepByTurn.set(event.data.turn, 0)
this.turnTimings.set(event.data.turn, { startTime: event.time })
this.turnTimingsRev++
return
case 'step/start':
this.lastStepByTurn.set(event.data.turn, event.data.step)
return
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
this.settleScheduledRetry('started', turn)
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
this.partial.push(chunk)
return
}
case 'assistant/message': {
if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) {
this.partial = null // finalize swaps in place (same notification batch, no flicker)
}
return
}
case 'tool/call': {
this.openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null,
subCalls: [],
})
this.callsRev++
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
return
}
case 'turn/end': {
const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0
const timing = this.turnTimings.get(event.data.turn)
if (timing !== undefined) {
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
this.turnTimingsRev++
}
this.turnEnds.set(event.data.turn, event.seq)
this.turnEndsRev++
if (event.data.reason.kind === 'aborted') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = event.data.reason.error
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: lastStep,
message: displayFailureMessage(failure),
code: failure.code,
})
this.derivedRev++
}
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
// from the logged chunks. Content-free partials are dropped outright.
if (this.partial !== null && this.partial.turn === event.data.turn) {
const { blocks } = this.partial.toPartial()
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.derivedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.derivedRev++
}
this.partial = null
}
let callOffset = 0
for (const [callId, call] of this.openCalls) {
if (call.turn !== event.data.turn) continue
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.derivedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null, subCalls: [],
})
this.derivedRev++
}
this.lastStepByTurn.delete(event.data.turn)
return
}
default:
return
}
}
/**
* Settle the newest scheduled retry, optionally restricted to its failed turn.
* @param retryState - next client projection state to publish.
* @param turn - failed turn required for cancellation; omitted for the next retry turn start.
*/
private settleScheduledRetry(
retryState: Exclude<ModelRetryNode['retryState'], 'scheduled'>,
turn?: number,
): void {
const index = this.derivedNodes.findLastIndex(node =>
node.kind === 'model-retry'
&& node.retryState === 'scheduled'
&& (turn === undefined || node.turn === turn))
if (index < 0) return
const node = this.derivedNodes[index]
/* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
if (node?.kind !== 'model-retry') return
this.derivedNodes[index] = { ...node, retryState }
this.derivedRev++
}
/** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes live handling and history replay converge on the same
* retry notices and interrupted nodes. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.lastStepByTurn.clear()
this.callsRev++
this.derivedNodes = []
this.derivedRev++
this.turnTimings = new Map()
this.turnTimingsRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.toolCallTree.reset()
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.applyEventSideEffects(event, this.views[i])
}
}
private windowTailSeq(): number | null {
const tail = this.events[this.events.length - 1]
return tail === undefined ? null : tail.seq
}
private buildSnapshot(): ConversationSnapshot {
const projected = this.transcript.nodes()
// Derived interruption nodes ride fractional seqs while retry notices keep their event seq.
// The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the
// merge on (projected reference, derivedRev) to retain identity across unrelated swaps.
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
nodes = this.derivedNodes.length === 0
? projected
: [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
}
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
}
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
const chat = (this.conversation.snapshot('chat') as ChatSnapshot | undefined) ?? EMPTY_CHAT_SNAPSHOT
const legacy = chat.legacy
return {
sessionId: this.sessionId,
nodes: this.toolCallTree.projectNodes(nodes),
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
chat,
nodes: legacy.nodes,
turnTimings: legacy.turnTimings,
turnEnds: legacy.turnEnds,
partial: legacy.partial,
runningCalls: legacy.runningCalls,
pending: this.pendingCache.value,
queue: this.queueCache.value,
queue: this.queueMirror.snapshot(),
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
// the host's no-turn sessionBlank predicate).
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
(!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
@@ -978,67 +730,18 @@ export class Session implements SessionFace {
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */
function parseRetryEventData(value: unknown): LlmRetryEventData | null {
if (value === null || typeof value !== 'object') return null
const data = value as Record<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
if (!nonNegativeSafeInteger(data.turn)
|| !nonNegativeSafeInteger(data.step)
|| typeof data.provider !== 'string'
|| data.provider.length === 0
|| typeof data.policyKey !== 'string'
|| data.policyKey.length === 0
|| !positiveSafeInteger(data.retry)
|| typeof data.delayMs !== 'number'
|| !Number.isFinite(data.delayMs)
|| data.delayMs < 0
|| data.delayMs > MAX_RETRY_DELAY_MS
|| typeof failureData.message !== 'string'
|| failureData.message.length === 0
|| typeof failureData.code !== 'string'
|| failureData.code.length === 0) return null
if (data.mode === 'normal') {
if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
} else if (data.mode === 'always') {
if ('maxRetries' in data) return null
} else {
return null
}
if (failureData.status !== undefined
&& (typeof failureData.status !== 'number'
|| !Number.isInteger(failureData.status)
|| failureData.status < 100
|| failureData.status > 599)) return null
if (failureData.providerRetryAfterMs !== undefined
&& (typeof failureData.providerRetryAfterMs !== 'number'
|| !Number.isFinite(failureData.providerRetryAfterMs)
|| failureData.providerRetryAfterMs <= 0)) return null
if (failureData.requestId !== undefined
&& (typeof failureData.requestId !== 'string'
|| failureData.requestId.length === 0)) return null
return data as unknown as LlmRetryEventData
}
function nonNegativeSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function positiveSafeInteger(value: unknown): value is number {
return nonNegativeSafeInteger(value) && value > 0
/** Convert one wire history row into the assembler's transport-neutral input. */
function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (non-command nodes,
* partial, running turn, pending waits; command lifecycle rows alone keep
* the session blank).
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics — see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

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

View File

@@ -1,5 +1,5 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from './conversation.ts'
@@ -55,13 +55,8 @@ export class ToolCallTree {
* @returns Whether the event was consumed as a child-call lifecycle event.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
if (event.type === 'tool/code-dispatch-start') {
const data = event.data
const running: RunningToolCall = {
callId: data.subCallId,
name: data.name,
@@ -78,15 +73,8 @@ export class ToolCallTree {
this.revision++
return true
}
if ((event.type as string) !== 'tool/code-dispatch') return false
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
if (event.type !== 'tool/code-dispatch') return false
const data = event.data
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true

View File

@@ -1,409 +0,0 @@
// TranscriptAdapter: the human transcript projected from the raw event window
// in LOG order. The model-visible surface deliberately shadows replaced ranges,
// so it is the wrong source for conversation a reader already saw; this adapter
// keeps every append-origin event at its own log position and contributes one
// marker node per landed compaction checkpoint. Node order is therefore
// seq-monotonic by construction — no surface fold, no padding sentinels, no
// seq === index assertion to satisfy, and no degradation branch.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Cordis-free leaf subpath (the dsh-commands/brand shape): the seam's own
// declaration of the checkpoint source, reachable as a TYPE from this program.
// The package ROOT is not — it reaches dsh-session's root, whose Context merge
// declares the HOST `sessions: SessionStore` against this program's
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
import { SteeringHistory } from './steering-history.ts'
import type { AssistantStepMetadata } from './assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
* at COMPILE time: renaming it there fails this annotation (`TS2322`). The
* import stays type-only because a value import would fail the client purity
* gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
* forbidden in a browser bundle — while an erased type never reaches it.
*/
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
/** In-window tool/call index entry used to materialize result cards. */
interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
steering: boolean,
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
case 'user/message': {
// Injected context (plugin/goal/skill-invocation source) folds to a
// context node, not a user message; only a direct human prompt is a
// user node. A compaction checkpoint never reaches here
// (isCompactCheckpoint routes it away).
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time),
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types
can be append-origin, and each has a case above; reachable only if core
adds an eligible type. */
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/**
* Whether an event is a landed compaction checkpoint — all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
* compaction seam's checkpoint plugin source, that REPLACED a surface range. A
* plugin-sourced `user/message` that appends is injected context (a
* session-reference card), not a compaction; a replacement `tool/result` is an
* in-place prune and a replacement `assistant/message` a generic rewrite, and
* both mark no boundary in the conversation.
* @param event - the raw window event.
* @returns true when the event compacted a surface range.
*/
function isCompactCheckpoint(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const source = event.data.source
return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN
&& isReplacementSurfaceEvent(event)
}
/** Whether an event contributes a node to the human transcript. */
function isTranscriptEvent(event: SessionEvent): boolean {
return isAppendSurfaceEvent(event) || isCompactCheckpoint(event)
}
/**
* Concatenated text of a `compact/summary` payload, or null when it carries no
* usable text. The payload is a `ContentBlock[]` whose union is
* merge-extensible, so a non-text block is skipped rather than discarding the
* text beside it; a payload with no text block at all falls to null through the
* empty check.
*/
function compactSummaryText(event: SessionEvent): string | null {
const summary = (event.data as unknown as { summary?: unknown }).summary
if (!Array.isArray(summary)) return null
let text = ''
for (const block of summary as readonly unknown[]) {
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue
text += candidate.text
}
return text.trim() === '' ? null : text
}
interface CompactSummaryDetails {
readonly summary: string | null
readonly shadowedItemCount: number | null
readonly shadowedTokenCount: number | null
}
/** Recover human-facing summary material from one structurally narrowed wire event. */
function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails {
const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown }
const shadowedSeqs = data.shadowedSeqs
const tokenCount = data.shadowedTokenCount
return {
summary: compactSummaryText(event),
shadowedItemCount: Array.isArray(shadowedSeqs)
&& shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0)
? shadowedSeqs.length
: null,
shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0
? tokenCount as number
: null,
}
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
* `compact/summary` event), never from the framed checkpoint payload, which is
* an instruction envelope written for the model. A window cut that left the
* provenance outside soft-falls to `summary: null` (a non-expandable marker),
* the same posture as a call-less tool result.
*/
function materializeCompaction(
checkpoint: SessionEvent,
eventIndex: ReadonlyMap<number, SessionEvent>,
): CompactionSummaryNode {
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
let summary: string | null = null
let summaryEventSeq: number | null = null
let shadowedItemCount: number | null = null
let shadowedTokenCount: number | null = null
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
const details = compactSummaryDetails(candidate)
summary = details.summary
summaryEventSeq = candidate.seq
shadowedItemCount = details.shadowedItemCount
shadowedTokenCount = details.shadowedTokenCount
break
}
return {
kind: 'compaction',
seq: checkpoint.seq,
time: checkpoint.time,
summary,
summaryEventSeq,
shadowedItemCount,
shadowedTokenCount,
}
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
export class TranscriptAdapter {
/** Window events by seq: provenance lookup for a checkpoint's summary. */
private eventIndex = new Map<number, SessionEvent>()
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
private projected: ConversationNode[] = []
private callIdx = new Map<string, CallIndexEntry>()
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
private stepTimings = new Map<string, AssistantStepMetadata>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
private readonly steeringHistory = new SteeringHistory()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so it is not a surface
* event and never joins the transcript projection; this index folds the pair
* (done settles its run's node in place) and nodes() merges the products in
* by seq. Window cuts soft-fall like tool pairs: a done with no in-window
* run still builds a node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Projection revision, bumped only when a transcript node or a command node actually
* changed, keying the nodes() result cache: an unchanged projection returns the previous
* ARRAY reference, not just cached elements — the snapshot's reference-stability contract
* (§A.9.4) starts here, and a chunk storm bumps nothing at all. */
private rev = 0
private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
/**
* Window rebuild (after open/resync/page prepend): re-index the raw window
* and re-project the transcript.
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
this.steeringHistory.reset()
const steeringSeqs = new Set<number>()
this.stepTimings = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event === undefined) continue
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
indexAssistantStepTiming(this.stepTimings, event)
}
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
}
this.projected = projected
}
/**
* Tail append (live session/event): index the event and, when it belongs to
* the transcript, extend the projection by one copy-on-write node so a
* published array never mutates. An event that changes no node (a chunk
* storm) bumps no revision, so nodes() keeps returning the same array
* reference.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
const steering = this.steeringHistory.apply(event)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
}
/**
* The current transcript node array. Same revision -> same array reference
* (memo boundary); node objects are materialized once, so an unchanged node
* keeps its identity across appends.
* @returns transcript nodes in log order, command nodes merged in by seq.
*/
nodes(): readonly ConversationNode[] {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
// Command nodes fold outside the transcript (log-only events); merge by
// seq. Both inputs are seq-ascending (log order and run-index insertion
// order are the same order), so one linear merge keeps flow order.
let nodes = this.projected
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of this.projected) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
this.nodesResult = { rev: this.rev, value: nodes }
return nodes
}
/** Materialize one transcript event against the complete current indexes. */
private materialize(event: SessionEvent, steering: boolean): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(
event,
this.callIdx,
this.resultViews.get(event.seq) ?? null,
steering,
this.stepTimings,
)
}
/**
* Fold one command lifecycle event into its node (run mints, done settles in
* place; done-only soft-falls).
* @returns whether the command index changed, so callers can bump the revision.
*/
private indexCommand(event: SessionEvent): boolean {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args?: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null,
})
return true
}
if ((event.type as string) !== 'command/done') return false
const data = event.data as unknown as {
commandId: CommandId
kind: 'success' | 'error'
text?: string
sourceEventSeq?: number
}
const run = this.commandIdx.get(data.commandId)
const sourceEventSeq = data.kind === 'success'
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
? data.sourceEventSeq as number
: undefined
const outcome = {
kind: data.kind,
...data.text === undefined ? {} : { text: data.text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
}
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return true
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}

View File

@@ -4,7 +4,7 @@
* the load-time validations, and the unload cascade). This layer owns what
* needs the runtime: the 'slots/changed' event bridge, register and
* declaration injection through the caller's ctx.effect (fiber unload
* collects both), the renderer install seam (install()/renderSlot('root') +
* collects both), the renderer installation contract (install()/renderSlot('root') +
* the SlotRendererHost face), and the store INSTANCE axis — handle x scope
* key -> create/cache, dropped with the last holding entry, session instances
* cleared (with persisted state) on scope death.
@@ -320,7 +320,7 @@ export class SlotsService extends Service {
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
const scope = (this._core.specDynamic(options.name) as SlotSpec<SlotEntryDef>).scope
this._acquire(store, scope)
}
let disposed = false

View File

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

View File

@@ -4,12 +4,14 @@
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as RuntimeClient from '../src/client/index.ts'
import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
import { Session } from '../src/client/sessions/session.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
@@ -52,7 +54,7 @@ describe('runtime client apply', () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
// (the SlotMap 'root' merge lives here).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
const workspaces = bench.ctx.get('workspaces')
@@ -112,6 +114,31 @@ describe('runtime client apply', () => {
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
const bench = await mount()
const sessions = bench.ctx.get('sessions') as SessionsService
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-registry' as never,
payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never,
})
await flushMicrotasks()
expect(sessions.binding('s-registry' as never)).toBeDefined()
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
const definition: ConversationNodeDefinition<null> = {
kind: 'registry-probe',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
bench.ctx.conversationEvents.register(definition)
await flushMicrotasks()
expect(rebuild).toHaveBeenCalledOnce()
rebuild.mockRestore()
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))

View File

@@ -1,52 +0,0 @@
/**
* Behavioral half of the compaction-checkpoint drift trap.
*
* `TranscriptAdapter` pins its plugin literal to the seam's own declaration at
* compile time through a type-only import of `dsh-compact/checkpoint`, so
* renaming the seam's plugin already fails `tsc`. This spec covers the same
* drift from the other side — end to end through the adapter, driving it with a
* checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and
* checking the seam's own predicate agrees. Both values come from the
* cordis-free checkpoint leaf, so the client test program never loads the host
* package root or its `Context` merges.
*/
import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
/** A replacement user message stamped with the seam's own canonical source. */
function canonicalCheckpoint(seq: number): SessionEvent {
return {
type: 'user/message',
seq,
time: 1_700_000_000_000 + seq,
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}),
} as unknown as SessionEvent
}
describe('compaction checkpoint recognition', () => {
it('recognizes a checkpoint carrying the seam-canonical source', () => {
const adapter = new TranscriptAdapter()
adapter.reset([canonicalCheckpoint(1)])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it("agrees with the seam's own predicate on the source it recognizes", () => {
// Both sides answer the same question about the same value: if the seam
// renames its plugin, this equality is what breaks.
const checkpoint = canonicalCheckpoint(1)
expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true)
expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' })
})
})

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

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

View File

@@ -54,12 +54,12 @@ export const ev = {
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
}),
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
@@ -105,7 +105,7 @@ export const ev = {
...text === undefined ? {} : { text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
} }),
/** A compaction's log-only `compact/summary` provenance record. */
/** A compaction's log-only `compact/summary` record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {
summary: text(summary),
@@ -140,7 +140,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
]
}
/** Wrap raw events as view-less history entries (the wire shape history now returns). */
/** Wrap raw events as view-less history entries (the wire shape history returns). */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}

View File

@@ -3,7 +3,7 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -64,7 +64,7 @@ export class FakeApiClient implements IApiClient {
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
readonly defaultModel: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
@@ -82,7 +82,7 @@ export class FakeApiClient implements IApiClient {
failures: [],
}))
onSelectModel: (payload: { provider: string; model: string }) =>
Promise<RpcResponse<{ selected: ModelTarget }>> =
Promise<RpcResponse<{ selected: ModelSelection }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {
@@ -204,6 +208,22 @@ export class FakeApiClient implements IApiClient {
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) =>
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
read: (payload: { agentPreset: string }) =>
this.record('agentPreset.read', payload, Promise.resolve(ok({
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
}))),
copy: (payload: { agentPreset: string }) =>
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,567 +0,0 @@
/**
* TranscriptAdapter over the raw append-only window: log-ordered projection of
* append-origin events, one marker per landed compaction, replacement copies
* hidden, command-lifecycle folding, node/array identity, call pairing, and
* host-provided wire views.
*/
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
/** A `compact/summary` provenance event (log-only, no surfaceOp). */
function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
return at(seq, {
type: 'compact/summary',
data: {
summary,
shadowedRange: { start: 1, end: 3 },
shadowedSeqs: [1, 3],
shadowedTokenCount: 100,
provider: 'fake',
model: 'compact-1',
},
})
}
/** The replacement user message a compaction backend lands (the checkpoint). */
function checkpoint(
seq: number,
summarySeq: number,
{ start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
start?: number
end?: number
sourceEventSeqs?: number[]
} = {},
): SessionEvent {
return at(seq, {
type: 'user/message',
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs,
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
})
}
describe('TranscriptAdapter', () => {
it('projects a window starting past seq 0 at its own log positions', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (materialize-once identity)', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
const first = adapter.nodes()
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second).toHaveLength(3)
expect(second[0]).toBe(first[0])
expect(second[1]).toBe(first[1])
expect(second).not.toBe(first) // a real change swaps the array
})
it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
const settled = adapter.nodes()
adapter.append(ev.chunkStart(6, 1))
expect(adapter.nodes()).toBe(settled)
adapter.append(ev.chunkText(7, 1, '流式'))
expect(adapter.nodes()).toBe(settled)
adapter.append(ev.assistant(8, 1, '流式完成'))
const finalized = adapter.nodes()
expect(finalized).not.toBe(settled)
expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
})
it('materializes every append-origin variant with field mapping', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
})
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(7, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
it('identifies steering on the live append path', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: 'live steer' }],
source: { kind: 'user' },
})
adapter.reset([])
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }))
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }))
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
})
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
const adapter = new TranscriptAdapter()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
const context = createUserMessage({
content: [{ type: 'text', text: 'context' }],
source: { kind: 'plugin', plugin: 'test' },
})
adapter.reset([
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, inserted: [queued],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [canceled],
} }),
at(4, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
} }),
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
at(6, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [context],
} }),
at(7, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
])
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('materializes a skill-invocation injection as a named instructions context', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
source: { kind: 'user' },
}) }),
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
}) }),
])
const nodes = adapter.nodes()
// The gesture stays a user bubble; the injected body folds to a context
// row named after the skill, presented as instructions.
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
expect(nodes[1]).toMatchObject({
provenance: { role: 'inject', label: 'hidden-demo' },
form: 'instructions',
})
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
// has not admitted contribute no node.
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 1),
at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
compactSummary(2),
ev.user(3, '唯一的一条'),
ev.turnEnd(4, 1),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
})
describe('compaction markers', () => {
it('keeps the original messages and full tool output, hiding replacement copies', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '原始问题'),
ev.assistant(1, 0, '原始回答'),
ev.toolCall(4, 0, 'c1', 'echo', '{}'),
ev.toolResult(5, 0, 'c1', '完整工具输出'),
// A pruned tool/result copy: rewrites one node for the model, marks nothing.
at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
turn: 0, step: 0,
message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
} }),
compactSummary(7),
checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
// A regenerated assistant/message: also a silent model-only rewrite.
at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '通用 replacement 副本' }],
source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
}),
} }),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([
['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
])
expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
})
it('adds one marker per landed compaction, in log order', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, 'a'),
compactSummary(1, [{ type: 'text', text: 'first' }]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
ev.user(3, 'b'),
compactSummary(4, [{ type: 'text', text: 'second' }]),
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
])
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
{
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
// The pagination hole A1 left open: quota is no longer spent on
// replacement copies, so a page can carry a checkpoint whose
// surfaceOp.start lies below the window head. The old surface fold threw
// on the missing range and degraded with a console error; a log-ordered
// projection has no range to resolve.
const adapter = new TranscriptAdapter()
const noise = { error: console.error, warn: console.warn }
const logged: unknown[] = []
console.error = (...args: unknown[]) => logged.push(args)
console.warn = (...args: unknown[]) => logged.push(args)
try {
adapter.reset([
compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
ev.user(82, '压缩后的新问题'),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
} finally {
console.error = noise.error
console.warn = noise.warn
}
expect(logged).toEqual([])
})
it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
// A session-reference card carries the same plugin source shape; only the
// replacement marker makes an event a checkpoint.
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '注入的上下文' }],
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'compact' },
form: 'instructions',
}])
})
it('ignores a foreign plugin s replacement user/message', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '保留'),
at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
content: [{ type: 'text', text: '别的插件重写' }],
source: { kind: 'plugin', plugin: 'not-compact' },
}) }),
])
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
})
it.each([
['absent provenance', undefined],
['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
['an empty summary array', compactSummary(1, [])],
['a non-array summary', compactSummary(1, 'plain string')],
])('degrades %s to a non-expandable marker', (_label, summary) => {
const adapter = new TranscriptAdapter()
adapter.reset([
...(summary === undefined ? [] : [summary]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toMatchObject([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
])
})
it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
// ContentBlock is merge-extensible and the payload type is ContentBlock[],
// so a non-text block must not discard recoverable text beside it.
const adapter = new TranscriptAdapter()
adapter.reset([
compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
it('leaves the summary null when the checkpoint records no provenance at all', () => {
const adapter = new TranscriptAdapter()
adapter.reset([at(2, {
type: 'user/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
data: createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
})])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it('skips a non-summary provenance seq before reaching the real one', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '被压缩的问题'),
at(1, { type: 'compact/start', data: { turn: 0 } }),
compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
])
expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
})
it('resolves the summary once an older page supplies the provenance', () => {
const adapter = new TranscriptAdapter()
const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
adapter.reset([landed])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
})
it('creates the marker on the live append path', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'))
adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
const nodes = adapter.nodes()
// The compacted history is still there; the marker is one more row after it.
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
})
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes a tool-result error field when present', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
turn: 0, step: 0,
message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }),
error: { name: 'Boom', code: 'boom' },
} }),
])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('attaches wire views to the materialized result node', () => {
const adapter = new TranscriptAdapter()
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
adapter.reset([
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
], [callView, resultView] as never)
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
})
})
it('attaches views on the live append path and defaults to null without views', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
callView: { title: '回声' }, resultView: null,
})
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new TranscriptAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
expect(adapter.nodes()[0]).toMatchObject({
kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' },
})
})
describe('command lifecycle nodes', () => {
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
})
it('represents command input omitted by the host as null', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
expect(adapter.nodes()[0]).toMatchObject({
kind: 'command', name: 'feedback', args: null, outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
expect(adapter.nodes()[0]).toMatchObject({
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new TranscriptAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'))
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.nodes().find(n => n.kind === 'command')
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
// Settlement replaced the node object rather than mutating the published one.
expect(settled).not.toBe(running)
})
it('tails command nodes whose seq is past every transcript node', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
})
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '压缩前的问题'),
ev.commandRun(1, 'cmd-compact', 'compact'),
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
expect(nodes[1]).toMatchObject({
name: 'compact',
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
})
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
})
})
describe('assistant timing', () => {
const base = 1_700_000_000_000
it('derives step timing across a window rebuild (start + first token + completion)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.turnStart(0, 0),
ev.user(1, '问'),
ev.stepStart(2, 0),
ev.chunkStart(3, 0),
ev.chunkText(4, 0, '答'),
ev.chunkText(5, 0, '案'),
ev.assistant(6, 0, '答案'),
ev.turnEnd(7, 0),
])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
})
})
it('derives the same timing on the live append path, first token winning once', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.user(0, '问')])
adapter.append(ev.stepStart(1, 0))
adapter.append(ev.chunkText(2, 0, '首'))
adapter.append(ev.chunkText(3, 0, '次'))
adapter.append(ev.assistant(4, 0, '首次'))
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
})
})
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
expect(assistant).toMatchObject({
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
})
})
})
})

View File

@@ -1,5 +1,5 @@
/**
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/

View File

@@ -27,16 +27,22 @@
"path": "../../host/apiproxy"
},
{
"path": "../../ui/commands"
"path": "../../interaction/commands"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../compact/compact"
},
{
"path": "../../session-projection/session-projection"
"path": "../../session/session-projection"
},
{
"path": "../../session-title/session-title"
"path": "../../session/session-title"
},
{
"path": "../../llm/llm"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/schema-form/README.md
README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac
README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46
README.md: ef1d2f9d8ce936fe60d38849f975dc8c0a08ded4
README.zh.md: aff77bc4c31a9aaa13977480d32551536ddceadd

View File

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

View File

@@ -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) 记录该权衡。

View File

@@ -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: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
README.zh.md: e292c57c21dde1f7639ce37ee9b65930c6d153ea
README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310

View File

@@ -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``stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`生产面一旦改形测试台在编译期即断而非静默漂移。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``stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`生产面一旦改形测试台在编译期即断而非静默漂移。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` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。

View File

@@ -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,6 +46,7 @@ export interface SessionFixture {
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
return {
sessionId,
chat: EMPTY_CHAT_SNAPSHOT,
nodes: [],
turnTimings: new Map(),
turnEnds: new Map(),

View File

@@ -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,
@@ -42,7 +44,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
/**
@@ -82,7 +84,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>()
@@ -144,11 +146,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)
})
}
@@ -220,6 +222,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)
}
@@ -269,7 +273,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 {

View File

@@ -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: [] })

View File

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

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c
README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd

View File

@@ -0,0 +1,67 @@
# dsh-client-ui-agent-preset
English | [中文](README.zh.md)
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files.
## Why it is a new-session preference
A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with.
## The new-session chip
A second surface, beside the workspace picker on the new-session screen. It sits there rather than in the composer because that is where the choice is still open: a control that spends most of its life disabled belongs on the screen where it still works.
The chip opens on the deployment default and its pick is *staged* — the screen precedes the session it would apply to. The stage reaches a session when one becomes current and is still blank, which covers both the session the workspace connect created and the blank one it reused; riding along on `sessions.create` would miss the second. It is spent on first use, so the next new session opens on the default again, exactly like the workspace picker beside it.
A session that has started is refused rather than queued: the host answers `agent-preset-locked`, and the stage is dropped instead of waiting for a session that will never accept it.
## The session-header label
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads.
## What it reads and writes
Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation.
A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted.
The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it.
## The management section
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions.
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure.
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it.
## When the surfaces are absent
A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails.
## Model Experience
Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
#### KV Cache effect
No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition.
## Known Limitations and Deferred Work
- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source.
- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself.
- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit.

View File

@@ -0,0 +1,67 @@
# dsh-client-ui-agent-preset
[English](README.md) | 中文
agent preset 的各个表层General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip用于选择**下一个会话**的 preset会话标题旁的一个只读标签以及一个设置页分区用于管理名单——复制、删除、默认值以及通往 preset 自身文件的入口。
## 为什么它是"新建会话"的偏好设置
会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
## 新建会话 chip
第二个表层,位于新建会话界面上、工作区选择器旁边。它落在这里而非 composer是因为这里才是选择仍然成立的地方一个大部分时间处于禁用状态的控件属于它仍然可用的那个界面。
chip 以部署默认值打开,其选择是**暂存**的——该界面先于它要应用到的会话存在。暂存值会在某个会话成为当前会话且仍为空白时抵达该会话;这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。暂存值一经使用即被清空,因此下一个新会话重新以默认值打开——与它旁边的工作区选择器完全一致。
已经开始的会话会被直接拒绝而非排队:宿主返回 `agent-preset-locked`,暂存值随之丢弃,而不是去等一个永远不会接受它的会话。
## 会话标题旁的标签
第三个表层,位于会话标题旁:**本会话**所运行的 preset作为静态装饰呈现。在那里放一个控件等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。
## 它读什么、写什么
选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。
本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。
本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。
## 管理分区
第四个表层,独立的设置页(`settings.section`id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id它将成为目录名所以必须当场取好、事后无法更改与一个可选显示名跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作文件正是修复它的地方而删除正是清掉幽灵目录组装文件被手动删除、目录仍占着 id的方式损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器通用设置行与新会话 chip则完全不列出损坏的 preset它们选的是下一个会话的组装列出无法组装的选项只会把失败推迟到会话启动。
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
`agentPreset.read``copy``openDocument``remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志而局域网客户端的选择器需要它。
## 何时不显示这些表层
未组装任何 preset 的部署返回空名单本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。
## Model Experience
Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
#### KV Cache effect
没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。
## Known Limitations and Deferred Work
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。
- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。
- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed``connection/reset` 时重读,而非每次磁盘编辑。

View File

@@ -0,0 +1,74 @@
{
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,23 @@
/* Session-header agent-preset label: static chrome, never a control. */
.label {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 180px;
padding: 0 8px;
height: 22px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary);
font-size: 12px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon {
flex: none;
opacity: 0.7;
}

View File

@@ -0,0 +1,62 @@
/**
* The session header's agent-preset label.
*
* Read-only by construction: a session's composition is fixed once its
* conversation starts, and a header is only worth reading after that. Offering
* a control here would promise a switch the host refuses; naming what the
* session runs is the honest affordance, and the choice itself lives on the
* new-session screen ({@link AgentPresetSeat}).
*/
import { useEffect } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSettingsState } from './settings-store.ts'
import css from './AgentPresetLabel.module.css'
/** Registration-side business face for the header label. */
export interface AgentPresetLabelInjected {
hooks: {
/** Roster snapshot bound by the renderer as useAgentPresets. */
agentPresets: SnapshotStore<AgentPresetSettingsState>
}
/** Read the roster, so the label can show a name rather than an id. */
load: () => Promise<void>
}
/** Full component props. */
export type AgentPresetLabelProps =
PropsRuntime<'conversation.session.header.actions'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetLabelInjected>
/**
* Render this session's agent-preset name beside its title.
* @param props - composed slot props.
* @returns the label, or null when the session records no preset.
*/
export function AgentPresetLabel({
sessionId, useSessions, useAgentPresets, load, t,
}: AgentPresetLabelProps) {
const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
const options = useAgentPresets(state => state.options)
useEffect(() => {
// Deployments that compose no presets never label anything, so the roster
// is only worth a request once a session reports one.
if (preset !== undefined) void load()
}, [preset, load])
if (preset === undefined) return null
const option = options.find(entry => entry.id === preset)
return (
<span className={css.label} title={option?.description ?? t('headerHint')}>
<IconThinkOutline16 className={css.icon} />
{option?.name ?? preset}
</span>
)
}

View File

@@ -0,0 +1,60 @@
/* Agent-preset row: title/description plus the preset selector pill. */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,89 @@
/**
* Agent-preset preference row: the preset new sessions are composed from.
* A running session keeps the composition it began with, so this row never
* disturbs work in progress.
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { AgentPresetSettingsState } from './settings-store.ts'
import type { AgentPresetSettingsKey } from './locales.ts'
import { PresetMenu } from './PresetMenu.tsx'
import css from './AgentPresetRow.module.css'
/** Registration-side business face for the host-backed preference. */
export interface AgentPresetRowInjected {
hooks: {
/** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */
agentPreset: SnapshotStore<AgentPresetSettingsState>
}
/** Load the roster when the row first renders. */
load: () => Promise<void>
/** Persist one preset as the default for later sessions. */
select: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetRowProps =
PropsRuntime<'settings.general.item'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetRowInjected>
/**
* Render the new-session agent-preset selector.
* @param props - composed slot props.
* @returns the row, or null when the deployment composes no presets.
*/
export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) {
const state = useAgentPreset(snapshot => snapshot)
const [open, setOpen] = useState(false)
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (state.writable && state.status !== 'unavailable') return
setOpen(false)
}, [state.status, state.writable])
// A deployment that composes no presets has nothing to choose between, and
// every session shares the host composition — the row simply does not exist.
if (state.status === 'unavailable') return null
const busy = state.status === 'loading' || state.status === 'saving'
// The metadata name is what every other surface shows — the id is the
// addressing, not the label. A preset that names itself nothing falls back
// to its id, which is then all there is to say about it.
const chosen = state.options.find(option => option.id === state.currentValue)
const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue)
const description: string = state.error ?? t('description')
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('title')}</div>
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
</div>
<PresetMenu
options={state.options}
selectedId={state.currentValue}
label={label}
userTrustLabel={t('userTrust')}
buttonClassName={css.selector}
chevronClassName={css.chevron}
disabled={busy || !state.writable || state.options.length === 0}
open={open}
onOpenChange={setOpen}
onSelect={(id) => { void select(id) }}
/>
</div>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Agent-preset row copy. */
'settings.agentPreset': AgentPresetSettingsKey
}
}

View File

@@ -0,0 +1,64 @@
/* Agent-preset chip on the new-session screen, beside the workspace picker.
Geometry mirrors HeroShell's .workspace so the two read as one row. */
.seat {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 240px);
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.seat:not(:disabled):hover,
.seat[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.seat:disabled {
cursor: default;
color: var(--dsw-alias-label-quaternary);
}
.seatIcon {
flex: none;
color: var(--dsw-alias-label-primary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);
}
/* Menu rows carry the name over its description: the id alone never said what
a preset does, which is why the metadata exists. */
.item {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 280px;
}
.itemName {
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
.itemDesc {
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-caption);
white-space: normal;
}

View File

@@ -0,0 +1,100 @@
/**
* The agent-preset chip on the new-session screen, beside the workspace
* picker.
*
* It lives here rather than in the composer because the choice is only
* available before a conversation starts: once a turn has run, the session's
* history was produced under that preset's tools and the host refuses to swap
* them. A control that spends most of its life disabled belongs on the screen
* where it still works.
*
* The menu opens on the staged choice, which starts as the deployment default.
* Picking stages; the choice reaches a session when one becomes current.
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSeatState } from './seat-store.ts'
import css from './AgentPresetSeat.module.css'
/** Registration-side business face for the hero chip. */
export interface AgentPresetSeatInjected {
hooks: {
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
}
/** Read the roster when the chip first renders. */
load: () => Promise<void>
/** Stage one preset for the next session. */
select: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSeatProps =
PropsRuntime<'conversation.hero.agentPreset'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetSeatInjected>
/**
* Render the new-session agent-preset chip.
* @param props - composed slot props.
* @returns the chip, or null when the deployment composes no presets.
*/
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
const state = useAgentPresetSeat(snapshot => snapshot)
const [open, setOpen] = useState(false)
useEffect(() => {
void load()
}, [load])
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (state.options.length === 0 || state.current === '') return null
const chosen = state.options.find(option => option.id === state.current)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({
id: option.id,
// Name and description together: the id alone never said what a
// preset does, which is the whole reason the metadata exists.
label: (
<span className={css.item}>
<span className={css.itemName}>{option.name ?? option.id}</span>
<span className={css.itemDesc}>{option.description ?? t('noDescription')}</span>
</span>
),
}))}
selectedId={state.current}
onSelect={(id) => {
setOpen(false)
void select(id)
}}
align="start"
portal
anchor={(
<button
type="button"
className={css.seat}
aria-haspopup="menu"
aria-expanded={open}
title={state.error ?? t('seatHint')}
disabled={state.busy}
onClick={() => { setOpen(value => !value) }}
>
<IconThinkOutline16 className={css.seatIcon} />
{chosen?.name ?? state.current}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
)
}

View File

@@ -0,0 +1,388 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
/* Cards, not rows: a preset is a thing you pick, and the description is the
part that tells them apart — a row would bury it beside the actions. */
.group {
display: flex;
flex-direction: column;
gap: 10px;
}
.groupHead {
margin: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--dsw-alias-label-tertiary);
}
.cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
/* Every row the same height, so a short description does not make its card
shorter than the one beside it. */
grid-auto-rows: 1fr;
gap: 12px;
}
.card {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-layer-3);
transition: border-color .16s, background .16s;
}
.card:hover:not(.cardActive) {
border-color: var(--dsw-alias-label-dimmed);
}
/* The default preset reads as selected, not merely badged. */
.cardActive {
background: var(--dsw-alias-bg-layer-2);
border-color: var(--dsw-alias-label-primary);
}
/* A broken preset reads as damaged before anything else: the card cannot be
picked, so its border carries the warning the disabled body cannot. */
.cardBroken {
border-color: var(--dsw-alias-state-error-primary);
}
.cardBroken:hover {
border-color: var(--dsw-alias-state-error-primary);
}
.brokenBadge {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-bg-layer-3);
}
/* The discovery-reported reason, verbatim: it names the file and the fix. */
.cardBrokenReason {
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-state-error-primary);
overflow-wrap: anywhere;
}
/* The card body is the control that picks the preset. */
.cardMain {
flex: 1;
appearance: none;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px 12px;
border-radius: 12px 12px 0 0;
}
.cardMain:disabled {
cursor: default;
}
.cardMain:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -2px;
}
.cardHead {
display: flex;
align-items: center;
gap: 8px;
}
.cardName {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.badge,
.inUse {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
}
.badge {
border: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
}
.inUse {
margin-left: auto;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.cardDesc {
font-size: 13px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
flex: 1;
min-height: 42px;
}
.cardId {
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--dsw-alias-label-dimmed);
}
.cardFoot {
display: flex;
justify-content: flex-end;
gap: 2px;
padding: 6px 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
/* Icon-only actions: the label rides `title` so the row stays quiet until
someone reaches for it. */
.iconButton {
position: relative;
appearance: none;
border: 0;
border-radius: 7px;
padding: 6px;
background: none;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
display: inline-flex;
align-items: center;
}
.iconButton:disabled {
opacity: 0.4;
cursor: default;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
.iconButton:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: -1px;
}
.iconButton::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 8px;
border-radius: 6px;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
font-size: 11px;
line-height: 17px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity .12s;
}
.iconButton:hover::after,
.iconButton:focus-visible::after {
opacity: 1;
}
.iconDanger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
/* Where the host has no desktop opener, the row answers with the directory
itself — text to copy, not a control that would spawn into nothing. */
.revealedPath {
margin: 0;
padding: 6px 16px 10px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
display: flex;
gap: 6px;
align-items: baseline;
}
.revealedPath code {
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
color: var(--dsw-alias-label-secondary);
user-select: all;
overflow-wrap: anywhere;
}
.revealedPathLabel {
white-space: nowrap;
}
.secondaryButton {
border: none;
border-radius: 7px;
padding: 5px 8px;
background: none;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.secondaryButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
.secondaryButton:disabled {
opacity: 0.5;
cursor: default;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.input {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
font: inherit;
font-size: 13px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
.input:focus {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input::placeholder {
color: var(--dsw-alias-label-dimmed);
}
.dialog {
width: min(560px, 100%);
}
.dialogFields {
display: flex;
flex-direction: column;
gap: 12px;
}
/* A shipped composition can be long; the dialog scrolls it rather than grow. */
.viewerCode {
margin: 0;
padding: 12px;
max-height: min(52vh, 480px);
overflow: auto;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12.5px;
line-height: 1.5;
white-space: pre;
tab-size: 2;
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.error {
margin: 0;
font-size: 12px;
color: var(--dsw-alias-state-error-primary);
}
.deleteDialog {
width: min(480px, 100%);
}
.deleteConfirm:not(:disabled) {
border-color: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
}
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* The conversational authoring entry, after the card grid in the spot the
create button vacated. Dashed like the Models page's add affordances: it
reads as a place a preset will appear, not a command. */
.creatorButton {
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
font: inherit;
font-size: 13px;
background: none;
color: inherit;
cursor: pointer;
}
.creatorButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
.creatorButton:disabled {
opacity: 0.5;
cursor: default;
}

View File

@@ -0,0 +1,372 @@
/**
* Agent-presets settings section: the roster as cards, a copy dialog as the
* only way a preset is created, and a read-only viewer over the shipped
* compositions.
*
* The browser edits no composition text — a shipped preset opens read-only to
* be READ (it is the known-good composition a copy starts from), and a custom
* preset is edited in its own files, which is what the location action leads
* to. Deleting a preset leaves running sessions alone: a composition is
* mounted once at session creation and nothing re-reads the file.
*/
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import {
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './AgentPresetSection.module.css'
/** Registration-side business face for the management section. */
export interface AgentPresetSectionInjected {
hooks: {
/** Page snapshot bound by the renderer as useAgentPresetSection. */
agentPresetSection: SnapshotStore<AgentPresetSectionState>
}
/** Read the roster; called once when the section first renders. */
load: () => Promise<void>
/** Open one shipped preset's composition in the read-only viewer. */
view: (id: string) => Promise<void>
/** Close the read-only viewer. */
closeView: () => void
/** Open the copy dialog over one preset. */
beginCopy: (from: string) => void
/** Close the copy dialog, discarding the draft. */
cancelCopy: () => void
/** Name the preset the copy creates. */
setCopyId: (id: string) => void
/** Name the copy's display name. */
setCopyName: (name: string) => void
/** Submit the copy. */
confirmCopy: () => Promise<void>
/** Open one preset's directory, or reveal its path where there is no desktop. */
openLocation: (id: string) => Promise<void>
/**
* Stage the self-referential preset and start a new session on it — the
* guided way to author a preset, beside copying. Absent when the surface
* is composed without the conversation flow to land the session in.
*/
startCreatorDraft?: () => void
/** Ask for delete confirmation, or dismiss it with null. */
confirmDelete: (id: string | null) => void
/** Delete the preset awaiting confirmation. */
remove: () => Promise<void>
/** Make one preset the default for sessions created later. */
makeDefault: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetSectionInjected>
/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */
interface CopyDialogProps {
state: AgentPresetSectionState
t: (key: AgentPresetSettingsKey) => string
actions: Pick<AgentPresetSectionInjected,
'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'>
}
function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
const draft = state.copy
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker))
return (
<Modal
open={draft !== null}
onClose={() => { actions.cancelCopy() }}
title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`}
closeLabel={t('close')}
description={t('copyIntro')}
className={css.dialog as string}
footer={(
<>
<Button
variant="outline"
disabled={draft?.saving === true}
onClick={() => { actions.cancelCopy() }}
>
{t('cancel')}
</Button>
<Button
disabled={draft === null || draft.saving || blocker !== undefined}
onClick={() => { void actions.confirmCopy() }}
>
{draft?.saving === true ? t('creating') : t('create')}
</Button>
</>
)}
>
{draft === null
? null
: (
<div className={css.dialogFields}>
<label className={css.field}>
<span className={css.fieldLabel}>{t('presetId')}</span>
<input
className={css.input}
value={draft.id}
autoFocus
spellCheck={false}
placeholder={t('presetIdPlaceholder')}
onChange={(event) => { actions.setCopyId(event.target.value) }}
/>
</label>
<label className={css.field}>
<span className={css.fieldLabel}>{t('displayName')}</span>
<input
className={css.input}
value={draft.name}
spellCheck={false}
placeholder={t('displayNamePlaceholder')}
onChange={(event) => { actions.setCopyName(event.target.value) }}
/>
</label>
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
</div>
)}
</Modal>
)
}
/**
* Render the Agent presets section content column.
* @param props - composed slot props.
* @returns the section, or null when the deployment composes no presets.
*/
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const { useAgentPresetSection, t, load } = props
const state = useAgentPresetSection(snapshot => snapshot)
useEffect(() => {
void load()
}, [load])
// A deployment that composes no presets has nothing to manage: every
// session shares the host composition and the page would be an empty list.
if (state.status === 'unavailable') return null
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
const detail = state.error ?? ''
return (
<div className={css.section}>
<p className={css.error} role="alert">{`${t('error')} ${detail}`}</p>
<button type="button" className={css.secondaryButton} onClick={() => { void load() }}>
{t('retry')}
</button>
</div>
)
}
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
const group = state.rows.filter(row => row.trust === trust)
if (group.length === 0) return null
return (
<section key={trust} className={css.group}>
<h3 className={css.groupHead}>{heading}</h3>
<ul className={css.cards}>
{group.map(row => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* The card body IS the control: picking a preset is the
common act, so it should not hide behind a small button.
The action row sits outside it — nesting buttons is
invalid, and these act on the card rather than select it.
A broken preset cannot compose a session, so its body is
disabled and the card says why instead of offering it. */}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{row.name ?? row.id}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* Shipped presets are the compositions a copy starts
from, so READING one is the point; a custom preset is
edited in its files instead, which the location action
leads to. A broken shipped preset has no readable
composition to offer, so its viewer is withheld; a
broken custom one keeps the location action — the
files are where it gets fixed. */}
{row.trust === 'system'
? row.broken === undefined
? (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${row.name ?? row.id}`}
onClick={() => { void props.view(row.id) }}
>
<IconBrowseOutline16 />
</button>
)
: null
: (
<button
type="button"
className={css.iconButton}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconFolderOpenOutline16 />
</button>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${row.name ?? row.id}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
</li>
))}
</ul>
</section>
)
})}
{/* The guided alternative to copying: the self-referential preset can
read this very composition and author a new one in conversation.
Offered only where that preset is actually on the roster and a
session can be landed; without a writable root the draft could
never be discovered, so the reason rides the disabled button. */}
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null}
<CopyDialog
state={state}
t={t}
actions={{
cancelCopy: props.cancelCopy,
confirmCopy: props.confirmCopy,
setCopyId: props.setCopyId,
setCopyName: props.setCopyName,
}}
/>
<Modal
open={state.view !== null}
onClose={() => { props.closeView() }}
title={state.view === null ? '' : `${t('view')} · ${state.view.title}`}
closeLabel={t('close')}
description={t('composition')}
className={css.dialog as string}
footer={(
<Button variant="outline" autoFocus onClick={() => { props.closeView() }}>
{t('close')}
</Button>
)}
>
{state.view === null
? null
: <pre className={css.viewerCode}>{state.view.content}</pre>}
</Modal>
<Modal
open={state.pendingDelete !== null}
onClose={() => { props.confirmDelete(null) }}
title={t('deleteTitle')}
closeLabel={t('close')}
description={t('deleteDescription')}
className={css.deleteDialog as string}
footer={(
<>
<Button
variant="outline"
autoFocus
disabled={state.deleting}
onClick={() => { props.confirmDelete(null) }}
>
{t('cancel')}
</Button>
<Button
variant="outline"
className={css.deleteConfirm}
disabled={state.deleting}
onClick={() => { void props.remove() }}
>
{state.deleting ? t('deleting') : t('deleteConfirm')}
</Button>
</>
)}
/>
</div>
)
}

View File

@@ -0,0 +1,83 @@
/**
* The preset picker both surfaces render: a menu of presets over a button
* naming the current one.
*
* The settings row and the composer seat differ in where they sit, what they
* call the current value, and when they refuse a pick — not in how the picker
* itself behaves. Trust is the one thing the list always says: a locally
* authored preset is exactly as privileged as the plugins it names, so the
* label marks it rather than presenting every preset as shipped and vetted.
*/
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentPresetOption } from './settings-store.ts'
/** What one surface passes to the shared picker. */
export interface PresetMenuProps {
/** Presets to offer, in roster order. */
options: readonly AgentPresetOption[]
/** The preset the button names and the menu marks selected. */
selectedId: string
/** Text on the button; the surfaces word a pending roster differently. */
label: string
/** Suffix marking a locally authored preset in the menu. */
userTrustLabel: string
/** Class for the trigger button, owned by the calling surface. */
buttonClassName: string | undefined
/** Class for the chevron, owned by the calling surface. */
chevronClassName: string | undefined
/** Whether the trigger refuses interaction. */
disabled: boolean
/** Whether the menu is open — the surface owns this so it can force it shut. */
open: boolean
/** Report the menu's next open state. */
onOpenChange: (open: boolean) => void
/** Called with the picked preset once the menu has closed. */
onSelect: (id: string) => void
}
/**
* Render the preset picker.
* @param props - the calling surface's copy, styling, and handlers.
* @returns the menu and its trigger.
*/
export function PresetMenu({
options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName,
disabled, open, onOpenChange, onSelect,
}: PresetMenuProps) {
return (
<Menu
open={open}
onClose={() => { onOpenChange(false) }}
items={options.map(option => ({
id: option.id,
// The metadata name is what every surface shows; the id is addressing,
// not a label. A preset that names itself nothing falls back to its id,
// which is then all there is to say about it.
label: option.trust === 'user'
? `${option.name ?? option.id} · ${userTrustLabel}`
: option.name ?? option.id,
}))}
selectedId={selectedId}
onSelect={(id) => {
onOpenChange(false)
onSelect(id)
}}
align="end"
portal
anchor={(
<button
type="button"
className={buttonClassName}
aria-haspopup="menu"
aria-expanded={open}
disabled={disabled}
onClick={() => { onOpenChange(!open) }}
>
{label}
<IconChevronDownOutline14 className={chevronClassName} />
</button>
)}
/>
)
}

View File

@@ -0,0 +1,209 @@
/**
* Agent-preset surface plugin, browser half — four surfaces over one roster:
* a General-settings row for the default preset, a chip on the new-session
* screen for the session about to start, a read-only label in the session
* header, and a settings section that manages the roster (copy, delete,
* default, and the way into a preset's own files).
*
* A running session keeps the composition it began with (the host refuses to
* adopt an existing session under a different preset). That is what splits
* the choice from the display: the General row and the hero chip are both
* before-the-fact, while the header only reports what a session already runs.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
import { AgentPresetRow } from './AgentPresetRow.tsx'
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
import { AgentPresetSection } from './AgentPresetSection.tsx'
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
import { AgentPresetSeatController } from './seat-store.ts'
import type { SeatSessionSummary } from './seat-store.ts'
import { AgentPresetSectionController } from './section-store.ts'
import { en, zh } from './locales.ts'
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx'
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
export {
draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView,
} from './section-store.ts'
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection']
/**
* Mount the General-settings row.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
const controller = new AgentPresetSettingsController(api)
// One roster, four surfaces. The chip is registered in a later scope, so it
// subscribes here rather than being reached from this one.
const rosterReaders = new Set<() => void>()
const section = new AgentPresetSectionController(api, () => {
void controller.load()
for (const read of rosterReaders) read()
})
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
const injected = (): AgentPresetRowInjected => ({
hooks: { agentPreset: controller.store },
load: () => controller.load(),
select: (id: string) => controller.select(id),
})
ctx.effect(() => {
// The roster is a live directory and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
void controller.load()
// The section reads the same roster and marks the same default, so a
// change made from either surface converges both.
if (section.store.getSnapshot().status !== 'idle') void section.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-agent-preset: settings refresh')
// The settings section's conversational authoring entry: stage the
// self-referential preset and land a new session on it. Bound inside the
// conversation scope below (the seat and the session flow live there) and
// unbound with it, so the section's face reads the current binding per
// render and simply hides the button while no flow exists.
let creatorDraft: (() => void) | undefined
// The new-session chip and the header label: one controller, because the
// staged choice belongs to the flow rather than to any one session.
ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => {
const api = (scope.get('connection') as ConnectionHandle).api
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
const state = scope.sessions.list.getSnapshot()
const summary = state.current === undefined ? undefined : state.byId[state.current]
return summary === undefined
? undefined
: {
id: summary.id,
blank: summary.blank,
...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset },
}
}, (sessionId, agentPreset) => {
scope.sessions.noteAgentPreset(sessionId as never, agentPreset)
})
const seatInjected = (): AgentPresetSeatInjected => ({
hooks: { agentPresetSeat: seat.store },
load: () => seat.load(),
select: (id: string) => seat.select(id),
})
const labelInjected = (): AgentPresetLabelInjected => ({
hooks: { agentPresets: controller.store },
load: () => controller.load(),
})
scope.effect(() => {
// Connecting a workspace either creates a blank session or reuses one,
// and either way the chip's pick predates it — so the stage is applied
// when the session arrives, not when it was made.
const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
// The chip opens on the deployment default, so a default changed from
// the settings surface moves it too — otherwise the screen that starts
// the next session keeps offering the previous default until a reload,
// which is exactly the session the setting claims to govern. A staged
// pick survives: `load()` prefers it over the refreshed fallback.
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
void seat.load()
})
// Authoring writes a FILE, not a setting, so nothing on the wire
// announces it — without this the screen that starts the next session
// keeps offering the roster as it stood when the chip first loaded, and
// a preset authored to be used is missing from the one place it is used.
const readRoster = (): void => { void seat.load() }
rosterReaders.add(readRoster)
// Stage WITHOUT applying — the still-current running session would
// refuse the swap and drop the stage — then start the session it lands
// on: the chip's list-change applier composes the blank session the
// workspace connect produces or reuses.
creatorDraft = () => {
seat.stage('cordis')
scope.workspaces.startSession()
}
const chip = scope.slots.register({
name: 'conversation.hero.agentPreset',
locale: 'settings.agentPreset',
inject: seatInjected,
}, AgentPresetSeat)
const label = scope.slots.register({
name: 'conversation.session.header.actions',
id: 'agent-preset',
order: 20,
locale: 'settings.agentPreset',
inject: labelInjected,
}, AgentPresetLabel)
return () => {
stop()
settingsMoved()
rosterReaders.delete(readRoster)
creatorDraft = undefined
chip()
label()
}
}, 'ui-agent-preset: new-session chip and header label')
})
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
load: () => section.load(),
view: (id: string) => section.view(id),
closeView: () => { section.closeView() },
beginCopy: (from: string) => { section.beginCopy(from) },
cancelCopy: () => { section.cancelCopy() },
setCopyId: (id: string) => { section.setCopyId(id) },
setCopyName: (name: string) => { section.setCopyName(name) },
confirmCopy: () => section.confirmCopy(),
openLocation: (id: string) => section.openLocation(id),
...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft },
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
remove: () => section.remove(),
makeDefault: (id: string) => section.makeDefault(id),
})
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'agent-preset',
order: -25,
locale: 'settings.agentPreset',
inject: injected,
}, AgentPresetRow))
// Ordered after Models: choosing a model is routine, and composing an
// agent is the deployment-shaping act behind it.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'agent-presets',
order: 20,
label: () => ctx.locale.bind('settings.agentPreset')('nav'),
locale: 'settings.agentPreset',
inject: sectionInjected,
}, AgentPresetSection))
}

View File

@@ -0,0 +1,118 @@
/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */
/** Locale keys these surfaces render. */
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
| 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view'
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
| 'brokenBadge' | 'brokenNoCopy'
| 'composition' | 'cancel' | 'close' | 'retry'
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
/** English copy. */
export const en: Record<AgentPresetSettingsKey, string> = {
title: 'Agent preset',
description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.',
loading: 'Loading presets…',
error: 'Could not load agent presets.',
userTrust: 'Custom',
seatHint: 'Agent preset for the session you are about to start',
headerHint: 'The agent preset this session runs, fixed when it started',
nav: 'Agent presets',
sectionIntro:
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
+ 'Duplicate an existing one and make it yours, or let the agent draft one for you in Creator mode.',
builtIn: 'Built-in',
setDefault: 'Set as default',
view: 'View',
duplicate: 'Duplicate',
duplicateUnavailable: 'This deployment has no writable preset directory',
delete: 'Delete',
presetId: 'Identifier',
presetIdPlaceholder: 'my-agent',
displayName: 'Name',
displayNamePlaceholder: 'Shown in the picker; defaults to the identifier',
inUse: 'In use',
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
brokenBadge: 'Broken',
brokenNoCopy: 'Broken presets cannot be duplicated',
copyOf: 'Copied from',
composition: 'Composition (agent.cordis.yml)',
cancel: 'Cancel',
close: 'Close',
retry: 'Retry',
copyTitle: 'Duplicate preset',
copyIntro:
'The whole preset is copied on this machine. The identifier becomes its directory name and cannot '
+ 'be changed later; everything else is edited in the preset\'s own files.',
create: 'Create',
creating: 'Creating…',
creatorDraft: 'Draft a custom preset with Creator mode',
openLocation: 'Open folder',
showLocation: 'Show location',
revealedPathLabel: 'Preset files:',
idRequired: 'Give the preset an identifier.',
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
idTaken: 'A preset with this identifier already exists.',
deleteTitle: 'Delete this preset?',
deleteDescription:
'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
deleteConfirm: 'Delete',
deleting: 'Deleting…',
}
/** Simplified Chinese copy. */
export const zh: Record<AgentPresetSettingsKey, string> = {
title: 'Agent 预设',
description: '对此后新建的会话生效。运行中的会话保持它开始时的预设。',
loading: '正在加载预设…',
error: '无法加载 Agent 预设。',
userTrust: '自定义',
seatHint: '即将开始的这个会话所用的 Agent 预设',
headerHint: '本会话运行的 Agent 预设,开始时即固定',
nav: 'Agent 预设',
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。',
builtIn: '内置',
setDefault: '设为默认',
view: '查看',
duplicate: '复制',
duplicateUnavailable: '此部署未配置可写的预设目录',
delete: '删除',
presetId: '标识符',
presetIdPlaceholder: 'my-agent',
displayName: '名称',
displayNamePlaceholder: '选择器中显示的名字,缺省用标识符',
inUse: '当前使用',
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',
brokenBadge: '已损坏',
brokenNoCopy: '预设已损坏,无法复制',
copyOf: '复制自',
composition: '组装agent.cordis.yml',
cancel: '取消',
close: '关闭',
retry: '重试',
copyTitle: '复制预设',
copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。',
create: '创建',
creating: '正在创建…',
creatorDraft: '用「创造模式」创作自定义预设',
openLocation: '打开目录',
showLocation: '查看路径',
revealedPathLabel: '预设文件:',
idRequired: '请填写标识符。',
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
idTaken: '该标识符已被占用。',
deleteTitle: '删除该预设?',
deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
deleteConfirm: '删除',
deleting: '正在删除…',
}

View File

@@ -0,0 +1,163 @@
/**
* Hero-chip controller: which preset the NEXT session gets.
*
* The new-session screen has no session, so a pick is staged rather than
* applied. It reaches a session when one becomes current and is still blank —
* whether the workspace connect created it or reused an existing blank one,
* which is why staging cannot simply ride along on `sessions.create`.
*
* The stage is forgotten once applied: the next new session starts from the
* deployment default again, matching the workspace picker beside it.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import {
createSnapshotStore, type SessionId, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import { messageOf, presetOptions } from './settings-store.ts'
import type { AgentPresetOption } from './settings-store.ts'
/** Hero-chip snapshot. */
export interface AgentPresetSeatState {
/** Presets the deployment supplies; empty means the chip renders nothing. */
options: readonly AgentPresetOption[]
/** The staged choice, empty until the roster loads. */
current: string
/** A rejected apply's message, cleared by the next attempt. */
error: string | null
busy: boolean
}
const INITIAL: AgentPresetSeatState = {
options: [], current: '', error: null, busy: false,
}
/** One session's identity and whether it has started. */
export interface SeatSessionSummary {
/** The session the chip would apply its staged choice to. */
id: SessionId
/** False once a turn has run — applying is refused from then on. */
blank: boolean
/** The preset the session already runs, when the summary reports one. */
agentPreset?: string
}
/** Stages the next session's preset and applies it when one appears. */
export class AgentPresetSeatController {
/** Chip snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
/**
* The deployment default, so a consumed stage can fall back to it without
* re-reading the roster.
*/
private fallback = ''
/** Set while a pick is waiting for a session; cleared once applied. */
private staged: string | undefined
constructor(
private readonly api: Pick<IApiClient, 'agentPresets'>,
/** The session the hero is about to hand over to, when there is one. */
private readonly currentSession: () => SeatSessionSummary | undefined,
/**
* Publish an applied switch into the session list, so the header label
* moves with the composition instead of waiting for the next full list
* refresh. Optional: a harness that renders no list omits it.
*/
private readonly onApplied?: (sessionId: string, agentPreset: string) => void,
) {}
private set(patch: Partial<AgentPresetSeatState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
/**
* Read the roster and open the chip on the deployment default.
* @returns once the snapshot reflects the host.
*/
async load(): Promise<void> {
try {
const response = await this.api.agentPresets.list({})
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
const { presets } = response.result.value
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
this.set({
options: presetOptions(presets),
// Staged pick first, then the composition the current session
// already carries, then the deployment default. The middle term is
// what keeps a late-landing load from regressing the display after
// an applied stage was consumed — the chip mounts (and loads) only
// once the flow's session is current, so the reply can arrive after
// apply() already composed it.
current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback,
error: null,
})
} catch (error) {
this.set({ error: messageOf(error) })
}
}
/**
* Stage one preset for the next session, applying it immediately when a
* blank session is already current.
* @param id - the preset to stage.
* @returns once the stage settled, and the apply too when one happened.
*/
async select(id: string): Promise<void> {
if (this.store.getSnapshot().busy) return
this.stage(id)
await this.apply()
}
/**
* Stage a pick WITHOUT the immediate apply, for a flow that starts the
* receiving session after the pick (the settings section's creator entry).
* `select()`'s immediate apply would meet the still-current running session
* and drop the stage as unservable; staging alone leaves it for the
* list-change applier, which fires when the started session becomes
* current.
* @param id - the preset to stage.
*/
stage(id: string): void {
this.staged = id
this.set({ current: id, error: null })
}
/**
* Hand the staged choice to the current session, if there is one to take it.
*
* Called both by `select()` and by whoever observes the current session
* changing, because the session may appear either before or after the pick.
* @returns once the switch settled, or immediately when there is nothing to do.
*/
async apply(): Promise<void> {
const staged = this.staged
const session = this.currentSession()
if (staged === undefined || session === undefined) return
// A started session's history was produced under its own composition; the
// host refuses the swap, so the stage is no longer meaningful.
if (!session.blank || session.agentPreset === staged) {
this.staged = undefined
return
}
this.set({ busy: true, error: null })
try {
const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
this.staged = undefined
if (!response.result.ok) {
this.set({ busy: false, error: response.result.error.message, current: this.fallback })
return
}
// Consumed: the next new session opens on the deployment default again.
this.set({ busy: false, current: response.result.value.agentPreset })
this.onApplied?.(session.id, response.result.value.agentPreset)
} catch (error) {
this.staged = undefined
this.set({ busy: false, error: messageOf(error), current: this.fallback })
}
}
}

View File

@@ -0,0 +1,347 @@
/**
* Agent-preset management controller: the roster as a list, a copy dialog as
* the only way a preset is created, and a read-only viewer over the shipped
* compositions.
*
* The browser edits no composition text. A new preset is a host-side copy of
* an existing one (`{ from, id, name? }` is all that crosses the wire), and
* everything after creation happens in the preset's own files — which is why
* the page's other job is getting the user TO those files: open the directory
* where the host has a desktop, show its path where it does not.
*
* The host stays the single fact source. Every mutation writes through the
* wire and the page re-reads the roster afterwards, because a copy changes
* more than the row it targeted.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
/** Ids a preset directory may be named, mirroring the host's own rule. */
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
/** One preset row the page renders. */
export interface PresetRow {
/** Preset id and directory name; the display name falls back to it. */
id: string
/** Display name the preset published, absent when it published none. */
name?: string
/** One sentence on what the preset is for. */
description?: string
/** Whether the preset ships with the deployment or was authored locally. */
trust: 'system' | 'user'
/** Whether a session that names no preset gets this one. */
isDefault: boolean
/**
* Why the preset cannot compose a session, absent when it can. A broken
* row renders marked and unselectable — its directory still occupies the
* id, so deleting it (or fixing the files) is the way out, and this page
* is where both of those live.
*/
broken?: string
}
/** The copy dialog: a new id and optional display name over a fixed source. */
export interface CopyDraft {
/** The preset being copied. */
from: string
/** Display name of the source, for the dialog title. */
fromTitle: string
/** New preset id being typed; the directory name, so it is required. */
id: string
/** Display name being typed; empty falls back to the id. */
name: string
/** Whether the copy is in flight. */
saving: boolean
/** The last copy failure, cleared by the next edit. */
error: string | null
}
/** The read-only composition viewer over one shipped preset. */
export interface PresetView {
/** The preset whose composition is shown. */
id: string
/** Display name, for the dialog title. */
title: string
/** Composition text exactly as stored. */
content: string
}
/** Page snapshot. */
export interface AgentPresetSectionState {
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
/** Whole-load failure text; a copy failure stays on the dialog. */
error: string | null
/** Whether the deployment configures a root new presets can be written to. */
authorable: boolean
/** Whether the host can open a preset directory on a native desktop. */
hasDocument: boolean
/** Every preset the deployment currently supplies. */
rows: readonly PresetRow[]
/** The open copy dialog, or null. */
copy: CopyDraft | null
/** The open read-only viewer, or null. */
view: PresetView | null
/** The preset awaiting delete confirmation. */
pendingDelete: string | null
/** Whether a delete is in flight. */
deleting: boolean
/**
* Preset directories shown as text because the host has no desktop opener
* — the answer `openDocument` gives instead of opening.
*/
revealedPaths: Readonly<Record<string, string>>
}
const INITIAL: AgentPresetSectionState = {
status: 'idle',
error: null,
authorable: false,
hasDocument: false,
rows: [],
copy: null,
view: null,
pendingDelete: null,
deleting: false,
revealedPaths: {},
}
/**
* Why this copy cannot be submitted yet, as a locale key, or undefined when
* it can. Client-side only: the host re-checks the id and its answer is what
* the dialog reports on failure.
* @param draft - the open copy dialog.
* @param rows - the roster, for the collision check.
* @returns the blocking reason's locale key, or undefined when submittable.
*/
export function draftBlocker(
draft: CopyDraft,
rows: readonly PresetRow[],
): 'idRequired' | 'idInvalid' | 'idTaken' | undefined {
if (draft.id === '') return 'idRequired'
if (!PRESET_ID.test(draft.id)) return 'idInvalid'
// A copy never overwrites: landing on a name already in use would replace
// something the user did not open.
if (rows.some(row => row.id === draft.id)) return 'idTaken'
return undefined
}
/** Reads the roster and drives the copy dialog, viewer, and location reveals. */
export class AgentPresetSectionController {
/** Page snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(
private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>,
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
* already announced by the host through `settings/changed`; a directory
* copied or deleted here is not, and the new-session chip has no other
* way to learn a preset it should offer now exists.
*/
private readonly rosterChanged: () => void = () => {},
) {}
private set(patch: Partial<AgentPresetSectionState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
private patchCopy(patch: Partial<CopyDraft>): void {
const { copy } = this.store.getSnapshot()
if (copy === null) return
this.set({ copy: { ...copy, ...patch } })
}
/**
* Load the roster. An empty roster means the deployment composes no
* presets, which is a valid deployment rather than a failure — the section
* reports `unavailable` and renders nothing.
* @returns once the snapshot reflects the host.
*/
async load(): Promise<void> {
const roster = await beginRosterRead(this.api, this.store)
if (roster === undefined) return
const { presets, authorable, hasDocument } = roster
if (presets.length === 0) {
// Nothing to manage leaves nothing to keep a dialog open over.
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
return
}
// A reveal outlives a reload but not its preset: a path for a row the
// roster no longer lists would be a claim about a directory that is gone.
const revealed = this.store.getSnapshot().revealedPaths
const kept = Object.fromEntries(
Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id)))
this.set({
status: 'ready',
error: null,
authorable,
hasDocument,
rows: presets.map(preset => ({ ...preset })),
revealedPaths: kept,
})
}
/**
* Open one shipped preset's composition in the read-only viewer.
* @param id - the preset to view.
* @returns once the composition loaded or the failure is on the page.
*/
async view(id: string): Promise<void> {
this.set({ error: null })
try {
const response = await this.api.agentPresets.read({ agentPreset: id })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
const { name, content } = response.result.value
this.set({ view: { id, title: name ?? id, content } })
} catch (error) {
this.set({ error: messageOf(error) })
}
}
/** Close the read-only viewer. */
closeView(): void {
this.set({ view: null })
}
/**
* Open the copy dialog over one preset.
* @param from - the preset the copy will start from.
*/
beginCopy(from: string): void {
const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from)
this.set({
error: null,
copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null },
})
}
/** Close the copy dialog, discarding whatever was typed. */
cancelCopy(): void {
this.set({ copy: null })
}
/**
* Name the preset the copy creates.
* @param id - the id typed into the dialog.
*/
setCopyId(id: string): void {
this.patchCopy({ id, error: null })
}
/**
* Name the copy's display name.
* @param name - the display name typed into the dialog.
*/
setCopyName(name: string): void {
this.patchCopy({ name, error: null })
}
/**
* Submit the copy, re-read the roster, then take the user to the new
* preset's files — the directory opens where the host has a desktop, and
* its path appears on the new row where it does not.
* @returns once the copy settled and the page reflects it.
*/
async confirmCopy(): Promise<void> {
const draft = this.store.getSnapshot().copy
if (draft === null || draft.saving) return
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
this.patchCopy({ saving: true, error: null })
try {
const name = draft.name.trim()
const response = await this.api.agentPresets.copy({
from: draft.from,
agentPreset: draft.id,
...name === '' ? {} : { name },
})
if (!response.result.ok) {
this.patchCopy({ saving: false, error: response.result.error.message })
return
}
this.set({ copy: null })
await this.load()
this.rosterChanged()
// A preset is its files from here on (the dialog collected nothing
// else), so landing in them is the completion, not a follow-up.
await this.openLocation(draft.id)
} catch (error) {
this.patchCopy({ saving: false, error: messageOf(error) })
}
}
/**
* Open one preset's directory on the host desktop, or reveal its path on
* the row where the deployment has no opener to hand it to.
* @param id - the preset whose files the user wants.
* @returns once the host answered and the page reflects it.
*/
async openLocation(id: string): Promise<void> {
try {
const response = await this.api.agentPresets.openDocument({ agentPreset: id })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
if (response.result.value.opened) return
const { path } = response.result.value
this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
} catch (error) {
this.set({ error: messageOf(error) })
}
}
/**
* Ask for confirmation before deleting one preset.
* @param id - the preset to delete, or null to dismiss the confirmation.
*/
confirmDelete(id: string | null): void {
if (this.store.getSnapshot().deleting) return
this.set({ pendingDelete: id })
}
/**
* Delete the preset awaiting confirmation, then re-read the roster.
*
* A session already composed from it keeps running: its composition was
* mounted at creation and nothing re-reads the file.
* @returns once the delete settled and the page reflects it.
*/
async remove(): Promise<void> {
const { pendingDelete, deleting } = this.store.getSnapshot()
if (pendingDelete === null || deleting) return
this.set({ deleting: true, error: null })
try {
const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
if (!response.result.ok) {
this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
return
}
this.set({ deleting: false, pendingDelete: null })
await this.load()
this.rosterChanged()
} catch (error) {
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
}
}
/**
* Make one preset the default for sessions created later. Running sessions
* keep the composition they began with, so this never disturbs work.
* @param id - the preset to make default.
* @returns once the write settled and the roster was re-read.
*/
async makeDefault(id: string): Promise<void> {
const failure = await writeDefaultPreset(this.api, id)
if (failure !== undefined) {
this.set({ error: failure })
return
}
await this.load()
}
}

View File

@@ -0,0 +1,255 @@
/**
* Agent-preset default-settings controller.
*
* Options and the current default both come from one `agentPreset.list` call:
* the roster already reports which id a session with no explicit choice gets,
* so the row needs no schema introspection. Writes target the settings
* namespace's `default` field, which is what the host resolves at creation.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
/**
* Human text for a rejected wire call. A transport failure rejects with an
* Error; a host or a runtime can reject with anything, and the surface still
* has to say something.
* @param error - the rejection value.
* @returns the message to show.
*/
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Persist one preset as the default for sessions created later.
*
* The default is a settings field rather than a preset property, so both the
* General row and the management section write it here — one home for which
* namespace and field the host resolves at session creation.
* @param api - the settings wire face.
* @param id - the preset to make default.
* @returns the failure message, or undefined once the write landed.
*/
export async function writeDefaultPreset(
api: Pick<IApiClient, 'settings'>,
id: string,
): Promise<string | undefined> {
let response
try {
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
} catch (error) {
// The transport rejected rather than answering; the caller must be able to
// say so instead of the row silently snapping back.
return messageOf(error)
}
return response.result.ok ? undefined : response.result.error.message
}
/** One selectable preset. */
export interface AgentPresetOption {
/** Preset id, written to Settings and the label's fallback. */
id: string
/** Whether the preset ships with the deployment or was authored locally. */
trust: 'system' | 'user'
/** Display name the preset published, absent when it published none. */
name?: string
/** One sentence on what the preset is for. */
description?: string
}
/** One roster entry exactly as the host reports it. */
export interface RosterPreset {
/** Preset id and directory name. */
id: string
/** Whether the preset ships with the deployment or was authored locally. */
trust: 'system' | 'user'
/** Whether a session that names no preset gets this one. */
isDefault: boolean
/** Display name the preset published, absent when it published none. */
name?: string
/** One sentence on what the preset is for. */
description?: string
/** Why the preset cannot compose a session, absent when it can. */
broken?: string
}
/** The roster the host answered with. */
export interface RosterValue {
/** Every preset the deployment composes, in the order the host lists them. */
presets: readonly RosterPreset[]
/** Whether this browser may author presets at all. */
authorable: boolean
/** Whether the host can open a preset directory on a native desktop. */
hasDocument: boolean
}
/** The roster, or the message to show in its place. */
export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string }
/**
* Read the roster, folding both refusal shapes into one message.
*
* The wire refuses in two ways — the transport rejects, or it answers an
* `ok: false` envelope — and every surface treats them identically. Folding
* them here keeps each store's `load` about what it does with a roster rather
* than about how the call can fail.
* @param api - the agent-preset wire face.
* @returns the roster, or the message to show in its place.
*/
export async function readRoster(api: Pick<IApiClient, 'agentPresets'>): Promise<RosterRead> {
try {
const response = await api.agentPresets.list({})
return response.result.ok
? { ok: true, value: response.result.value }
: { ok: false, error: response.result.error.message }
} catch (error) {
return { ok: false, error: messageOf(error) }
}
}
/**
* The opening move every roster-backed surface makes: refuse a read that is
* already in flight, mark the store loading, then read.
*
* A surface that gets `undefined` returns without touching its snapshot
* further — either another read owns it, or this one already wrote the
* failure. What differs between surfaces starts after this.
* @param api - the agent-preset wire face.
* @param store - the surface's own snapshot store.
* @returns the roster, or undefined when the caller should return.
*/
export async function beginRosterRead<S extends { status: string; error: string | null }>(
api: Pick<IApiClient, 'agentPresets'>,
store: SnapshotStore<S>,
): Promise<RosterValue | undefined> {
const before = store.getSnapshot()
if (before.status === 'loading') return undefined
store.set({ ...before, status: 'loading', error: null })
const roster = await readRoster(api)
if (roster.ok) return roster.value
store.set({ ...store.getSnapshot(), status: 'error', error: roster.error })
return undefined
}
/**
* The roster entries as the pickers render them: healthy presets only.
*
* The chip and the row exist to choose the NEXT session's composition, and a
* broken preset cannot compose one — offering it would defer the discovery
* of that fact to a failed session start. The management section renders the
* full roster (broken rows included) from its own store instead.
*
* The chip, the row, and the management section all show the same facts, and
* `exactOptionalPropertyTypes` makes "absent" and "present as undefined"
* different shapes — so the spread dance belongs in one place rather than
* once per store.
* @param presets - the roster the host answered with.
* @returns one option per selectable preset, in roster order.
*/
export function presetOptions(
presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[],
): AgentPresetOption[] {
return presets.filter(preset => preset.broken === undefined).map(preset => ({
id: preset.id,
trust: preset.trust,
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
}))
}
/** Agent-preset settings-row snapshot. */
export interface AgentPresetSettingsState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
error: string | null
/**
* Whether this browser may persist the choice at all. `settings.describe` is
* loopback-only and reports a read-only provider as `writable: false`; the
* row then shows the current default and disables the control rather than
* offering a write the gateway will refuse.
*/
writable: boolean
currentValue: string
options: readonly AgentPresetOption[]
}
const INITIAL: AgentPresetSettingsState = {
status: 'idle',
error: null,
// Assumed until `load()` asks; a row that has not read yet renders nothing
// interactive anyway (status 'idle').
writable: true,
currentValue: '',
options: [],
}
/** Reads the roster and persists the chosen default. */
export class AgentPresetSettingsController {
/** Row snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
constructor(private readonly api: IApiClient) {}
private set(patch: Partial<AgentPresetSettingsState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
/**
* Load the roster. An empty roster means the deployment composes no
* presets, which is a valid deployment rather than a failure — the row
* reports `unavailable` and renders nothing.
* @returns once the snapshot reflects the host.
*/
async load(): Promise<void> {
const roster = await beginRosterRead(this.api, this.store)
if (roster === undefined) return
const { presets } = roster
const [first] = presets
if (first === undefined) {
this.set({ status: 'unavailable', options: [], currentValue: '' })
return
}
try {
// The roster says what may be chosen; `settings.describe` says whether
// this browser may write the choice down. A non-loopback browser reaches
// neither method, so a refused describe leaves the row read-only rather
// than offering a control whose write answers `settings-not-exposed`.
const described = await this.api.settings.describe({})
this.set({
status: 'ready',
error: null,
writable: described.result.ok && described.result.value.writable,
options: presetOptions(presets),
// A roster can mark nothing default: settings can name a preset that
// was since deleted, and the picker still has to show something.
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
})
} catch (error) {
this.set({ status: 'error', error: messageOf(error) })
}
}
/**
* Persist one preset as the default for sessions created later. Running
* sessions keep the composition they were created with, so this never
* disturbs work in progress.
* @param id - the preset to make default.
* @returns once the write settled and the roster was re-read.
*/
async select(id: string): Promise<void> {
const before = this.store.getSnapshot()
if (before.status === 'saving' || id === before.currentValue) return
this.set({ status: 'saving', error: null, currentValue: id })
const failure = await writeDefaultPreset(this.api, id)
if (failure !== undefined) {
this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
return
}
// Re-read rather than trust the patch: the host resolves the default
// through the same roster the row displays.
await this.load()
}
}

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