mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/pr343-retarget-latest-master
# Conflicts: # docs/cordis-primer.md
This commit is contained in:
@@ -53,9 +53,9 @@ packages/
|
||||
|
||||
The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead:
|
||||
|
||||
- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.)
|
||||
- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. The aggregate configs (`tsconfig.host.json`, `tsconfig.client.json`) reuse that source map and carry the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.)
|
||||
- `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages/<group>/<pkg>`), resolving the `TODO(package-inventory)`.
|
||||
- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)).
|
||||
- The aggregates' project `references` stay explicit lists — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)).
|
||||
|
||||
### Guardrails added
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 65fb01f44698c61e6bf6958e332e1854fbb77fa9
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: e8b15789846ea124fb6a90f2afef437184d4348a
|
||||
|
||||
@@ -53,7 +53,7 @@ Direction discipline (every rule auditable from package deps):
|
||||
- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
|
||||
- Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it).
|
||||
|
||||
TypeScript checks in **two aggregate programs** (`tsconfig.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs.
|
||||
TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)).
|
||||
|
||||
On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect).
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ harness core packages ──────────────────┘
|
||||
- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。
|
||||
- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。
|
||||
|
||||
TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。
|
||||
TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。
|
||||
|
||||
协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025
|
||||
2026-07-19-gui-web-client-architecture.md: f21b840493b83c02d7abc3ba1c1bf90635166ec1
|
||||
2026-07-19-gui-web-client-architecture.zh.md: a50dc556cfc96b6d35feea6ef2b1aadae9f31c44
|
||||
|
||||
@@ -39,32 +39,17 @@ The loading chain, end to end:
|
||||
|
||||
**The dual-instance ban**: a module-table package inlined into a plugin bundle would duplicate runtime identity (two React copies, two store registries — the root cause of an actual white-screen P0). The tsdown client preset enforces purity at build time: a bare-name import of a module-table package must resolve external (rewritten to its `/client` form where applicable), and any other workspace leak that is not an inline-safe wire/type layer fails the build (`packages/client/tsdown.client.ts`, pinned by `scripts/client-bundle-purity.spec.ts`).
|
||||
|
||||
Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — the root `tsconfig.json` is the host program, `tsconfig.client.json` the client program, because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
|
||||
Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
|
||||
|
||||
## The slot system: how the page composes
|
||||
|
||||
A page is a tree of slots; whoever owns a region declares its slots. Contracts live in one place — the `SlotMap` interface in `@deepseek-ai/dsh-client-ui-slots`, extended by declaration merging. An entry declares the slot's axes and the **owner share** only; the registrant's injected props never enter the global table ("whoever injects it, owns its type"):
|
||||
The slot system has its own RFC — the [slot system standard](2026-07-22-slot-type-chain-implementation.md) — and this document defers to it entirely. The one-paragraph summary for orientation: the shell renders only `'root'`; a plugin composes UI through a single `register` call that occupies a slot, declares+authorizes its child slots (`children` spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject), each from its single source of truth. `SlotMap` declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.
|
||||
|
||||
```ts ignore-check
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
|
||||
sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
|
||||
conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
|
||||
} }
|
||||
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账
|
||||
ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
|
||||
```
|
||||
|
||||
- Three kinds: `single` (duplicate registration throws), `list` (id/order), `keyed` (runtime dispatch, duplicate key throws). Register before define throws. Two scopes: `root` (no session context) and `session` — the scope decides the injection shape below.
|
||||
- **Full component props are composed by reference, never re-typed**: a registrant's component declares `OwnerOf<K> & StandardOf<K> & OwnInjected` — the owner share referenced from the slot owner's package, the standard share supplied by the framework (session slots: `useSession`), and the registrant's own injected share declared locally next to the component. `register<K, I>` enforces the composition at the call site: the component parameter is `SlotComponent<ComposedProps<K, NoInfer<I>>>` (a bare call signature, not `FC` — FC's `propTypes` static position generates contravariance noise against the standard share), and `I` is inferred exclusively from the inject factory's return type (`NoInfer` pins it), so a drifted component or a mismatched factory is a compile error at the registration point. In ui-conversation the injected shares live in `src/client/contract/slots.ts` (`ConversationInjected` and kin) and each skeleton component's props is a one-line reference composition.
|
||||
- **Delegation is a hand-written whitelist with an optional declared ceiling**: an owner component receives a whitelist-narrowed `slots: ScopedSlots<'a' | 'b'>` through its own props and calls `slots.renderSlot(key, props)`; passing a narrowed subset to a child goes through `narrowSlots` (pure type covariance). Overreach is a compile error, and the runtime whitelist backstops plain-JS callers. An entry may additionally declare `children: <key>` — register then validates the component's whitelist ⊆ the declared ceiling (opt-in visibility layer, not mandatory). Every rendered entry is wrapped in a per-entry error boundary: a crashing registrant (component or inject factory) blacks out only its own entry, while assembly errors (missing providers) rethrow — a miswired shell fails loud instead of degrading.
|
||||
- **Props merge from three sources** (the outlet does it; owners write only the first): ① owner-supplied props (identity, display parameters, frozen slices) — typed as the entry's owner share, exact at the renderSlot point; ② scope-standard injection — session slots automatically receive `useSession` bound to the right Session; ③ the registrant's `inject` factory, called once per (entry × session) for session slots and once per entry for root slots, cached in WeakMaps so a session switch-back reuses the cached result. Inject factories receive the assembly handle (`SessionBinding { sessionId, session, ctx }` or `RootBinding { ctx }`) — an apply-world object that never enters React.
|
||||
- Two supply channels close the loop: `RootBindingProvider` (mounted once by the shell) feeds root-slot inject factories their ctx; `createSessionProvider(deps)` builds the single session provider — dependency-inverted (`useCurrent` / `resolveBinding` / `renderBody`), so web-react never imports the runtime. It subscribes to the current session id, resolves a reference-stable binding, remounts its body under `key={id}`, and delegates body rendering to the assembler's `renderBody` closure (slot ownership stays with layout; the provider knows no slot names).
|
||||
|
||||
Implementation homes: registry core in `packages/client/ui-slots` (zero dependencies), outlet/providers/uSES bridge in `packages/client/web-react`.
|
||||
Implementation homes: registry core and the props-share types in `packages/client/ui-slots`, outlet/renderer/uSES bridge in `packages/client/web-react`.
|
||||
|
||||
## Services and scope addressing
|
||||
|
||||
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`), `ctx.sessions` (list store, scope tree, bindings), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (navigation + panel viewing state), `ctx.conversation` (send/cancel/selection/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters).
|
||||
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do).
|
||||
|
||||
@@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
|
||||
The glue package is the whole ctx↔React boundary; components stay framework-free.
|
||||
|
||||
- `createSnapshotStore<T>(init, opts)`: the store engine for plugin-owned data and shell viewing state — zustand vanilla with draft-based updates, `flush: 'sync'` by default (controlled inputs need same-tick echo) with opt-in `'raf'` batching for frame-driven stores, opt-in whole-value localStorage persistence, dev-mode deep freeze. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`).
|
||||
- The snapshot store engine **lives in the runtime package** (zustand vanilla with draft-based updates, `flush: 'sync'` by default with opt-in `'raf'` batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported from `runtime`'s `./client` main entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). web-react composes every hook at the binding site (`bindSnapshotSelector`, per-source cached) from the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; web-react is shell-only glue.
|
||||
- `bindSnapshotSelector(source)`: binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to `Object.is` with `shallowEqual` opt-in per call.
|
||||
- `useInvoke(fn)`: wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes.
|
||||
- Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with `Object.is` or `shallowEqual`; `React.memo` shallow. Deep comparison is banned everywhere.
|
||||
@@ -128,9 +113,9 @@ Domain implementation files never import a sibling domain — shared surfaces ro
|
||||
## How to develop
|
||||
|
||||
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores, registers slots and toolviews), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
|
||||
- **A new slot**: merge the contract into `SlotMap`, `define` at the owner, render through the owner's own `ScopedSlots` whitelist; registrants `register` with an optional inject factory. Never export components globally.
|
||||
- **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally.
|
||||
- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept.
|
||||
- **Where does this state live**: per-session and must survive switches → the Session object / scope-mounted store; private to one view (selection, scroll) → component state; shell viewing state (navigation, panel widths, preferences) → `ctx.layout`'s stores; business data → always the object layer, never a viewing-state store.
|
||||
- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)).
|
||||
- **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -39,32 +39,17 @@ Status: implemented
|
||||
|
||||
**双实例禁令**:模块表包若被内联进插件 bundle,会复制运行时身份(两份 React、两套 store 注册表——一次真实白屏 P0 的根因)。tsdown client 预设在构建期把守纯度:模块表包的裸名 import 必须解析为 external(适用时改写为其 `/client` 形态),其余任何非 inline 安全 wire/类型层的 workspace 泄漏都令构建大声失败(`packages/client/tsdown.client.ts`,由 `scripts/client-bundle-purity.spec.ts` 钉住)。
|
||||
|
||||
dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——根 `tsconfig.json` 是 host program,`tsconfig.client.json` 是 client program,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
|
||||
dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
|
||||
|
||||
## slot 体系:页面怎么拼
|
||||
|
||||
页面是一棵坑位树;谁拥有区域谁声明坑位。契约只有一个家——`@deepseek-ai/dsh-client-ui-slots` 的 `SlotMap` 接口,经声明合并扩展。entry 只声明坑的轴与 **owner 份额**;注册方的注入 props 永不进全局表(「谁注入的放谁那里」):
|
||||
slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)——本文整体移交给它。此处只留一段定位摘要:壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——占坑、声明并授权子坑(`children` spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject),各有唯一真源。`SlotMap` 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。
|
||||
|
||||
```ts ignore-check
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
|
||||
sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
|
||||
conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
|
||||
} }
|
||||
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账
|
||||
ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
|
||||
```
|
||||
|
||||
- 三型:`single`(重复注册即 throw)、`list`(id/order)、`keyed`(运行时按 key 分发,重 key 即 throw)。define 之前 register 即 throw。两 scope:`root`(无会话语境)与 `session`——scope 决定下述注入形态。
|
||||
- **组件全量 props 一律引用组合,不重抄**:注册方组件声明 `OwnerOf<K> & StandardOf<K> & OwnInjected`——owner 份额从坑位 owner 的包引用、标配份额由框架供给(session 坑:`useSession`)、注册方自己的注入份额就地声明在组件旁。`register<K, I>` 在调用点强制组合:组件形参位是 `SlotComponent<ComposedProps<K, NoInfer<I>>>`(裸调用签名而非 `FC`——FC 的 `propTypes` 静态位对标配份额产生反变噪音),`I` 只从 inject 工厂返回值推断(`NoInfer` 钉死),组件漂移或工厂不匹配都在注册点编译报错。ui-conversation 的注入份额住 `src/client/contract/slots.ts`(`ConversationInjected` 族),各骨架组件的 props 是一行引用组合。
|
||||
- **转授=手写白名单+可选声明上限**:owner 组件经自己的 props 拿到白名单收窄的 `slots: ScopedSlots<'a' | 'b'>`,调 `slots.renderSlot(key, props)` 渲染;把收窄子集递给子组件走 `narrowSlots`(纯类型协变)。越权是编译错误,运行时白名单再兜住纯 JS 调用方。entry 可另声明 `children: <key>`——register 校验组件白名单 ⊆ 声明上限(可选可见层,不强制)。每个被渲染的注册项都包在 per-entry 错误边界里:注册方崩溃(组件或 inject 工厂)只黑自己那一格,装配错误(缺 provider)则重抛——接错线的壳大声失败而不是静默降级。
|
||||
- **props 三源合并**(出口组件来做;owner 只写第一份):① owner 供参(身份、展示参数、冻结切片)——按 entry 的 owner 份额强类型,renderSlot 点即精确;② scope 标配注入——session 坑自动获得绑定正确 Session 的 `useSession`;③ 注册方的 `inject` 工厂,session 坑 per-(注册项 × 会话) 调一次、root 坑 per-注册项调一次,以 WeakMap 缓存——切回会话时复用缓存结果。inject 工厂收到装配句柄(`SessionBinding { sessionId, session, ctx }` 或 `RootBinding { ctx }`)——apply 世界的对象,永不进入 React。
|
||||
- 两条供给通道收拢闭环:`RootBindingProvider`(壳顶部挂一次)为 root 坑 inject 工厂供给 ctx;`createSessionProvider(deps)` 构造唯一的会话 provider——依赖倒置(`useCurrent` / `resolveBinding` / `renderBody`),web-react 永不 import runtime。它订阅当前会话 id、解析引用恒等的 binding、以 `key={id}` 重挂其 body,并把 body 渲染委托给装配方的 `renderBody` 闭包(坑位所有权留在 layout;provider 不认识坑名)。
|
||||
|
||||
实现的家:注册表纯核在 `packages/client/ui-slots`(零依赖),出口组件/provider/uSES 桥在 `packages/client/web-react`。
|
||||
实现的家:注册表核心与 props 份额类型在 `packages/client/ui-slots`,出口组件/渲染器/uSES 桥在 `packages/client/web-react`。
|
||||
|
||||
## 服务与 scope 寻址
|
||||
|
||||
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`)、`ctx.sessions`(列表 store、scope 树、binding)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(导航 + 面板观看态)、`ctx.conversation`(send/cancel/selection/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。
|
||||
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
|
||||
|
||||
SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。
|
||||
|
||||
@@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
|
||||
胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
|
||||
|
||||
- `createSnapshotStore<T>(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)。
|
||||
- 快照 store 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何 hook 成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据契约合成每个 hook:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;web-react 是仅壳可用的胶水。
|
||||
- `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector hook。uSES 契约四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。
|
||||
- `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走 per-hook 外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。
|
||||
- 相等性协议,全链一致:生产端结构共享;消费端以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。
|
||||
@@ -128,9 +113,9 @@ src/client/
|
||||
## 怎么开发
|
||||
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
|
||||
- **新 slot**:契约合并进 `SlotMap`,owner 处 `define`,经 owner 自己的 `ScopedSlots` 白名单渲染;注册方 `register`,按需带 inject 工厂。永不全局导出组件。
|
||||
- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
|
||||
- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
|
||||
- **状态住哪**:per-session 且要跨切换存续 → Session 对象 / scope 挂账 store;单视图私有(选中、滚动)→ 组件状态;壳观看态(导航、面板宽、偏好)→ `ctx.layout` 的 store;业务数据 → 永远对象层,永不进观看态 store。
|
||||
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
|
||||
- **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -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
|
||||
2026-07-22-slot-type-chain-implementation.md: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2
|
||||
2026-07-22-slot-type-chain-implementation.md: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9
|
||||
|
||||
@@ -1,47 +1,109 @@
|
||||
# Agent Note: Slot type-chain hardening — the non-obvious implementation rulings
|
||||
# Agent Note: The slot system standard — single register, four props shares, and the framework store seat
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-slot-type-chain-implementation.zh.md)
|
||||
|
||||
> Scope: why the slot registration/render type chain (`packages/client/ui-slots/src/index.ts`, consumed by `packages/client/web-react/src/scoped-slots.tsx`) is implemented the way it is. The design-level trade-offs (registration-site inference over declaration tables, hand-written whitelists over derived ones) live in the web client architecture RFC; this note pins the five implementation decisions a future editor would otherwise re-litigate or accidentally revert.
|
||||
> Scope: the definitive slot-system design for the web client — how UI plugins compose the page, where render authority lives, how component props are typed, and where business live-data goes. The [web client architecture RFC](2026-07-19-gui-web-client-architecture.md) owns the surrounding context (loading chain, object layer, services) and defers its slot sections here.
|
||||
|
||||
## Problem
|
||||
|
||||
The hardened chain types every hop from `SlotMap` declaration to rendered component: owner share + framework-standard share + registrant-injected share compose into the component's props, checked at `register()`. Making that constraint hold without false rejections forced five choices that look arbitrary from the code alone — each one exists because the obvious alternative fails in a specific, reproducible way.
|
||||
The page is composed at runtime from independently loaded plugins, so the UI needs a composition mechanism that answers four questions with static force. Who may render into a region — and is that authority enforceable, or merely conventional? How does a component receive everything it needs while staying a pure function (no ctx, no framework imports), without every value being hand-threaded through assembly code? Where does business live-data live so that streaming updates re-render precisely the subscribers — without every plugin building its own subscription machinery? And how much of this can the compiler check, so that a drifted component, an over-reaching render call, or a mismatched store schema is a compile error at one visible call site rather than a runtime surprise?
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position
|
||||
One sentence: **the shell renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
|
||||
|
||||
`register()` constrains components as `SlotComponent<ComposedProps<K, NoInfer<I>>>` where `SlotComponent<P> = (props: P) => ReactNode`. React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations therefore checks those statics too, and the bottom-typed standard share (see ruling 4's `useSession: never`) makes those covariant checks reject components that narrow it — precisely the components the design wants to accept. The bare call signature checks through clean parameter contravariance only. Components stay ordinary functions; nothing observable changes at runtime.
|
||||
### 'root' is the only a-priori slot
|
||||
|
||||
### 2. `NoInfer<I>` pins the registrant share's inference to the inject factory
|
||||
`SlotsService` (client runtime) declares `'root'` at construction — single/root, `owner: {}` — and its `SlotMap` merge lives in the runtime package. The shell's entire assembly is `ctx.slots.renderSlot('root', {})`: the only ctx-level render entry; any other key, a missing renderer, or an unregistered root fails loud (no fallback).
|
||||
|
||||
`I` (the registrant's injected share) must be inferred from the `inject` factory's return type — the single authoritative source. Without `NoInfer`, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently WIDENS `I` to make the call check, absorbing the drift instead of reporting it. `NoInfer<I>` at the component position removes that candidate site, so negative sample ⑥ (a hand-drifted copy of the owner share fails at `register`) actually fails — with inference bleed it would pass. If the `NoInfer` ever gets "simplified away", the type-chain spec's expect-error site goes red first.
|
||||
### register is the single API; children = declaration + authorization + runtime spec
|
||||
|
||||
### 3. `ComposedProps` dispatches on the entry's `owner` key for progressive migration
|
||||
```ts ignore-check
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: createLayoutStore, // StoreHandle or factory (below)
|
||||
inject: injectFrame, // business face (below)
|
||||
}, AppFrame)
|
||||
```
|
||||
|
||||
`ComposedProps<K, I>` composes `owner & standard & I` only when the SlotMap entry declares an `owner` share; entries without one fall back to the legacy full-`props` constraint (`PropsShape`). This conditional is the migration seam: legacy declarations keep compiling unchanged while entries opt into the composed model one at a time, and both forms flow through the same `register()` overload — no parallel API, no flag. Removing the fallback branch is the flip-the-switch moment for the whole repo, not a cleanup.
|
||||
There is no separate slot-definition API. The `children` object both **declares the child slots into existence** and **authorizes this component to render them** — a slot is a hole in the render tree that exists because someone will render it, so its lifecycle is the declaring entry's lifecycle (entry disposed → slots gone, contributions cleared). The values are the runtime spec (`kind`/`scope` drive outlet iteration and binding selection; `SlotMap` is types-only and erased at runtime, which is why an array of keys could not work), statically checked against the `SlotMap` entry so type and value are declared at one point and cross-validated.
|
||||
|
||||
### 4. The standard share is bottom-typed, and bare `register` bivariance is accepted, not fought
|
||||
Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes.
|
||||
|
||||
Session slots' framework-supplied hook is constrained as `{ useSession: never }` (`StandardOf`): `never` in a parameter-ish position means any registrant narrowing (e.g. a runtime-typed conversation hook) is accepted, and the responsibility for what actually arrives lives with the injecting renderer. Known boundary rider: for components typed with METHOD syntax or otherwise bivariant parameter positions, TS can accept a `register` call it strictly shouldn't (parameter bivariance is unsound by design in TS). The accepted stance is documented rather than tested: we do not add negative samples that depend on strictness TS does not guarantee — they would pin compiler-version behavior, not our contract. The samples we do pin (six expect-error sites in `packages/client/ui-slots/tests/type-chain.spec.tsx`) all fail for contract reasons.
|
||||
`SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type").
|
||||
|
||||
### 5. `ChildrenChecked` is an opt-in validation layer keyed on the entry's `children` declaration
|
||||
### Component props: four shares, each from its own source of truth
|
||||
|
||||
Sub-slot delegation authority stays a hand-written whitelist (`slots: ScopedSlots<'a' | 'b'>` in the component's own props). `ChildrenChecked<K, P>` adds an optional second check: only when the entry declares `children` does the component's `slots` face get validated against the authorized union (violation collapses `slots` to `never`, surfacing at the register call). Entries without `children` pass through untouched. The hook point is inside `ComposedProps` — i.e. it fires exactly at the registration boundary, not at render — because register is where both halves (entry declaration, component face) are statically visible at once; a render-time check would need runtime plumbing for a purely static guarantee.
|
||||
| Share | Type | Source of truth | Contents |
|
||||
|---|---|---|---|
|
||||
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` |
|
||||
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S |
|
||||
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
|
||||
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
|
||||
|
||||
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
|
||||
|
||||
### The store seat: framework engine, registrant schema
|
||||
|
||||
The framework owns exactly one subscription machine: the snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads):
|
||||
|
||||
```ts ignore-check
|
||||
export function createChatStore() {
|
||||
return defineStore({
|
||||
init: () => ({ selection: null as SelectionTarget | null, draft: '' }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, t: SelectionTarget) => { d.selection = t },
|
||||
clearDraft:(d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
One factory, three consumption points: (a) `register` — pass the factory for an exclusive store, or call it once in `apply` and pass the same handle to several registers to share the instance (cross-plugin sharing is constructively impossible: the handle never leaves the package); (b) `PropsStore<ReturnType<typeof createChatStore>>` derives the component's store share with zero hand-written members; (c) tests call the factory and `.create()` a real engine instance, feeding `useSelector`/`actions` straight in as props — production outlets run the very same `create` path, so there is no second machinery.
|
||||
|
||||
Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation surface. Production code never calls the factory or `create` outside `apply`.
|
||||
|
||||
### inject: the registrant's business face, on its own ctx
|
||||
|
||||
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
|
||||
|
||||
### Data-boundary discipline
|
||||
|
||||
Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
|
||||
|
||||
### Tree context and the renderer seam
|
||||
|
||||
`SessionProvider` is a framework component **delivered as a standard-kit seat**: an entry whose `children` declare a session-scope slot receives it as a prop (type in ui-slots, value injected by the renderer) — components never value-import it. It is self-wired (it reads the runtime's current-session state internally; the assembler passes nothing), render-prop shaped — `children(sessionId)` with an `empty` branch, remounting under `key={sessionId}`. `BindingContext` is machinery-internal; business components see zero React contexts. Inject factories execute inside the outlet on purpose (per-entry error boundaries catch them; a crashing registrant blacks out only its own entry while assembly errors rethrow); the outlet reads tree context as a machinery-only implicit parameter — the "identity from the register closure, situation from the tree position" split.
|
||||
|
||||
Rendering lives behind an install seam so the runtime stays React-free: `SlotRenderer` (interface in ui-slots, implementation `createSlotRenderer()` in web-react) is installed once at shell boot via `ctx.slots.install(...)`; double install and render-before-install throw. Ownership bookkeeping is a single `Map<key, entry>` in the service — ledger, slots, contributions, render bindings, and store instances all live and die on the one entry axis, which closes the stale-authority window across plugin reloads by construction (a disposed entry's captured `renderSlot` throws a stale-authorization error on entry).
|
||||
|
||||
### Type-chain implementation rulings
|
||||
|
||||
Two hardening decisions in the register signature exist because the obvious alternative fails in a specific, reproducible way; a future editor should not re-litigate them:
|
||||
|
||||
1. **`SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position.** React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations checks those statics too and rejects components the design wants to accept. The bare call signature checks through clean parameter contravariance only; components stay ordinary functions.
|
||||
2. **`NoInfer<I>` pins the business share's inference to the inject factory.** Without it, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently widens `I` to make the call check — absorbing exactly the drift the chain exists to catch. The negative-sample spec pins this: if the `NoInfer` is ever "simplified away", the expect-error site goes red first.
|
||||
|
||||
## Consequences
|
||||
|
||||
The register call site is now the chain's single choke point: share drift, missing inject keys, unauthorized sub-slot faces, and keyed/list option omissions all surface there at compile time, and the six-sample negative spec pins each failure mode. Costs: the conditional types make hover-signatures at register sites noticeably wider; the bottom-typed standard share shifts arrival-type responsibility onto web-react's renderer (documented on `StandardOf`); and the bivariance boundary means one unsound-accept class is knowingly tolerated.
|
||||
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Keep `FC` and cast at register sites | The casts hide exactly the drift the chain exists to catch; FC statics' covariant noise is the mechanical cause, so remove the noise, not the check |
|
||||
| Infer `I` from the component parameter | Inference bleed absorbs props drift silently — negative sample ⑥ becomes unwritable |
|
||||
| Big-bang migration to composed props | Every SlotMap declarant lands in one PR; the `owner`-keyed conditional lets entries migrate one by one with both forms live |
|
||||
| Test the bivariant-accept edge as a negative sample | Would pin TS soundness behavior we don't own; compiler upgrades would break the spec without any contract change |
|
||||
| Derive delegation whitelists from `children` declarations | The hand-written face is the API the component author reads; derivation inverts ownership and was rejected at design level — `ChildrenChecked` validates instead of generating |
|
||||
| Separate define/register two-step API | The split leaves render authority unenforced and invites ordering bugs; children-in-register settles declaration, authorization, and spec in one visible place |
|
||||
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
|
||||
| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
|
||||
| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
|
||||
| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
|
||||
| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
|
||||
| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
|
||||
| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |
|
||||
|
||||
@@ -1,47 +1,109 @@
|
||||
# Agent Note: slot 类型链硬化——五条非显然实现裁定
|
||||
# Agent Note: slot 体系标准——单一 register、props 四份额与框架 store 席位
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-slot-type-chain-implementation.md) | 中文
|
||||
|
||||
> 范围:slot 注册/渲染类型链(`packages/client/ui-slots/src/index.ts`,消费方 `packages/client/web-react/src/scoped-slots.tsx`)为什么这样实现。设计层取舍(注册点推断优于声明表、手写白名单优于派生)住 Web 客户端架构 RFC;本文钉住五条实现决定——不写下来,将来的编辑者要么重新争论一遍,要么不经意地回退它们。
|
||||
> 范围:Web 客户端 slot 体系的终版设计——UI 插件如何拼合页面、渲染权威落在哪里、组件 props 如何定型、业务活数据住在哪里。周边语境(装载链、对象层、服务)归 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md) 所有,其 slot 各节移交本文。
|
||||
|
||||
## Problem
|
||||
|
||||
硬化后的类型链给从 `SlotMap` 声明到组件渲染的每一跳定型:owner 份额 + 框架标配份额 + 注册方注入份额组合成组件 props,在 `register()` 处校验。让这条约束既成立又不误伤,逼出了五个单看代码显得任意的选择——每一个的存在都是因为显然的替代方案会以一种具体的、可复现的方式失败。
|
||||
页面在运行时由各自独立装载的插件拼合而成,UI 因此需要一套能以静态强制力回答四个问题的组合机制。谁可以渲染进某块区域——这份权威是可强制执行的,还是仅靠约定?组件如何在保持纯函数(零 ctx、零框架 import)的同时拿到它需要的一切,而不必把每个值都经装配代码手工穿线?业务活数据住在哪里,才能让流式更新恰好只重渲染订阅者——而不必每个插件自建一套订阅机械?以及这一切有多少能交给编译器检查,让漂移的组件、越权的渲染调用、错配的 store schema 成为单一可见调用点上的编译错误,而非运行时的意外?
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. 注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`
|
||||
一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占坑、声明并授权子坑、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
|
||||
|
||||
`register()` 以 `SlotComponent<ComposedProps<K, NoInfer<I>>>` 约束组件,其中 `SlotComponent<P> = (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。
|
||||
### 'root' 是唯一的先验坑
|
||||
|
||||
### 2. `NoInfer<I>` 把注册方份额的推断钉在 inject 工厂上
|
||||
`SlotsService`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包(package)。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。
|
||||
|
||||
`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer` 时,TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer<I>` 移除了那个候选位,负样本⑥(owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。
|
||||
### register 是唯一 API;children = 声明+授权+运行时 spec
|
||||
|
||||
### 3. `ComposedProps` 按条目的 `owner` 键分派,支撑渐进迁移
|
||||
```ts ignore-check
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: createLayoutStore, // StoreHandle or factory (below)
|
||||
inject: injectFrame, // business face (below)
|
||||
}, AppFrame)
|
||||
```
|
||||
|
||||
`ComposedProps<K, I>` 只在 SlotMap 条目声明了 `owner` 份额时才组合 `owner & standard & I`;未声明的条目回落到 legacy 全量 `props` 约束(`PropsShape`)。这个条件类型就是迁移接缝:legacy 声明原样编译,条目逐个转入组合模型,两种形态走同一个 `register()`——无平行 API、无开关旗。删掉回落分支的那一刻=全仓切换时刻,不是一次清理。
|
||||
不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。
|
||||
|
||||
### 4. 标配份额 bottom 型化;裸 `register` 的双变接受面认账不硬测
|
||||
对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。
|
||||
|
||||
session 坑的框架供给 hook 约束为 `{ useSession: never }`(`StandardOf`):参数性位置上的 `never` 意味着任何注册方收窄(如 runtime 定型的会话 hook)都被接受,实际到达什么的类型责任归注入侧渲染器。已知边界搭车项:对以方法语法定型或参数位本就双变的组件,TS 可能接受一个严格意义上不该过的 `register` 调用(参数双变是 TS 的有意不健全)。这个立场以文档记账而不加测试:我们不写依赖 TS 并不承诺的严格性的负样本——那钉住的是编译器版本行为,不是我们的契约。真正钉住的六个 expect-error 位(`packages/client/ui-slots/tests/type-chain.spec.tsx`)全部因契约原因失败。
|
||||
`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。
|
||||
|
||||
### 5. `ChildrenChecked` 是按条目 `children` 声明挂载的 opt-in 校验层
|
||||
### 组件 props:四份额,各有唯一真源
|
||||
|
||||
子坑转授权威仍是手写白名单(组件自己 props 上的 `slots: ScopedSlots<'a' | 'b'>`)。`ChildrenChecked<K, P>` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。
|
||||
| 份额 | 类型 | 真源 | 内容 |
|
||||
|---|---|---|---|
|
||||
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
|
||||
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S |
|
||||
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
|
||||
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
|
||||
|
||||
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
|
||||
|
||||
### store 席位:引擎归框架,schema 归注册方
|
||||
|
||||
框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
|
||||
|
||||
```ts ignore-check
|
||||
export function createChatStore() {
|
||||
return defineStore({
|
||||
init: () => ({ selection: null as SelectionTarget | null, draft: '' }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, t: SelectionTarget) => { d.selection = t },
|
||||
clearDraft:(d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
一个工厂,三个消费点:① `register`——独占 store 直接传工厂;要共享实例,则在 `apply` 里调用一次工厂、把同一句柄传给多次 register(跨插件共享构造性不可能:句柄从不出包);② `PropsStore<ReturnType<typeof createChatStore>>` 推导出组件的 store 份额,零手写成员;③ 测试自己调用工厂并 `.create()` 出真引擎实例,把 `useSelector`/`actions` 直接当 props 喂进去——生产 outlet 走的正是同一条 `create` 路径,不存在第二套机械。
|
||||
|
||||
store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会话一个实例,随会话生灭;root 坑→每个 entry 一个)。读 = `props.useStore`;写 = 仅 `props.actions.*`——裸实例(带 `update`/`set`)永远到不了组件,声明的 actions 就是完整且可审计的变更面。生产代码在 `apply` 之外从不调用工厂或 `create`。
|
||||
|
||||
### inject:注册方的业务面,立足自己的 ctx
|
||||
|
||||
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
|
||||
|
||||
### 数据界线纪律
|
||||
|
||||
hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
|
||||
|
||||
### 树上语境与渲染器安装缝
|
||||
|
||||
`SessionProvider` 是框架组件,**以标配席形式送达**:`children` 里声明了 session scope 坑的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。
|
||||
|
||||
渲染住在一条安装缝之后,runtime 因此保持 React-free:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map<key, entry>`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。
|
||||
|
||||
### 类型链实现裁定
|
||||
|
||||
register 签名里的两条硬化裁定之所以存在,是因为显然的替代方案会以具体、可复现的方式失败;将来的编辑者不应重新争论它们:
|
||||
|
||||
1. **注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`。** React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性检查连这些静态位一起查,会拒绝设计本想接受的组件。裸调用签名只走干净的形参逆变检查;组件仍是普通函数。
|
||||
2. **`NoInfer<I>` 把业务份额的推断钉在 inject 工厂上。** 没有它,TS 还会从组件形参位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默把 `I` 加宽到让调用通过——恰好吸收掉类型链本要抓的漂移。负样本 spec 钉住这一点:若这个 `NoInfer` 日后被「顺手简化」掉,expect-error 位会第一个变红。
|
||||
|
||||
## Consequences
|
||||
|
||||
register 调用点成为全链唯一收口:份额漂移、inject 键缺失、越权子坑面、keyed/list options 缺省全部在编译期于此暴露,六样本负样本 spec 逐一钉住失败模式。代价:条件类型让 register 位的悬停签名明显变宽;bottom 型标配份额把到达类型的责任转给 web-react 渲染器(记录于 `StandardOf`);双变边界意味着一类不健全接受被知情容忍。
|
||||
渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| 保留 `FC`、在 register 位 cast | cast 恰好藏起类型链要抓的漂移;FC 静态位的协变噪音是机械成因,该移除噪音而非移除检查 |
|
||||
| 从组件参数位推断 `I` | 推断渗漏静默吸收 props 漂移——负样本⑥无从写起 |
|
||||
| 组合 props 一次性全仓迁移 | 所有 SlotMap 声明方挤进一个 PR;`owner` 键分派让条目逐个迁移、两形态共存 |
|
||||
| 给双变接受边缘加负样本 | 钉住的是我们不拥有的 TS 健全性行为;编译器升级会在契约零变化时打红 spec |
|
||||
| 从 `children` 声明派生转授白名单 | 手写面才是组件作者读到的 API;派生反转所有权,设计层已否——`ChildrenChecked` 做校验不做生成 |
|
||||
| 独立的 define/register 两步式 API | 拆分让渲染权威无从强制、招来时序 bug;children 进 register 让声明、授权、spec 在同一个可见位置结清 |
|
||||
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
|
||||
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
|
||||
| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
|
||||
| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
|
||||
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
|
||||
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
|
||||
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
|
||||
|
||||
@@ -15,7 +15,7 @@ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-b
|
||||
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*/*']` (explicit globs keep bundling to vendored Cordis and the TypeScript package tree; `workspace: true` would also discover example manifests and non-bundled workspace members).
|
||||
- Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build Agent Note](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler.
|
||||
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape).
|
||||
- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`.
|
||||
- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b && tsdown` (the root solution owns the emit graph).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Agent Note: TSC-first build and one tsconfig
|
||||
# Agent Note: TSC-first build and one compiler ownership
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged.
|
||||
|
||||
## Problem
|
||||
|
||||
The current TypeScript build and typecheck setup had these issues:
|
||||
@@ -28,29 +30,29 @@ In-package relative imports use explicit `.ts` specifiers.
|
||||
|
||||
`pnpm run build` is a two-stage build:
|
||||
|
||||
- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`.
|
||||
- The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results.
|
||||
- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`.
|
||||
- The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results.
|
||||
- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations.
|
||||
|
||||
`tsdown` is no longer the owner of TypeScript compilation or declaration output.
|
||||
|
||||
`pnpm run typecheck` runs build mode over the root `tsconfig.json`.
|
||||
- The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references.
|
||||
- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`.
|
||||
- The root no-emit project disables `rewriteRelativeImportExtensions`; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled.
|
||||
`pnpm run typecheck` runs the same `tsc -b` graph.
|
||||
- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references.
|
||||
- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`.
|
||||
- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled.
|
||||
|
||||
The command orchestration shape is:
|
||||
|
||||
```sh
|
||||
pnpm run build:
|
||||
tsc -b tsconfig.build.json
|
||||
tsc -b
|
||||
tsdown
|
||||
|
||||
pnpm run verify-node-next-types:
|
||||
tsx scripts/verify-node-next-types.ts
|
||||
|
||||
pnpm run typecheck:
|
||||
tsc -b tsconfig.json
|
||||
tsc -b
|
||||
```
|
||||
|
||||
`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
|
||||
@@ -65,7 +67,7 @@ tsc -b tsconfig.json
|
||||
Build responsibilities are clearer:
|
||||
|
||||
- Each module under `packages/<group>/<pkg>` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`.
|
||||
- The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
|
||||
- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
|
||||
- `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output.
|
||||
- `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files.
|
||||
- `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target.
|
||||
|
||||
@@ -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
|
||||
2026-07-19-web-styling-system.md: c80ef0d56a0e57b38fbb52bd07cbc0f69ec85912
|
||||
2026-07-19-web-styling-system.zh.md: 59013a4a950196f3a065ac18415f9b5ed42f3ec3
|
||||
2026-07-19-web-styling-system.md: b4d647924ab6ab172cd7a7e2531a10a2a7e62981
|
||||
2026-07-19-web-styling-system.zh.md: 01064d4d52b3ed2b179a4795f5113b94480945bd
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override). Current authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15.
|
||||
> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override) — the sheets themselves are the token authority.
|
||||
|
||||
English | [中文](2026-07-19-web-styling-system.zh.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)。现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15。
|
||||
> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)——样式表本身即 token 权威。
|
||||
|
||||
[English](2026-07-19-web-styling-system.md) | 中文
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112
|
||||
2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933
|
||||
2026-07-20-gui-testing-system.md: fdd5c7f9d33f9a90ea4afe145265be5fe93e0fc2
|
||||
2026-07-20-gui-testing-system.zh.md: 0ae08133742711b87e9155ddc6f3104b757c1b55
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Current test-system authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18.
|
||||
> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership/registry semantics are tier-2 territory (`runtime` + `ui-slots` suites), not component specs.
|
||||
|
||||
English | [中文](2026-07-20-gui-testing-system.zh.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。测试体系现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18。
|
||||
> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的零机械路径),框架 hook 用普通桩;无渲染机械、不挂 provider。坑位归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。
|
||||
|
||||
[English](2026-07-20-gui-testing-system.md) | 中文
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-22-cordis-tutorial-docs.md: 45abb8f524218ce0b2678606ae62c7bf2dbca00b
|
||||
2026-07-22-cordis-tutorial-docs.zh.md: cd1a62e2a6e7f2e28bc32109dd67746840f8b8b7
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Tutorial-style Cordis docs under docs/cordis-tutorial
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-cordis-tutorial-docs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The repo documents Cordis at two levels: the condensed [cordis-primer](../../../../docs/cordis-primer.md) states the concepts, and the `docs/user/develop/` pages teach harness plugin authoring against harness services. Neither serves a developer meeting Cordis itself for the first time: the primer assumes the reader already writes plugins, and the develop pages jump straight to `defineTool` without showing how contexts, fibers, services, and dispatch actually behave. There was no path where a reader runs bare Cordis, watches a fiber go PENDING, or sees a waterfall veto happen.
|
||||
|
||||
## Decision
|
||||
|
||||
`docs/cordis-tutorial/` holds a seven-chapter hands-on tutorial (first plugin → lifecycle/effects → services → events → config → composition/HMR → harness tool). Its properties, in decreasing order of load-bearing-ness:
|
||||
|
||||
- **Every transcript is real.** Each chapter's files run in the gitignored `tmp/cordis-tutorial/` scratch directory via `node --import tsx ../../vendor/cordis/bin.js`, and the shown output is what those commands print. The chapter that uses harness packages (`@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-llm`) runs keylessly.
|
||||
- **dsh-flavored, not pure Cordis**: later chapters use real harness services and events (`ctx.tools`, `tools/result`) so the tutorial lands the reader inside this repo's actual composition model, per the requesting user's choice.
|
||||
- **English-only, published to both website locales** through `mirroredPages()` in [website/docs.ts](../../../../website/docs.ts) under a `Cordis 教程` / `Cordis tutorial` section of the develop sidebar — the same pattern as the reference pages, so a Chinese pair can ratchet in later without route changes.
|
||||
- Code fences compile under `doc-typecheck` except the two fences that import scratch-relative files (`./stats.ts`) or intentionally throw, which carry `ignore-check`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Under `docs/user/develop/` as paired product docs.** That tier requires en+zh+i18n records in the same PR, roughly doubling the change and coupling every future tutorial edit to a translation. Rejected for the first landing; the mirrored projection keeps the same public visibility.
|
||||
|
||||
**Pure-Cordis tutorial with no harness packages.** Cleaner as framework documentation, but the audience is agent developers extending this harness; ending at `ctx.tools.execute` and `tools/result` teaches the composition they will actually work in. The user chose this explicitly.
|
||||
|
||||
**Extending the primer instead of a new directory.** The primer is a 600-word budgeted concept reference; a multi-chapter walkthrough inside it would break its tier's job (and its budget) rather than complement it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A runnable introduction to Cordis exercises the loader, fiber states, effects, service injection, all five dispatch-mode contracts, Schemastery validation, and HMR. It demonstrates PENDING dependencies and validation failure; it explains the loader's logged unresolved-entry failure because that boot-time log may not reach a console exporter.
|
||||
- The tutorial's transcripts pin behavior informally but are not snapshot-gated; if loader or HMR behavior changes, the transcripts drift until a human replays the chapters. The compile gate covers only the code fences.
|
||||
- The chapters name concrete harness APIs (`ctx.tools.execute`, `CallId`, `tools/result`); renames must update the tutorial like any other doc reference (`verify-md-links` catches file moves, not API prose).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: `docs/cordis-tutorial` 下的 Cordis 实操教程文档
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-cordis-tutorial-docs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
本仓库从两个层面介绍 Cordis:精简的 [cordis-primer](../../../../docs/cordis-primer.md) 阐述概念,`docs/user/develop/` 下的页面则讲解如何基于 harness 服务编写 harness 插件。但二者都不适合初次接触 Cordis 的开发者:primer 假定读者已经会编写插件,开发页面则直接从 `defineTool` 讲起,没有展示上下文、fiber、服务和 dispatch 的实际行为。此前没有一条学习路径让读者运行原生 Cordis、观察 fiber 进入 PENDING 状态,或看到 waterfall(瀑布式事件)否决实际发生。
|
||||
|
||||
## 决策
|
||||
|
||||
`docs/cordis-tutorial/` 包含一套七章实操教程(第一个插件 → 生命周期与 effect → 服务 → 事件 → 配置 → 组合与 HMR(热模块替换)→ harness 工具)。以下是教程的特性,按重要性从高到低排列:
|
||||
|
||||
- **每段 transcript(文本记录)都真实可复现。** 每章文件都通过 `node --import tsx ../../vendor/cordis/bin.js` 在 git 忽略的 `tmp/cordis-tutorial/` 临时目录中运行,展示的输出就是这些命令实际打印的内容。使用 harness 包(package)(`@deepseek-ai/dsh-tools` 和 `@deepseek-ai/dsh-llm`)的章节无需密钥即可运行。
|
||||
- **采用 dsh 风格,而非纯 Cordis**:后续章节使用真实的 harness 服务和事件(`ctx.tools`、`tools/result`),使读者最终进入本仓库实际采用的组合模型,这遵循了提出请求的用户所作的选择。
|
||||
- **仅提供英文版,但发布到网站的两个语言区域**:通过 [website/docs.ts](../../../../website/docs.ts) 中的 `mirroredPages()`,发布到开发侧边栏的 `Cordis 教程` / `Cordis tutorial` 分区。该方式与参考页面采用的模式相同,因此日后可以逐步纳入中文配对,而无需更改路由。
|
||||
- 除两个围栏代码块外,其余代码块均通过 `doc-typecheck` 编译;这两个例外分别导入临时目录中的相对路径文件(`./stats.ts`)或有意抛出异常,因此标有 `ignore-check`。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**作为双语产品文档放在 `docs/user/develop/` 下。** 该层级要求在同一个 PR(Pull Request)中同时提供英文、中文和 i18n 记录,这会使变更量大致翻倍,并要求未来每次修改教程时都同步翻译。首次落地不采用此方案;镜像投影仍可保持同等的公开可见性。
|
||||
|
||||
**不使用任何 harness 包的纯 Cordis 教程。** 作为框架文档会更简洁,但目标读者是扩展此 harness 的 agent(智能体)开发者;以 `ctx.tools.execute` 和 `tools/result` 收尾,能讲清他们实际使用的组合方式。用户明确选择了此方案。
|
||||
|
||||
**扩充 primer,而非新建目录。** primer 是一份预算上限为 600 词的精简概念参考;在其中加入多章演练会破坏该文档层级的职责及其篇幅预算,而非形成补充。
|
||||
|
||||
## 结果
|
||||
|
||||
- 现在有了一份可运行的 Cordis 入门教程,涵盖 loader、fiber 状态、effect、服务注入、全部五种 dispatch 模式的契约、Schemastery 校验和 HMR。教程实际展示了依赖处于 PENDING 状态和配置校验失败;对于 loader 记录的配置项解析失败,教程只作说明,因为启动阶段的日志可能无法到达控制台导出器。
|
||||
- 教程中的 transcript 以非正式方式固定了行为,但没有快照门禁;如果 loader 或 HMR 的行为发生变化,transcript 会逐渐偏离实际结果,直到有人重新运行各章。编译门禁只覆盖围栏代码块。
|
||||
- 各章写明了具体的 harness API(`ctx.tools.execute`、`CallId`、`tools/result`);这些 API 重命名时,必须像更新其他文档引用一样同步修改教程(`verify-md-links` 能发现文件移动,但无法发现 API 文字引用变化)。
|
||||
@@ -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
|
||||
2026-07-22-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56
|
||||
2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Solution root over two aggregate programs
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries:
|
||||
|
||||
- The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph).
|
||||
- The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI.
|
||||
- tsserver discovers only configs named `tsconfig.json`, so client test files sat on no discoverable config chain and fell back to inferred projects (no paths, wrong lib/jsx).
|
||||
- The vitest configs pointed at three different resolution sources (`tsconfig.vitest.json`, the root config, and one hand-written alias).
|
||||
|
||||
## Decision
|
||||
|
||||
One solution root, two check units, one shared base pair, no separate build or vitest config:
|
||||
|
||||
| File | Role | Forms a program? |
|
||||
|---|---|---|
|
||||
| `tsconfig.json` | Solution root: `extends` base, `files: []`, two references; the whole-repo `tsc -b tsconfig.json` graph, the tsserver entry, and the nearest config for get-tsconfig consumers (tsx running `examples/`, `scripts/`, doc fences) whose bare workspace imports resolve through the inherited `paths` | No |
|
||||
| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map; doubles as the resolution facade for vite-tsconfig-paths (no `include`, so it applies to every importer) | No |
|
||||
| `tsconfig.base.client.json` | Browser compiler shape (`jsx: react-jsx`, DOM libs, `types: []`) shared by the client aggregate and every `packages/client/*` package | No |
|
||||
| `tsconfig.host.json` | The former root aggregate, moved verbatim: host packages, examples, tests, scripts, website; excludes `packages/client` | Yes |
|
||||
| `tsconfig.client.json` | Client packages and their tests; extends `tsconfig.base.client.json` | Yes |
|
||||
|
||||
The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives.
|
||||
|
||||
Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`.
|
||||
|
||||
The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Rename `tsconfig.build.json` to `tsconfig.host.json`** — rejected: the build graph was the full emit graph including all client packages, not a host graph; the name that fits the former root aggregate is `tsconfig.host.json`, and the build graph itself is subsumed by the solution.
|
||||
- **Point vitest at the root solution** — rejected: a solution has neither `paths` nor `include`, so resolution would become a function of how far the plugin walks references, and the client aggregate's include (tests only, no src) would leave transitive src→src imports unmapped, falling through to `exports` and loading a second copy of module singletons.
|
||||
- **Keep `tsconfig.vitest.json` as a dedicated facade** — retained only as the fallback if vite-tsconfig-paths mishandles an include-less config; the base file already carries the paths map, and an include-less config applies everywhere, which is strictly wider than the facade's hand-kept include list.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions.
|
||||
- The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note.
|
||||
- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration.
|
||||
- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 以 solution 根文件统辖两个聚合 program
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-tsconfig-solution-root-two-aggregates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称:
|
||||
|
||||
- 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。
|
||||
- lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。
|
||||
- tsserver 只发现名为 `tsconfig.json` 的配置,客户端测试文件不在任何可发现的配置链上,回落到推断项目(inferred project),既没有 paths,lib/jsx 也不对。
|
||||
- 各 vitest 配置指向三个不同的解析来源(`tsconfig.vitest.json`、根配置,外加一处手写别名)。
|
||||
|
||||
## 决策
|
||||
|
||||
一个 solution 根文件,两个检查单元,一对共享 base,不再单设 build 或 vitest 配置:
|
||||
|
||||
| 文件 | 角色 | 是否构成 program? |
|
||||
|---|---|---|
|
||||
| `tsconfig.json` | solution 根文件:`extends` base、`files: []`、两条 references;同时是全仓 `tsc -b tsconfig.json` 图、tsserver 入口,以及 get-tsconfig 消费方(tsx 运行 `examples/`、`scripts/`、文档围栏代码块)就近命中的配置,其裸 workspace 导入经继承来的 `paths` 解析 | 否 |
|
||||
| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射;兼任 vite-tsconfig-paths 的解析门面(不含 `include`,因此对每个导入方都生效) | 否 |
|
||||
| `tsconfig.base.client.json` | 浏览器侧编译形态(`jsx: react-jsx`、DOM lib、`types: []`),由客户端聚合与每个 `packages/client/*` 包共享 | 否 |
|
||||
| `tsconfig.host.json` | 原根聚合原样迁入:宿主各包、examples、测试、scripts、website;排除 `packages/client` | 是 |
|
||||
| `tsconfig.client.json` | 客户端各包及其测试;通过 `extends` 继承 `tsconfig.base.client.json` | 是 |
|
||||
|
||||
整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。
|
||||
|
||||
各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。
|
||||
|
||||
solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把 `tsconfig.build.json` 改名为 `tsconfig.host.json`**——不予采纳:构建图是包含全部客户端包的全量 emit 图,不是宿主图;`tsconfig.host.json` 这个名字对应的是原根聚合,而构建图本身已被 solution 吸收。
|
||||
- **让 vitest 指向根 solution**——不予采纳:solution 既没有 `paths` 也没有 `include`,解析结果将取决于插件沿 references 走多远;且客户端聚合的 include 只收测试、不收 src,传递的 src→src 导入会失去映射,回落到 `exports`,加载出模块单例的第二份副本。
|
||||
- **保留 `tsconfig.vitest.json` 作为专用门面**——仅保留为后备方案:若 vite-tsconfig-paths 处理不了无 include 的配置再启用;base 文件已经携带 paths 映射,而无 include 的配置处处生效,严格宽于该门面手工维护的 include 列表。
|
||||
|
||||
## 后果
|
||||
|
||||
- `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。
|
||||
- [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。
|
||||
- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。
|
||||
- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。
|
||||
@@ -6,13 +6,13 @@ Status: proposed
|
||||
|
||||
Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points.
|
||||
|
||||
The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages/<group>/<pkg>` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form).
|
||||
The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages/<group>/<pkg>` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly the aggregate configs' (`tsconfig.host.json`, `tsconfig.client.json`) project `references`, which TypeScript requires as explicit arrays (no wildcard form).
|
||||
|
||||
Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy.
|
||||
|
||||
## Proposal
|
||||
|
||||
Make the remaining package/gate inventories discoverable. A single canonical source — the `packages/<group>/<pkg>` hierarchy plus package manifests — should drive `tsconfig.build.json`'s `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list.
|
||||
Make the remaining package/gate inventories discoverable. A single canonical source — the `packages/<group>/<pkg>` hierarchy plus package manifests — should drive the aggregates' `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list.
|
||||
|
||||
The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart.
|
||||
|
||||
@@ -20,7 +20,7 @@ One cataloged item needs no generator at all: folding the e2e entry glob into kn
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `tsconfig.build.json` project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained.
|
||||
- Aggregate-config project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained.
|
||||
- Adding a package does not require editing a static package list for any gate.
|
||||
- Docs describe the source of truth rather than repeating generated inventories.
|
||||
- CI invokes the aggregate commands and lets those commands own their sub-gate lists.
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
@@ -525,7 +525,7 @@ jobs:
|
||||
if: matrix.platform == 'linux'
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
- **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent.
|
||||
- **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string`.
|
||||
- **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries.
|
||||
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
|
||||
- **`ts.Program` consumers seed `tsconfig.host.json` or `tsconfig.client.json`, never the root solution** — one program holding both sides collides the cordis `Context` merges ([layout](docs/development.md#typescript-project-layout)).
|
||||
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
|
||||
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
|
||||
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
|
||||
|
||||
@@ -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
|
||||
README.md: 8b34d6177834e1c410b2c3ecaf32154de42520b4
|
||||
README.zh.md: cf029cc0bb8c5aa527d14753803eac4c28ab9de7
|
||||
README.md: 27774fd3e0ffc821e7287f6153906a5d21530dc2
|
||||
README.zh.md: b7d08f2bc948d0a6d388dd672c5a702bc7da6f6f
|
||||
|
||||
68
README.md
68
README.md
@@ -2,32 +2,76 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **DeepSeek Harness SDK** is a plugin-based SDK for building agent harnesses.
|
||||
DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.
|
||||
|
||||
It uses an architecture where **everything is a plugin**.
|
||||
|
||||
## Install
|
||||
|
||||
Install the `dsh` coding agent with one line — it needs `git` and Node `^22.19 || >=24`, and offers to install `pnpm` if it is missing:
|
||||
Install `dsh` with one command:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
It clones the harness to `~/.dsh/source`, runs `pnpm install`, symlinks `dsh` into `~/.local/bin` (offering to add it to your PATH), prompts once for your `DEEPSEEK_API_KEY`, and launches `dsh`; re-running it updates an existing checkout. Run from inside a checkout (`sh scripts/install.sh`) it reuses that checkout and skips the clone. The overridable `DSH_*` variables are documented in [`scripts/install.sh`](scripts/install.sh).
|
||||
The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.
|
||||
|
||||
The installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.
|
||||
|
||||
## Use DeepSeek Harness
|
||||
|
||||
### Web UI
|
||||
|
||||
For the recommended local interface, build the frontend after installation and after each update, then start the Web UI:
|
||||
|
||||
```sh
|
||||
pnpm --dir ~/.dsh/source run build:web
|
||||
dsh web
|
||||
```
|
||||
|
||||
The Web UI is served at `http://127.0.0.1:3080` by default.
|
||||
|
||||
### TUI
|
||||
|
||||
Start the full-screen terminal interface:
|
||||
|
||||
```sh
|
||||
dsh
|
||||
```
|
||||
|
||||
### Headless
|
||||
|
||||
Run one task, print the final answer, and exit:
|
||||
|
||||
```sh
|
||||
dsh -p "summarize this workspace"
|
||||
```
|
||||
|
||||
## Why DeepSeek Harness
|
||||
|
||||
Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.
|
||||
|
||||
- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.
|
||||
- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).
|
||||
- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).
|
||||
|
||||
## Community
|
||||
|
||||
Follow <a href="https://x.com/Deepseekharness">DeepSeek Harness on Twitter</a> for project updates.
|
||||
|
||||
## Development
|
||||
|
||||
This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) framework (vendored as source under `vendor/`), microkernel-style: everything is a plugin.
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm run test # vitest
|
||||
# Agent demos require DEEPSEEK_API_KEY.
|
||||
pnpm run demo:tui # full-screen TUI coding agent
|
||||
pnpm run demo:headless "task" # one-shot coding agent
|
||||
pnpm run demo:cordis # self-referential agent demo
|
||||
pnpm run demo:acp # ACP server agent demo
|
||||
pnpm run test:coverage
|
||||
```
|
||||
|
||||
For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) and [documentation graph index](docs/graph-atlas.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/).
|
||||
Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.
|
||||
|
||||
For agents, follow [AGENTS.md](AGENTS.md).
|
||||
|
||||
DeepSeek Harness is currently pre-release.
|
||||
|
||||
## License
|
||||
|
||||
[BSD 3-Clause](LICENSE)
|
||||
|
||||
72
README.zh.md
72
README.zh.md
@@ -2,32 +2,80 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**DeepSeek Harness SDK** 是用于构建 agent harness(智能体框架)的 SDK,采取基于插件的设计。
|
||||
DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。
|
||||
|
||||
它采用了**一切皆插件**的架构。
|
||||
|
||||
## 安装
|
||||
|
||||
一行命令即可安装 `dsh` 编码智能体——需要 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时会询问是否代为安装:
|
||||
使用一条命令安装 `dsh`:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
脚本会把 harness 克隆到 `~/.dsh/source`,运行 `pnpm install`,把 `dsh` 软链接到 `~/.local/bin`(并询问是否加入 PATH),提示输入一次 `DEEPSEEK_API_KEY`,随后启动 `dsh`;再次运行会更新已有的检出。若在检出目录内运行(`sh scripts/install.sh`),脚本会复用当前检出并跳过克隆。可覆盖的 `DSH_*` 变量见 [`scripts/install.sh`](scripts/install.sh)。
|
||||
安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。
|
||||
|
||||
安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。
|
||||
|
||||
## 使用 DeepSeek Harness
|
||||
|
||||
### Web UI
|
||||
|
||||
推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI:
|
||||
|
||||
```sh
|
||||
pnpm --dir ~/.dsh/source run build:web
|
||||
dsh web
|
||||
```
|
||||
|
||||
Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
|
||||
|
||||
### TUI
|
||||
|
||||
启动全屏终端界面:
|
||||
|
||||
```sh
|
||||
dsh
|
||||
```
|
||||
|
||||
### Headless
|
||||
|
||||
运行一项任务,打印最终答案后退出:
|
||||
|
||||
```sh
|
||||
dsh -p "summarize this workspace"
|
||||
```
|
||||
|
||||
## 为什么选择 DeepSeek Harness
|
||||
|
||||
内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。
|
||||
|
||||
- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。
|
||||
- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。
|
||||
- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。
|
||||
|
||||
## 社区
|
||||
|
||||
扫描二维码,或打开 <a href="https://wj.qq.com/s2/27234598/03eb/">DeepSeek Harness 微信社区申请页面</a> 申请加入。
|
||||
|
||||
<p>
|
||||
<img src="assets/community-wecom-survey.png" alt="DeepSeek Harness 微信社区二维码" width="240">
|
||||
</p>
|
||||
|
||||
## 开发
|
||||
|
||||
本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:所有功能都以插件形式提供。
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm run test # vitest
|
||||
# Agent demos require DEEPSEEK_API_KEY.
|
||||
pnpm run demo:tui # full-screen TUI coding agent
|
||||
pnpm run demo:headless "task" # one-shot coding agent
|
||||
pnpm run demo:cordis # self-referential agent demo
|
||||
pnpm run demo:acp # ACP server agent demo
|
||||
pnpm run test:coverage
|
||||
```
|
||||
|
||||
面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。
|
||||
请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。
|
||||
|
||||
面向 agent:遵循 [AGENTS.md](AGENTS.md)。
|
||||
|
||||
DeepSeek Harness 目前处于预发布阶段。
|
||||
|
||||
## 许可证
|
||||
|
||||
[BSD 3-Clause](LICENSE)
|
||||
|
||||
BIN
assets/community-wecom-survey.png
Normal file
BIN
assets/community-wecom-survey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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
|
||||
adding-a-package.md: 556a48493af4452c178634c0abb4e23e2419dd8e
|
||||
adding-a-package.zh.md: 5f7e4692233448c746d25e4808c078c390cf39e6
|
||||
adding-a-package.md: 1859310965538b35a353ee05c94b01d1093a3e43
|
||||
adding-a-package.zh.md: 22f574a0469609e44f5c55957560ff0f04b9a053
|
||||
|
||||
@@ -32,10 +32,11 @@ In-package relative imports use explicit `.ts` specifiers in source (for example
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages/<group>/*/src` candidate to the `@deepseek-ai/dsh-*` wildcard |
|
||||
| `tsconfig.json` | add `{ "path": "./packages/<group>/<pkg>" }` to `references` |
|
||||
| `tsconfig.build.json` | add `{ "path": "./packages/<group>/<pkg>" }` to `references` |
|
||||
| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages/<group>/<pkg>" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) |
|
||||
| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) |
|
||||
|
||||
A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract.
|
||||
|
||||
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`.
|
||||
|
||||
## 3. Decide the package topology
|
||||
|
||||
@@ -32,10 +32,11 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages/<group>/*/src` 候选路径 |
|
||||
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
|
||||
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
|
||||
| `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) |
|
||||
| `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) |
|
||||
|
||||
`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。
|
||||
|
||||
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。
|
||||
|
||||
## 3. 确定包拓扑
|
||||
|
||||
@@ -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
|
||||
adding-a-vendored-package.md: 1b82f2e582ca5cd040a7f3237505848dbb304fae
|
||||
adding-a-vendored-package.zh.md: 7245682ef8b7d85ace2c626f8d47aa36f739506b
|
||||
adding-a-vendored-package.md: 71ca9fccc9418348784dbb6668127242e4fb45d2
|
||||
adding-a-vendored-package.zh.md: c340630aebeda0ec293a835cdfc8d15d71cd7801
|
||||
|
||||
@@ -38,8 +38,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts`
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | add `"<npm-name>": ["./vendor/<dir>/src"]` to `paths` |
|
||||
| `tsconfig.json` | add `{ "path": "./vendor/<dir>" }` to `references` |
|
||||
| `tsconfig.build.json` | add `{ "path": "./vendor/<dir>" }` to `references` (before the `packages/*` entries) |
|
||||
| `tsconfig.host.json` | add `{ "path": "./vendor/<dir>" }` to `references` (before the `packages/*` entries; vendored code enters the graph through the host aggregate only) |
|
||||
| `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications |
|
||||
| `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) |
|
||||
|
||||
@@ -57,4 +56,4 @@ pnpm run typecheck
|
||||
pnpm run build && pnpm run test && pnpm run constraints
|
||||
```
|
||||
|
||||
The source `paths` map is shared by build and root typecheck configs. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into a root strict program.
|
||||
The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)).
|
||||
|
||||
@@ -38,8 +38,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显
|
||||
| 文件 | 修改内容 |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | 在 `paths` 中添加 `"<npm-name>": ["./vendor/<dir>/src"]` |
|
||||
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }` |
|
||||
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }`(置于 `packages/*` 条目之前) |
|
||||
| `tsconfig.host.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }`(置于 `packages/*` 条目之前;vendored 代码只经 host 聚合进图) |
|
||||
| `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 |
|
||||
| `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) |
|
||||
|
||||
@@ -57,4 +56,4 @@ pnpm run typecheck
|
||||
pnpm run build && pnpm run test && pnpm run constraints
|
||||
```
|
||||
|
||||
源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入根目录的严格程序中。
|
||||
源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。
|
||||
|
||||
@@ -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
|
||||
cordis-primer.md: 39d3d97b9ac43fec50cb0c832af449fc8bc6232f
|
||||
cordis-primer.zh.md: f941c90489e1153910ed809d2cef30ece8539f8f
|
||||
cordis-primer.md: ee65e6e702ecaeb506ce7334032c38e09c936cda
|
||||
cordis-primer.zh.md: ceb9916f19dde35141405caee82e1c7794147193
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](cordis-primer.zh.md)
|
||||
|
||||
Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md).
|
||||
Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; the [Cordis tutorial](cordis-tutorial/index.md) walks the same ideas hands-on. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md).
|
||||
|
||||
## Cordis In Five Ideas
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](cordis-primer.md) | 中文
|
||||
|
||||
Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。本文介绍 harness 插件作者在阅读生成的[事件](cordis-catalog/events.md)与[服务](cordis-catalog/services.md)目录之前需要了解的 Cordis 核心概念。vendor 源码与同步流程见 [vendor/README.md](../vendor/README.md)。
|
||||
Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。本文介绍 harness 插件作者在阅读生成的[事件](cordis-catalog/events.md)与[服务](cordis-catalog/services.md)目录之前需要了解的 Cordis 核心概念;[Cordis 教程](cordis-tutorial/index.md)则通过实践逐一讲解这些概念。vendor 源码与同步流程见 [vendor/README.md](../vendor/README.md)。
|
||||
|
||||
## 五个核心概念
|
||||
|
||||
|
||||
93
docs/cordis-tutorial/01-first-plugin.md
Normal file
93
docs/cordis-tutorial/01-first-plugin.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 1. Your first plugin
|
||||
|
||||
In the loader configuration used here, a Cordis plugin module named-exports an `apply` function. When Cordis loads it, it calls `apply` with a **context** — the `ctx` object through which the plugin registers everything it contributes.
|
||||
|
||||
## Write the plugin
|
||||
|
||||
In your `tmp/cordis-tutorial` directory (see [setup](index.md#setup)), create `hello.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'hello'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
console.log('hello from my first plugin')
|
||||
}
|
||||
```
|
||||
|
||||
The `name` export is optional display metadata; it labels the plugin in diagnostics.
|
||||
|
||||
## Compose the app
|
||||
|
||||
This tutorial's launcher assembles the application from configuration. Create `cordis.yml`:
|
||||
|
||||
```yaml
|
||||
- name: './hello.ts'
|
||||
```
|
||||
|
||||
The file is a list of plugin entries. `name` is a module specifier — a relative path or an npm package name — and the loader mounts every entry. Entries start concurrently, so list position guarantees nothing about which plugin loads first; ordering comes from service dependencies (`inject`, [chapter 3](03-services.md)), not from position in the file.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
node --import tsx ../../vendor/cordis/bin.js
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
hello from my first plugin
|
||||
```
|
||||
|
||||
The process exits on its own once nothing is left running. What happened:
|
||||
|
||||
1. The launcher created a root `Context` and mounted the **Loader** plugin.
|
||||
2. The Loader read `cordis.yml`, resolved `./hello.ts`, and mounted it as a child plugin.
|
||||
3. Cordis called your `apply(ctx)`.
|
||||
|
||||
There is no framework bootstrap code in your file: a plugin describes what it contributes, and `cordis.yml` composes the application. The [TUI agent](../../examples/tui-agent/cordis.yml), for example, is a longer plugin composition.
|
||||
|
||||
## The two other plugin shapes
|
||||
|
||||
A function is the most common shape, but Cordis accepts three:
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
// 1. Function plugin (what you just wrote).
|
||||
export function apply(ctx: Context) {}
|
||||
|
||||
// 2. Object plugin: an object with an `apply` method.
|
||||
export const objectPlugin = {
|
||||
name: 'object-plugin',
|
||||
apply(ctx: Context) {},
|
||||
}
|
||||
|
||||
// 3. Class plugin: a Service subclass (covered in chapter 3).
|
||||
export class MyService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'myTutorialService')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the function form until you need to expose a service; [chapter 3](03-services.md) covers when the class form earns its place.
|
||||
|
||||
## Try breaking it
|
||||
|
||||
Make `apply` throw:
|
||||
|
||||
```ts ignore-check
|
||||
export function apply(ctx: Context) {
|
||||
throw new Error('apply exploded')
|
||||
}
|
||||
```
|
||||
|
||||
Run again: the process dies with your error. A plugin that fails to load is a loud failure, not a skipped entry.
|
||||
|
||||
One caveat worth knowing early: a config entry whose module cannot be **resolved** — a typo'd path or package name — is reported through the Cordis logger service instead of crashing the process, and at boot that report can be lost before a console exporter is watching. If a freshly added entry seems to do nothing, check the spelling first.
|
||||
|
||||
Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
96
docs/cordis-tutorial/02-lifecycle-and-effects.md
Normal file
96
docs/cordis-tutorial/02-lifecycle-and-effects.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 2. Lifecycle and effects
|
||||
|
||||
A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in `ctx.effect()`.
|
||||
|
||||
## Effects
|
||||
|
||||
For a resource Cordis does not already manage — a timer, a connection, a watcher — wrap it in `ctx.effect()` and return a disposer:
|
||||
|
||||
Create `lifecycle.ts` in `tmp/cordis-tutorial`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'lifecycle-demo'
|
||||
|
||||
function heartbeat(ctx: Context) {
|
||||
console.log('heartbeat plugin loading')
|
||||
ctx.effect(() => {
|
||||
const timer = setInterval(() => console.log('tick'), 200)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
console.log('heartbeat cleaned up')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// Mount a child plugin and keep its fiber to dispose it later.
|
||||
const fiber = ctx.plugin(heartbeat)
|
||||
// The demo timer is itself an effect: if THIS plugin is unloaded first,
|
||||
// the pending callback is cancelled instead of firing on a dead app.
|
||||
ctx.effect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
await fiber.dispose()
|
||||
console.log('disposed')
|
||||
process.exit(0)
|
||||
}, 700)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Point `cordis.yml` at it:
|
||||
|
||||
```yaml
|
||||
- name: './lifecycle.ts'
|
||||
```
|
||||
|
||||
Run (`node --import tsx ../../vendor/cordis/bin.js`) and you get:
|
||||
|
||||
```
|
||||
heartbeat plugin loading
|
||||
tick
|
||||
tick
|
||||
tick
|
||||
heartbeat cleaned up
|
||||
disposed
|
||||
```
|
||||
|
||||
Three things to notice:
|
||||
|
||||
- `ctx.plugin(heartbeat)` mounts a function **from code** as a plugin — the same operation the YAML loader performs for each config entry. A function plugin needs no `apply` method: Cordis calls the function directly and uses its name only for diagnostics. An `apply` method is required only for the object form, `ctx.plugin({ apply(ctx) { /* ... */ } })`. The call returns a **fiber**, the runtime handle for one loaded plugin instance.
|
||||
- The effect body runs during load; the disposer it returns runs during unload. You never call the disposer yourself for a plugin-lifetime resource.
|
||||
- `fiber.dispose()` resolves after all of the plugin's cleanup — including async disposers — has finished, and recursively unloads any child plugins it mounted.
|
||||
|
||||
## The fiber state machine
|
||||
|
||||
Every loaded plugin instance owns a fiber that moves through these states:
|
||||
|
||||
```
|
||||
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
|
||||
↘ FAILED
|
||||
```
|
||||
|
||||
- **PENDING** — declared, but a required service (chapter 3) is not available yet.
|
||||
- **LOADING / ACTIVE** — `apply` is running / has completed.
|
||||
- **FAILED** — `apply` or config validation threw.
|
||||
- **UNLOADING / DISPOSED** — disposers are running / everything is torn down.
|
||||
|
||||
You will meet PENDING again in [chapter 6](06-composition-and-hmr.md), where it is the usual answer to "why does my plugin print nothing?".
|
||||
|
||||
## What is already an effect
|
||||
|
||||
You rarely write `ctx.effect()` yourself, because the built-in registration APIs are effects already:
|
||||
|
||||
- `ctx.on(event, listener)` — the listener is removed on unload ([chapter 4](04-events.md)).
|
||||
- `ctx.plugin(child)` — the child is disposed with its parent.
|
||||
- Service registrations are effects. Harness registries such as `ctx.tools.register(...)` also attach their returned disposers to the calling plugin, so they unwind automatically ([chapter 7](07-into-the-harness.md)).
|
||||
|
||||
For a resource Cordis does not manage, acquire it inside `ctx.effect()` and return a disposer that releases it. Cordis then invokes that release during unloading, including hot reload.
|
||||
|
||||
One ordering caveat: disposers start in reverse registration order, but multiple **async** disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there.
|
||||
|
||||
Next: [Services](03-services.md) — how plugins share capabilities.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
96
docs/cordis-tutorial/03-services.md
Normal file
96
docs/cordis-tutorial/03-services.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 3. Services
|
||||
|
||||
A **service** is a named capability one plugin provides and other plugins consume through `ctx`. In the harness, `ctx.tools`, `ctx.llm`, and `ctx.agents` are services. A consumer names the capability, such as `'tools'`, rather than importing its provider, so configuration can select a provider without changing the consumer.
|
||||
|
||||
## Provide a service
|
||||
|
||||
Create `greeter.ts` in `tmp/cordis-tutorial`:
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
greeter: GreeterService
|
||||
}
|
||||
}
|
||||
|
||||
export class GreeterService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'greeter')
|
||||
}
|
||||
|
||||
greet(who: string) {
|
||||
return `Hello, ${who}!`
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'greeter'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.plugin(GreeterService)
|
||||
}
|
||||
```
|
||||
|
||||
Two pieces work together:
|
||||
|
||||
- **Runtime**: `super(ctx, 'greeter')` registers the instance under the name `greeter`. From then on, any plugin can reach it as `ctx.greeter`. The registration is an effect — unloading the provider removes the service.
|
||||
- **Compile time**: the `declare module 'cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety.
|
||||
|
||||
A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx.plugin(GreeterService)` mounts it like any other.
|
||||
|
||||
## Consume a service with `inject`
|
||||
|
||||
Create `consumer.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'consumer'
|
||||
export const inject = ['greeter']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
console.log(ctx.greeter.greet('world'))
|
||||
}
|
||||
```
|
||||
|
||||
`inject` lists the services this plugin requires. Cordis holds the plugin in PENDING until every listed service exists, so inside `apply`, `ctx.greeter` is guaranteed ready. Load order in `cordis.yml` does not matter — dependencies, not file order, decide when plugins start.
|
||||
|
||||
Compose and run:
|
||||
|
||||
```yaml
|
||||
- name: './greeter.ts'
|
||||
- name: './consumer.ts'
|
||||
```
|
||||
|
||||
```
|
||||
Hello, world!
|
||||
```
|
||||
|
||||
Swap the two lines in `cordis.yml` and rerun: same output. Try removing `./greeter.ts` entirely: the consumer stays PENDING and prints nothing — no crash, no partial run. A PENDING fiber does not keep Node's event loop alive either, so a composition with nothing else running exits 0 silently. [Chapter 6](06-composition-and-hmr.md) shows how to diagnose that state.
|
||||
|
||||
## Dependencies are tracked after load
|
||||
|
||||
`inject` is not a one-shot boot check. If a required service disappears while the app runs — its provider was unloaded or hot-replaced — every dependent plugin is unloaded too, and loads again when the service returns. Combined with effects ([chapter 2](02-lifecycle-and-effects.md)), this prevents a running consumer from retaining a reference to an unavailable service: its own registrations are unwound when the dependency disappears.
|
||||
|
||||
This is also why service replacement works in config: unload the `dsh-bash-local` entry, mount a different `bash` provider, and every plugin injecting `'bash'` cleanly restarts against the new implementation.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
`inject` is for hard requirements. For a capability the plugin can live without, skip `inject` and probe at the use site:
|
||||
|
||||
```ts ignore-check
|
||||
export function apply(ctx: Context) {
|
||||
// undefined when no provider is loaded; the plugin still runs.
|
||||
const greeter = ctx.get('greeter')
|
||||
console.log(greeter?.greet('maybe') ?? 'no greeter available')
|
||||
}
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
Service names live in one flat namespace per application. Prefix or namespace your own services distinctively (the harness claims plain names like `tools` and `llm`); the generated [services catalog](../cordis-catalog/services.md) lists every name the harness registers.
|
||||
|
||||
Next: [Events](04-events.md) — communication without a shared service.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
142
docs/cordis-tutorial/04-events.md
Normal file
142
docs/cordis-tutorial/04-events.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 4. Events
|
||||
|
||||
Services support direct calls; **events** let a plugin announce something without knowing which plugins listen. The harness uses events for interactions such as tool results, model requests, and approval decisions.
|
||||
|
||||
## Declare, emit, listen
|
||||
|
||||
Create `stats.ts` in `tmp/cordis-tutorial` — a service that counts things and announces each change:
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
stats: StatsService
|
||||
}
|
||||
interface Events {
|
||||
'stats/report'(name: string, count: number): void
|
||||
}
|
||||
}
|
||||
|
||||
export class StatsService extends Service {
|
||||
private counts = new Map<string, number>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'stats')
|
||||
}
|
||||
|
||||
bump(name: string) {
|
||||
const next = (this.counts.get(name) ?? 0) + 1
|
||||
this.counts.set(name, next)
|
||||
this.ctx.emit('stats/report', name, next)
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'stats'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.plugin(StatsService)
|
||||
}
|
||||
```
|
||||
|
||||
The `interface Events` merge is the event-system twin of the `interface Context` merge from chapter 3: it declares the event name and its listener signature, so `ctx.emit` and `ctx.on` are fully typed. The `namespace/action` naming convention keeps the flat event namespace readable.
|
||||
|
||||
Create `reporter.ts`:
|
||||
|
||||
```ts ignore-check
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from './stats.ts'
|
||||
|
||||
export const name = 'reporter'
|
||||
export const inject = ['stats']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('stats/report', (name, count) => {
|
||||
console.log(`[stats] ${name} -> ${count}`)
|
||||
})
|
||||
ctx.stats.bump('tool_call')
|
||||
ctx.stats.bump('tool_call')
|
||||
ctx.stats.bump('prompt')
|
||||
}
|
||||
```
|
||||
|
||||
The `import type {} from './stats.ts'` line imports nothing at runtime; it exists so TypeScript sees the declaration merges. Compose and run:
|
||||
|
||||
```yaml
|
||||
- name: './stats.ts'
|
||||
- name: './reporter.ts'
|
||||
```
|
||||
|
||||
```
|
||||
[stats] tool_call -> 1
|
||||
[stats] tool_call -> 2
|
||||
[stats] prompt -> 1
|
||||
```
|
||||
|
||||
Because `ctx.on()` is an effect, the listener disappears with the plugin — no manual `removeListener` bookkeeping, ever.
|
||||
|
||||
## Dispatch modes
|
||||
|
||||
`emit` is one of five dispatch modes. Which one an event uses is part of its contract — it decides whether listeners can return values, run concurrently, or short-circuit each other:
|
||||
|
||||
| Mode | Call | Semantics |
|
||||
|---|---|---|
|
||||
| emit | `ctx.emit(name, ...args)` | Synchronous broadcast; returned promises and values are not awaited or collected. |
|
||||
| parallel | `await ctx.parallel(name, ...args)` | All listeners run concurrently; awaited together. |
|
||||
| serial | `await ctx.serial(name, ...args)` | Listeners run in order, awaited; the first non-`null`/`false`/`undefined` return wins and stops the rest. |
|
||||
| bail | `ctx.bail(name, ...args)` | Synchronous version of serial. |
|
||||
| waterfall | `ctx.waterfall(name, ...args, next)` | Around-middleware; see below. |
|
||||
|
||||
Every harness event documents its mode in the generated [events catalog](../cordis-catalog/events.md).
|
||||
|
||||
## Waterfall: transform or short-circuit
|
||||
|
||||
Waterfall is the mode that powers interception. Each listener receives the arguments plus a `next()` continuation; it can transform what `next()` returns, or return without calling `next()` and short-circuit the rest of the chain — what the Cordis docs call the veto. Create `waterfall-demo.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'waterfall-demo'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// Listener 1: wrap the downstream result.
|
||||
ctx.on('demo/transform', async (input, next) => {
|
||||
const downstream = await next()
|
||||
return downstream.toUpperCase()
|
||||
})
|
||||
|
||||
// Listener 2: short-circuit when it owns the decision.
|
||||
ctx.on('demo/transform', async (input, next) => {
|
||||
if (input.includes('blocked')) return '** blocked **'
|
||||
return next()
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
|
||||
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
|
||||
})()
|
||||
}
|
||||
```
|
||||
|
||||
Point `cordis.yml` at just this file and run:
|
||||
|
||||
```
|
||||
HELLO
|
||||
** BLOCKED **
|
||||
```
|
||||
|
||||
Walk through the second line: listener 1 runs first, calls `next()`, which invokes listener 2; listener 2 sees `blocked` and returns without calling `next()` — the innermost default (the function passed to `ctx.waterfall`) never runs — and listener 1 uppercases the replacement message on the way out.
|
||||
|
||||
The discipline that follows: **a waterfall listener that only observes or annotates must call `next()`**; returning without it is a deliberate short-circuit. Forgetting `next()` in a logging listener silently swallows the default behavior for everyone downstream. This is important enough that it is a standing rule of this repository ([waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)).
|
||||
|
||||
The harness uses waterfalls for decisions that cooperating plugins may wrap or answer: [`agent/request`](../cordis-catalog/events.md#agentrequest--waterfall) lets a plugin replace the model-call config, and [`approval/request`](../cordis-catalog/events.md#approvalrequest--waterfall) lets a policy answer instead of the user.
|
||||
|
||||
Next: [Configuration](05-config.md) — plugin options from `cordis.yml`.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
82
docs/cordis-tutorial/05-config.md
Normal file
82
docs/cordis-tutorial/05-config.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 5. Configuration
|
||||
|
||||
Each `cordis.yml` entry can carry a `config` block, and the plugin declares a schema that validates it before `apply` runs. Bad config fails the load with a precise error — the plugin never starts half-configured.
|
||||
|
||||
## A configurable plugin
|
||||
|
||||
Create `config-demo.ts` in `tmp/cordis-tutorial`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import Schema from 'schemastery'
|
||||
|
||||
export const name = 'config-demo'
|
||||
|
||||
export interface Config {
|
||||
greeting: string
|
||||
targets: string[]
|
||||
}
|
||||
|
||||
export const Config: Schema<Config> = Schema.object({
|
||||
greeting: Schema.string().default('Hello'),
|
||||
targets: Schema.array(String).default(['world']),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
for (const target of config.targets) {
|
||||
console.log(`${config.greeting}, ${target}!`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The exported `Config` is both a TypeScript interface and a runtime schema with the same name — consumers get the type, Cordis gets the validator. This repo uses [Schemastery](https://github.com/shigma/schemastery) for schemas; Cordis itself accepts any [Standard Schema](https://standardschema.dev/) validator, so a plain object exported as `Config` will not work.
|
||||
|
||||
Configure it:
|
||||
|
||||
```yaml
|
||||
- name: './config-demo.ts'
|
||||
config:
|
||||
targets: ['alpha', 'beta']
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
Hello, alpha!
|
||||
Hello, beta!
|
||||
```
|
||||
|
||||
`greeting` was omitted, so the schema default filled it in — `apply` always receives complete, validated config.
|
||||
|
||||
## Fail loud
|
||||
|
||||
Now feed it something invalid:
|
||||
|
||||
```yaml
|
||||
- name: './config-demo.ts'
|
||||
config:
|
||||
targets: 'not-an-array'
|
||||
```
|
||||
|
||||
```
|
||||
ValidationError: invalid config:
|
||||
- $.targets expected array but got not-an-array (at targets)
|
||||
```
|
||||
|
||||
The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference.
|
||||
|
||||
## Computed config values
|
||||
|
||||
The loader used in this repo supports a `!!js` tag for config values that must be computed at load time, such as reading an API key from the environment:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
```
|
||||
|
||||
`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration).
|
||||
|
||||
Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
111
docs/cordis-tutorial/06-composition-and-hmr.md
Normal file
111
docs/cordis-tutorial/06-composition-and-hmr.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 6. Composition and HMR
|
||||
|
||||
Every capability built so far is a plugin, and `cordis.yml` selects the application's plugin tree. This chapter changes that composition, hot-reloads a plugin, and diagnoses a plugin that never loads.
|
||||
|
||||
## Entries are more than a name
|
||||
|
||||
A config entry accepts metadata beyond `name` and `config`:
|
||||
|
||||
```yaml
|
||||
- id: greeter # stable identity for this entry
|
||||
name: './greeter.ts'
|
||||
- id: consumer
|
||||
name: './consumer.ts'
|
||||
disabled: true # keep the entry, skip mounting it
|
||||
```
|
||||
|
||||
`id` gives the entry a stable identity so the loader can tell an edit to an existing entry apart from a removal plus an addition. `disabled: true` unmounts a plugin without deleting its entry — flip it back and the plugin (and everything PENDING on its services) loads again.
|
||||
|
||||
Groups nest a sub-list of entries that load and unload as one unit, and `isolate` gives a group its own instance of a service name — two groups can each see a differently-configured `bash` without affecting each other. Those are worth knowing about before you need them; the [Cordis primer](../cordis-primer.md) and the [service isolation example](../user/develop/framework/service.md#service-isolation) cover the details.
|
||||
|
||||
## Hot module replacement
|
||||
|
||||
Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@cordisjs/plugin-hmr` plugin watches your files and does exactly that on save.
|
||||
|
||||
In `tmp/cordis-tutorial`, write `cordis.yml`:
|
||||
|
||||
```yaml
|
||||
- id: logger
|
||||
name: '@cordisjs/plugin-logger-console'
|
||||
- id: timer
|
||||
name: '@cordisjs/plugin-timer'
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
- id: hello
|
||||
name: './hello.ts'
|
||||
```
|
||||
|
||||
Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@cordisjs/plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section.
|
||||
|
||||
HMR also needs Node's loader internals:
|
||||
|
||||
```sh
|
||||
node --expose-internals --import tsx ../../vendor/cordis/bin.js
|
||||
```
|
||||
|
||||
Now edit `hello.ts` — change the log message — and save:
|
||||
|
||||
```
|
||||
hello from my first plugin
|
||||
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
|
||||
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
|
||||
hello from my EDITED plugin
|
||||
```
|
||||
|
||||
The old instance unloaded (all its effects unwound), the new code loaded, `apply` ran again. Stop the process with Ctrl-C. Editing `cordis.yml` itself is also picked up: the loader diffs entries by `id` and mounts, unmounts, or reconfigures only what changed. This is why the entries above carry explicit `id`s — an entry without one gets a generated id on every read, so after any config-file edit it counts as removed-plus-added and remounts even if its own lines did not change.
|
||||
|
||||
## Diagnosing a plugin that never loads
|
||||
|
||||
The flip side of dependency-driven loading: a plugin whose `inject` names a service nobody provides waits forever, printing nothing. No error — PENDING is a legitimate state, since the provider may be mounted later.
|
||||
|
||||
You can see the states directly. Every context can enumerate the plugin registry; create `diagnose.ts`:
|
||||
|
||||
```ts
|
||||
import { FiberState, type Context } from 'cordis'
|
||||
|
||||
export const name = 'diagnose'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
setTimeout(() => {
|
||||
for (const runtime of ctx.registry.values()) {
|
||||
for (const fiber of runtime.fibers) {
|
||||
if (fiber.state === FiberState.PENDING) {
|
||||
console.log(`${fiber.name} is PENDING — a required service is missing`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
```
|
||||
|
||||
And a plugin with an unsatisfiable dependency, `needs-timer.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'needs-timer'
|
||||
export const inject = ['timer']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
console.log('needs-timer loaded')
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: './needs-timer.ts'
|
||||
- name: './diagnose.ts'
|
||||
```
|
||||
|
||||
Run it (plain `node --import tsx ../../vendor/cordis/bin.js`; stop with Ctrl-C):
|
||||
|
||||
```
|
||||
needs-timer is PENDING — a required service is missing
|
||||
```
|
||||
|
||||
`inject: ['timer']` has no provider. Add `- name: '@cordisjs/plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself.
|
||||
|
||||
Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
101
docs/cordis-tutorial/07-into-the-harness.md
Normal file
101
docs/cordis-tutorial/07-into-the-harness.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 7. Into the harness
|
||||
|
||||
This chapter registers a model-callable tool with the harness's `tools` service, executes it through the harness tool pipeline, and observes the result event. It remains keyless and does not call a model.
|
||||
|
||||
## A tool plugin
|
||||
|
||||
Create `greet-tool.ts` in `tmp/cordis-tutorial`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export const name = 'greet-tool'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'greet',
|
||||
description: 'Greet the named person.',
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'Who to greet' },
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `Hello, ${args.name}!` }]
|
||||
},
|
||||
}))
|
||||
|
||||
// Drive one call through the real execution pipeline, standing in for
|
||||
// the model. CallId brands the correlation id a provider would issue.
|
||||
void (async () => {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('demo-1'),
|
||||
name: 'greet',
|
||||
arguments: { name: 'Cordis' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
console.log('tool replied:', JSON.stringify(result.content))
|
||||
})()
|
||||
}
|
||||
```
|
||||
|
||||
Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3](03-services.md)) holds the plugin until the tool registry exists; `ctx.tools.register(...)` attaches the registration disposer to the plugin ([chapter 2](02-lifecycle-and-effects.md)), so unloading unregisters the tool. `defineTool` converts the `parameters` spec to the JSON Schema shown to the model, infers the type of `args`, and validates model-supplied arguments before `execute` runs.
|
||||
|
||||
## An observer plugin
|
||||
|
||||
Create `tool-logger.ts` — a separate plugin that watches every tool call in the app through the harness's `tools/result` event:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'tool-logger'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('tools/result', (exec, result) => {
|
||||
const text = result.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
console.log(`[tool-logger] ${exec.name} -> ${text}`)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The `import type {} from '@deepseek-ai/dsh-tools'` line pulls in the package's declaration merges so `'tools/result'` and its payload are typed — the same move as chapter 4's `stats.ts` import, at package scale.
|
||||
|
||||
## Compose and run
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-system-prompt'
|
||||
- name: '@deepseek-ai/dsh-tools'
|
||||
- name: './tool-logger.ts'
|
||||
- name: './greet-tool.ts'
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-tools` injects the `systemPrompt` service because tools contribute schemas to the system prompt, so the composition lists its provider too. Without it, the tools plugin remains PENDING as described in [chapter 6](06-composition-and-hmr.md).
|
||||
|
||||
```sh
|
||||
node --import tsx ../../vendor/cordis/bin.js
|
||||
```
|
||||
|
||||
```
|
||||
[tool-logger] greet -> Hello, Cordis!
|
||||
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
|
||||
```
|
||||
|
||||
The logger fired first: `tools/result` is emitted as part of result materialization, before `execute`'s promise resolves to the caller. Neither of your plugins knows the other exists — the registry service and the event connect them.
|
||||
|
||||
## From here to a full agent
|
||||
|
||||
A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file.
|
||||
|
||||
Where to go next:
|
||||
|
||||
- [Build a tool](../user/develop/basic/tool.md) — more of `defineTool`, including presentation and richer schemas.
|
||||
- [Three-layer capability design](../user/develop/practice/index.md) — how the harness structures replaceable capabilities.
|
||||
- The generated [services](../cordis-catalog/services.md) and [events](../cordis-catalog/events.md) catalogs — everything you can inject and listen to.
|
||||
- [Architecture](../architecture.md) — the system map these plugins live in.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
54
docs/cordis-tutorial/index.md
Normal file
54
docs/cordis-tutorial/index.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Cordis tutorial
|
||||
|
||||
Cordis is the plugin framework underneath the DeepSeek Harness SDK: a small runtime where every capability — tools, LLM adapters, file access, the agent loop itself — is a plugin mounted into a shared context. This tutorial teaches Cordis hands-on: each chapter is a runnable example you build in a scratch directory inside this repository, ending with a plugin wired into real harness services.
|
||||
|
||||
The audience is agent developers. You do not need deep TypeScript experience; the [TypeScript notes](#typescript-notes) below explain the syntax that may be unfamiliar, and every chapter shows the exact commands and expected output.
|
||||
|
||||
If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated [events](../cordis-catalog/events.md) and [services](../cordis-catalog/services.md) catalogs and the [Cordis core API](../cordis-catalog/core/context.md) pages.
|
||||
|
||||
## Setup
|
||||
|
||||
You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/deepseek-harness/deepseek-harness.git
|
||||
cd deepseek-harness
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Create the scratch directory the chapters work in. `tmp/` is gitignored, so nothing you write there touches version control:
|
||||
|
||||
```sh
|
||||
mkdir -p tmp/cordis-tutorial
|
||||
cd tmp/cordis-tutorial
|
||||
```
|
||||
|
||||
Every chapter runs the same command from this directory:
|
||||
|
||||
```sh
|
||||
node --import tsx ../../vendor/cordis/bin.js
|
||||
```
|
||||
|
||||
That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) creates a root `Context`, mounts the Loader plugin, and tells it to load `./cordis.yml` from the current directory. Everything else — which plugins exist, how they are configured — comes from that YAML file, which you will write in a moment. The `--import tsx` flag lets Node run the TypeScript files the config points at without a build step.
|
||||
|
||||
## Chapters
|
||||
|
||||
1. [Your first plugin](01-first-plugin.md) — a plugin is a function; the loader mounts it.
|
||||
2. [Lifecycle and effects](02-lifecycle-and-effects.md) — Cordis-managed registrations are undone when their plugin unloads.
|
||||
3. [Services](03-services.md) — expose a capability on `ctx` and depend on it with `inject`.
|
||||
4. [Events](04-events.md) — typed events, broadcast dispatch, and the waterfall short-circuit.
|
||||
5. [Configuration](05-config.md) — validated config from `cordis.yml`, failing loud on bad input.
|
||||
6. [Composition and HMR](06-composition-and-hmr.md) — the config file as a plugin tree, hot reload, and diagnosing a plugin that never loads.
|
||||
7. [Into the harness](07-into-the-harness.md) — register a model-callable tool against real harness services.
|
||||
|
||||
## TypeScript notes
|
||||
|
||||
The examples use three TypeScript features beyond ordinary modern JavaScript:
|
||||
|
||||
- **Type annotations** describe values without changing runtime behavior: `ctx: Context` says that `ctx` has the Cordis context API, `who: string` accepts text, and `string[]` means an array of strings.
|
||||
- **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency.
|
||||
- **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full.
|
||||
|
||||
Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects.
|
||||
|
||||
[](https://github.com/deepseek-harness/deepseek-harness)
|
||||
@@ -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
|
||||
development.md: 3559b09d86395707f222aad0a281c9db1246c24f
|
||||
development.zh.md: 8664a3291c04a5338fdadbce8f24b160cc9ec0a8
|
||||
development.md: 4294038e40aa774a006874e6641ca63eea44beeb
|
||||
development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a
|
||||
|
||||
@@ -33,7 +33,26 @@ Run typecheck once after a fresh clone:
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings.
|
||||
That first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.
|
||||
|
||||
## TypeScript project layout
|
||||
|
||||
The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.
|
||||
|
||||
| File | Role | Forms a program? |
|
||||
|---|---|---|
|
||||
| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |
|
||||
| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |
|
||||
| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |
|
||||
| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |
|
||||
| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |
|
||||
|
||||
Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:
|
||||
|
||||
- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.
|
||||
- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.
|
||||
|
||||
Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).
|
||||
|
||||
If a relevant local check consumes built package output, build once first:
|
||||
|
||||
@@ -59,7 +78,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||
|
||||
- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-push` runs only the incremental repository typecheck.
|
||||
- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
@@ -80,7 +99,7 @@ pnpm run test # unit tests
|
||||
pnpm run test:coverage # unit tests with per-file coverage gates
|
||||
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
|
||||
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
|
||||
pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts
|
||||
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
|
||||
pnpm run lint # eslint .
|
||||
pnpm run lint:fix # eslint . --fix
|
||||
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
|
||||
|
||||
@@ -33,7 +33,26 @@ pnpm exec lefthook install --force
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。
|
||||
首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。
|
||||
|
||||
## TypeScript 项目布局
|
||||
|
||||
仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。
|
||||
|
||||
| 文件 | 角色 | 是否构成 program? |
|
||||
|---|---|---|
|
||||
| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |
|
||||
| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |
|
||||
| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |
|
||||
| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |
|
||||
| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |
|
||||
|
||||
host 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:
|
||||
|
||||
- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。
|
||||
- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。
|
||||
|
||||
静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。
|
||||
|
||||
如果相关的本地检查需要使用构建后的包产物,请先构建一次:
|
||||
|
||||
@@ -59,7 +78,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 只运行仓库增量类型检查。
|
||||
- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
|
||||
@@ -80,7 +99,7 @@ pnpm run test # unit tests
|
||||
pnpm run test:coverage # unit tests with per-file coverage gates
|
||||
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
|
||||
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
|
||||
pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts
|
||||
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
|
||||
pnpm run lint # eslint .
|
||||
pnpm run lint:fix # eslint . --fix
|
||||
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
|
||||
|
||||
@@ -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
|
||||
testing.md: 1de3a0754dadbad1f36a9fc8c19b8ba77071bd54
|
||||
testing.zh.md: f6ec709862879192bb513f4701227fedfa7ecb66
|
||||
testing.md: 5a18397ba2431a4c4f2595d32d9de6fe3ddeb6f4
|
||||
testing.zh.md: 19ee4aa6abffc13c35b1933e2af0ed38eef5c7e6
|
||||
|
||||
@@ -31,6 +31,10 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
|
||||
- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert.
|
||||
- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero.
|
||||
|
||||
## Test resolution: source plane only
|
||||
|
||||
- Every vitest config points vite-tsconfig-paths at `tsconfig.base.json`; bare workspace imports resolve to `src` ([layout](development.md#typescript-project-layout)), never through package `exports` to built `lib/` — stale artifacts there load a second copy of module singletons. Built artifacts are consumed only explicitly: `lib`-mode subprocesses and the built smokes below.
|
||||
|
||||
## Test subprocess launch modes
|
||||
|
||||
- CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses.
|
||||
|
||||
@@ -31,6 +31,10 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身
|
||||
- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。
|
||||
- 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/ui/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。
|
||||
|
||||
## 测试解析:仅限源码
|
||||
|
||||
- 每个 vitest 配置都将 vite-tsconfig-paths 指向 `tsconfig.base.json`;工作区包的裸导入解析到 `src`([布局](development.md#typescript-project-layout)),绝不会经由包的 `exports` 解析到构建后的 `lib/`,因为其中的陈旧产物会加载第二份模块单例。构建产物只在显式指定时使用:以 `lib` 模式运行的子进程,以及下文的构建产物冒烟测试。
|
||||
|
||||
## 测试子进程启动模式
|
||||
|
||||
- CI 与已有构建产物的测试通道通过共享双模式启动器,从构建后的 `lib/` 运行每个示例或 Cordis 配置子进程。不要为这些子进程手写 `--import tsx`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Web GUI 样式规范
|
||||
|
||||
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写);组件对账基准=`missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md`。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace——这些已收编进 architecture.md §15。
|
||||
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写),sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。
|
||||
|
||||
> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ export default tseslint.config(
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
// Same shared project service as the src block: test files resolve
|
||||
// through the root tsconfig (its include covers every tests/ tree).
|
||||
// through the root solution to tsconfig.host.json (its include covers
|
||||
// every host tests/ tree).
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
|
||||
16
knip.json
16
knip.json
@@ -88,8 +88,7 @@
|
||||
},
|
||||
"packages/client/runtime": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
"tests/**/*.spec.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
@@ -106,6 +105,16 @@
|
||||
"tests/**/*.tsx"
|
||||
]
|
||||
},
|
||||
"packages/client/web-react": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.tsx"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"tests/**/*.tsx"
|
||||
]
|
||||
},
|
||||
"packages/client/ui-layout": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
@@ -116,9 +125,6 @@
|
||||
"src/**/*.tsx",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/dsh-client-ui-slots"
|
||||
]
|
||||
},
|
||||
"website": {
|
||||
|
||||
@@ -20,4 +20,4 @@ pre-commit:
|
||||
pre-push:
|
||||
jobs:
|
||||
- name: typecheck
|
||||
run: node_modules/.bin/tsc -b tsconfig.json --pretty false
|
||||
run: node_modules/.bin/tsc -b --pretty false
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
"website"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -b tsconfig.build.json && tsdown",
|
||||
"build": "tsc -b && tsdown",
|
||||
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
|
||||
"clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo",
|
||||
"typecheck": "tsc -b tsconfig.json tsconfig.client.json",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"duplication": "jscpd --config .jscpd.json packages scripts",
|
||||
|
||||
@@ -19,6 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
|
||||
Naming notes:
|
||||
|
||||
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
|
||||
|
||||
@@ -1,42 +1,51 @@
|
||||
# AGENTS.md — Web client stack
|
||||
|
||||
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
|
||||
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services).
|
||||
|
||||
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
## Slot and props discipline
|
||||
|
||||
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
|
||||
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking.
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
|
||||
## Export discipline (client plugin packages)
|
||||
|
||||
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
|
||||
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
|
||||
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
|
||||
|
||||
## ctx discipline (components never see ctx)
|
||||
|
||||
`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
|
||||
|
||||
## Layering red lines
|
||||
|
||||
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
|
||||
The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
|
||||
|
||||
1. **Data object layer** (`web-runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
|
||||
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
|
||||
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
|
||||
1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
|
||||
2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
|
||||
3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
|
||||
|
||||
Non-negotiables across the layers:
|
||||
|
||||
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
|
||||
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
|
||||
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). 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).
|
||||
|
||||
## Directory regime (`web-ui/src`)
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
|
||||
|
||||
Two-level feature directories, one contributor per directory — physical conflict avoidance:
|
||||
|
||||
```
|
||||
web-ui/src/
|
||||
shell/ # AppShell + the three slot registries + builtins
|
||||
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
|
||||
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
|
||||
components/ # shared leaves (MessageText, JsonBlock, …)
|
||||
hooks/ utils/ style/ # cross-cutting; not feature-owned
|
||||
```
|
||||
|
||||
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
|
||||
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
|
||||
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
|
||||
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 the slot/view/toolview registries in `apply` — never module-level side effects.
|
||||
|
||||
## Styling
|
||||
|
||||
@@ -63,9 +72,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
## New component checklist
|
||||
|
||||
1. Claim the slot (see the directory regime above): one feature, one directory.
|
||||
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
|
||||
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
|
||||
1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
|
||||
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
|
||||
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
|
||||
4. Tokens only in CSS; Chinese product copy; English comments.
|
||||
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -21,16 +21,13 @@ export type {
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
// ---- Connection loop ----
|
||||
export { ConnectionController } from './connection.ts'
|
||||
// ---- Connection loop types (part of the ConnectionHandle.start contract;
|
||||
// the controller class itself stays package-internal — apply owns the loop,
|
||||
// tests reach it via src) ----
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
|
||||
// ---- Platform client subclasses ----
|
||||
export { WebApiClient } from './web-api-client.ts'
|
||||
export { FixtureApiClient, createFixtureApi } from './fixture.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
export const inject: string[] = []
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^"
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
* Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
// The snapshot-store engine lives in runtime (store relocation): framework
|
||||
// data stores like this locale cell use it directly. The store carries no
|
||||
// hook — a React consumer binds a selector hook via web-react's
|
||||
// bindSnapshotSelector at its own seam (none exists today; the current
|
||||
// consumers are translate() reads and test-side subscribe/set).
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
@@ -18,7 +12,7 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -37,10 +37,11 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
"zustand": "~4.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
244
packages/client/runtime/src/client/contract/store.ts
Normal file
244
packages/client/runtime/src/client/contract/store.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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
|
||||
* 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).
|
||||
*/
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Store contract types are ui-slots authority; re-exported beside the engine
|
||||
// so store consumers get one import surface.
|
||||
export type {
|
||||
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
|
||||
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
|
||||
|
||||
/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */
|
||||
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
|
||||
/**
|
||||
* Mutate the state through an immer draft.
|
||||
* @param mutator - draft mutator.
|
||||
*/
|
||||
update(mutator: (draft: T) => void): void
|
||||
/**
|
||||
* Replace the state wholesale.
|
||||
* @param next - next state.
|
||||
*/
|
||||
set(next: T): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow equality for selector slices (zustand/shallow semantics; travels
|
||||
* with the engine so hook consumers need no zustand dependency).
|
||||
* @param a - left value.
|
||||
* @param b - right value.
|
||||
* @returns whether the values are shallowly equal.
|
||||
*/
|
||||
export function shallowEqual(a: unknown, b: unknown): boolean {
|
||||
return shallow(a, b)
|
||||
}
|
||||
|
||||
/** Batches subscriber notification into one flush per animation frame. */
|
||||
function rafBatch(notify: () => void): () => void {
|
||||
// Fall back to microtask batching where rAF is absent (node unit tests);
|
||||
// both preserve the N-changes=1-notification contract within a tick.
|
||||
const schedule: (fn: () => void) => void =
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? (fn) => { requestAnimationFrame(() => { fn() }) }
|
||||
: (fn) => { queueMicrotask(fn) }
|
||||
let scheduled = false
|
||||
return () => {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
schedule(() => {
|
||||
scheduled = false
|
||||
notify()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot store.
|
||||
*
|
||||
* Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven
|
||||
* stores opt into 'raf', where a frame's worth of updates coalesces into one
|
||||
* notification. Known raf-mode tradeoff: a component mounting mid-frame reads
|
||||
* fresh state while existing subscribers hear it next flush — transient
|
||||
* frame-level skew, same nature as the object layer's microtask batching.
|
||||
*
|
||||
* @param init - initial state.
|
||||
* @param opts - flush mode and opt-in persistence (localStorage, keyed by name).
|
||||
* @returns the store.
|
||||
*/
|
||||
export function createSnapshotStore<T>(
|
||||
init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {
|
||||
// Immer enters through produce() in update() below (identical semantics to
|
||||
// the immer middleware without its setState-signature mutator generics).
|
||||
const withSelector = subscribeWithSelector(() => init)
|
||||
const api: StoreApi<T> = createStore<T>()(withSelector)
|
||||
if (opts?.persist) attachPersistence(api, opts.persist.name)
|
||||
|
||||
let subscribe = (fn: () => void) => api.subscribe(fn)
|
||||
if (opts?.flush === 'raf') {
|
||||
const listeners = new Set<() => void>()
|
||||
const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
|
||||
api.subscribe(flush)
|
||||
subscribe = (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => api.getState(),
|
||||
subscribe: fn => subscribe(fn),
|
||||
update: (mutator) => {
|
||||
// Immer's produce (not setState's partial-merge path) so scalar and
|
||||
// array roots replace correctly; produce also freezes in dev.
|
||||
api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)
|
||||
},
|
||||
set: (next) => {
|
||||
api.setState(devFreeze(next), true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-value JSON persistence to localStorage. Hand-rolled instead of the
|
||||
* zustand persist middleware: its write path spreads state into an object
|
||||
* (`partialize({ ...get() })`), exploding primitive state (a persisted string
|
||||
* draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options
|
||||
* because the corruption happens before serialization. Storage failures
|
||||
* (quota, private mode) only disable persistence, never break the store.
|
||||
*/
|
||||
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
|
||||
// Non-browser runs (node e2e booting the client tree) have no localStorage:
|
||||
// persistence silently disables — same contract as a storage failure, minus
|
||||
// the per-store console noise a ReferenceError would produce.
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
const raw = localStorage.getItem(name)
|
||||
if (raw !== null) {
|
||||
api.setState(devFreeze(JSON.parse(raw) as T), true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`snapshot store '${name}' rehydration failed:`, error)
|
||||
}
|
||||
api.subscribe((state) => {
|
||||
try {
|
||||
localStorage.setItem(name, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.error(`snapshot store '${name}' persistence failed:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
|
||||
function devFreeze<T>(value: T): T {
|
||||
if (process.env.NODE_ENV === 'production') return value
|
||||
deepFreeze(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function deepFreeze(value: unknown): void {
|
||||
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
|
||||
Object.freeze(value)
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
deepFreeze((value as Record<PropertyKey, unknown>)[key])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- defineStore shell (slot terminal design §4) ----
|
||||
// The type authority is ui-slots' store family (create(scopeKey?) and
|
||||
// clearPersisted() included); this module houses only the engine-backed
|
||||
// implementation. The one engine-side widening left: instances expose the
|
||||
// raw engine store for framework/test surfaces.
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
/** The underlying engine store (framework/test surface; components never see it). */
|
||||
readonly store: SnapshotStore<T>
|
||||
}
|
||||
|
||||
/** The engine-backed handle: create() narrowed to the engine instance. */
|
||||
export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {
|
||||
/**
|
||||
* Construct a live engine instance (see the contract JSDoc on
|
||||
* {@link StoreHandle.create} for scopeKey/persist semantics).
|
||||
*
|
||||
* Known boundary: the persist key is the storage identity, so multiple live
|
||||
* instances created under the same resolved key share (and cross-pollute)
|
||||
* one localStorage entry. Instance uniqueness per key is the caller's
|
||||
* responsibility — production is safe because the framework caches one
|
||||
* instance per handle x scope key; tests wanting isolation use distinct
|
||||
* scope keys or persist-free declarations (multi-create freedom is a
|
||||
* feature there, so create() deliberately does not dedupe or throw).
|
||||
* @param scopeKey - session id for session-scope instances; omitted for root scope.
|
||||
* @returns the engine instance.
|
||||
*/
|
||||
create(scopeKey?: string): EngineStoreInstance<T, A>
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a store: initial state, optional persistence, and the full write
|
||||
* set as pure draft mutators. The returned handle is the registration
|
||||
* currency of the store seat — its identity keys instance sharing. Satisfies
|
||||
* ui-slots' DefineStore contract (the handle/instance are the engine-extended
|
||||
* subtypes).
|
||||
*
|
||||
* The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
|
||||
* `init` in the first inference round, and the intersection then contextually
|
||||
* types each mutator's draft parameter (context-sensitive functions defer),
|
||||
* so call sites write `(d, x: X) => { ... }` with no draft annotation. If a
|
||||
* future TS version breaks this single-literal inference, the design's
|
||||
* documented fallback is currying (`defineStore(init).actions({...})`).
|
||||
* @param decl - init lambda (fresh state per instance), optional persist key, actions table.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function defineStore<T, A extends ActionsDecl<T>>(
|
||||
decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {
|
||||
return {
|
||||
spec: decl,
|
||||
create(scopeKey?: string): EngineStoreInstance<T, A> {
|
||||
const persistKey = decl.persist === undefined
|
||||
? undefined
|
||||
: scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`
|
||||
const store = createSnapshotStore<T>(
|
||||
decl.init(),
|
||||
persistKey !== undefined ? { persist: { name: persistKey } } : undefined)
|
||||
const actions = {} as Record<string, (...params: unknown[]) => void>
|
||||
for (const key of Object.keys(decl.actions)) {
|
||||
const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void
|
||||
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
|
||||
}
|
||||
return {
|
||||
actions: actions as BakedActions<T, A>,
|
||||
getSnapshot: () => store.getSnapshot(),
|
||||
subscribe: fn => store.subscribe(fn),
|
||||
store,
|
||||
clearPersisted: () => {
|
||||
if (persistKey === undefined || typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(persistKey)
|
||||
} catch {
|
||||
// Storage failures (private mode, quota teardown races) only skip
|
||||
// cleanup — the same non-fatal contract as attachPersistence.
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
/**
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService, SessionsService (list store + scope tree + object layer),
|
||||
* the ClientLoader interface, and the cordis Context/Events merges. apply
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), the ClientLoader interface, and the cordis Context/Events
|
||||
* merges. apply
|
||||
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
|
||||
* the object layer. The loader machinery implementation is NOT in the plugin
|
||||
* bundle — it ships via the package's `./loader` subpath, statically held by
|
||||
* the web shell (a loader cannot load itself).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from './contract/store.ts'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
|
||||
// ui-layout: the framework slot is declared by the framework package).
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionsService, scopeOf } from './sessions/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
export { SessionManager } from './sessions/manager.ts'
|
||||
export type { SessionListSnapshot } from './sessions/manager.ts'
|
||||
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
|
||||
export type { SessionListEntry } from './sessions/lineage.ts'
|
||||
// The snapshot-store engine lives here since the store migration (the data
|
||||
// layer owns its substrate; web-react is React glue only). The './client'
|
||||
// main export is the single serving door — no store subpath.
|
||||
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
|
||||
PendingInteraction, RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -41,11 +51,8 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
*/
|
||||
export type ClientContext = Context
|
||||
|
||||
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
|
||||
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
|
||||
export type UseConversationSession = UseSession<ConversationSnapshot>
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/**
|
||||
* One tool call as the chat flow renders it: still-running (spinner card) or
|
||||
@@ -54,6 +61,25 @@ export type UseConversationSession = UseSession<ConversationSnapshot>
|
||||
*/
|
||||
export type ToolCallBlock = RunningToolCall | ToolResultNode
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/**
|
||||
* Session standard kit, real members (ui-slots declares the empty seat;
|
||||
* the runtime — where the subjects live — merges the concrete types):
|
||||
* every session-scope slot component receives these from the framework.
|
||||
*/
|
||||
interface SessionStandardProps {
|
||||
/** Selector hook over this session's conversation snapshot. */
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
}
|
||||
/** Global standard kit, real members: the session-list hook every slot component receives. */
|
||||
interface GlobalStandardProps {
|
||||
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* load one by one in inject topology.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
|
||||
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is watch-driven: a scope is minted lazily on first
|
||||
* resolution; a session leaving the list tears its scope down only when
|
||||
@@ -13,8 +15,9 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -28,8 +31,12 @@ export interface SessionSummary {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Session list store shape. */
|
||||
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
|
||||
/**
|
||||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
@@ -69,15 +76,26 @@ interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
|
||||
cell: SessionCell
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
|
||||
readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open}. Projection validates it against the live list
|
||||
* instead of destructively pruning, so a selection survives transient list
|
||||
* states (reconnect re-pull) and resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
|
||||
private watched: SessionId | undefined
|
||||
@@ -90,13 +108,29 @@ export class SessionsService {
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere (the sole selection write path).
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
if (this.list.getSnapshot().byId[id] === undefined) {
|
||||
throw new Error(`sessions.open: unknown session ${id}`)
|
||||
}
|
||||
this.selection.update((draft) => { draft.sessionId = id })
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
@@ -132,6 +166,23 @@ export class SessionsService {
|
||||
return record.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer session cell (SessionProvider's feed through
|
||||
* the renderer host; ctx never enters the render layer). Marks the session
|
||||
* watched, same as {@link SessionsService.binding}.
|
||||
* @param id - session id.
|
||||
* @returns cell, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined {
|
||||
const record = this.resolve(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (this.watched !== id) {
|
||||
this.watched = id as SessionId
|
||||
this.sweepDeferred()
|
||||
}
|
||||
return record.cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
@@ -158,10 +209,14 @@ export class SessionsService {
|
||||
if (this.list.getSnapshot().byId[id] === undefined) return undefined
|
||||
const fiber = this.rootCtx.plugin(sessionScope)
|
||||
const ctx = fiber.ctx.extend({ [kScope]: id })
|
||||
const session = this.manager.get(id)
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session: this.manager.get(id), ctx },
|
||||
binding: { sessionId: id, session, ctx },
|
||||
// Bare source form (store migration): the Session object IS the
|
||||
// observable; the React side binds the useSession hook per cell.
|
||||
cell: { sessionId: id, session },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
@@ -183,7 +238,11 @@ export class SessionsService {
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
this.list.set({ ids, byId })
|
||||
// current = the persisted selection, masked while its session is absent
|
||||
// (falls to the empty state; resurfaces if the session returns).
|
||||
const selected = this.selection.getSnapshot().sessionId
|
||||
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
|
||||
this.list.set({ ids, byId, current })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
@@ -197,10 +256,18 @@ export class SessionsService {
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
void record.fiber.dispose()
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
@@ -220,7 +287,7 @@ export class SessionsService {
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
void record.fiber.dispose()
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
@@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts'
|
||||
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
|
||||
/**
|
||||
* Per-session state owner: event window + fold + partial, snapshot out via
|
||||
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
|
||||
* only (store migration): the React machinery binds the per-cell useSession
|
||||
* hook at its own seam — no selector hook member lives on the data layer.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
|
||||
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
|
||||
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
private events: SessionEvent[] = []
|
||||
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
|
||||
|
||||
@@ -1,22 +1,84 @@
|
||||
/**
|
||||
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
|
||||
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
|
||||
* run through the caller's ctx.effect so a plugin's registrations are
|
||||
* collected when its fiber unloads (cordis-native cascade).
|
||||
* SlotsService: the cordis Service layer of the slot system over the pure
|
||||
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
|
||||
* the load-time validations, and the unload cascade). This layer owns what
|
||||
* needs the runtime: the 'slots/changed' event bridge, register through the
|
||||
* caller's ctx.effect (fiber unload collects registrations), the renderer
|
||||
* install seam (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.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
|
||||
* in this compilation unit (intersection reads `never`) but consumers merge
|
||||
* keys in; the rule fires on the empty-map view, not on real redundancy. */
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
|
||||
* holds this package's 'root' row in this compilation unit, but consumers
|
||||
* merge keys in; the rule fires on the narrow-map view, not on real
|
||||
* redundancy. */
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from './index.ts'
|
||||
import type {
|
||||
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
|
||||
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Root owner share: the shell supplies nothing — the frame is inject-assembled. */
|
||||
export interface RootOwnerProps { children?: never }
|
||||
|
||||
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
|
||||
const ROOT_INSTANCE_KEY = 'root'
|
||||
|
||||
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
|
||||
// takes the scope key (per-session localStorage suffix) and instances expose
|
||||
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
|
||||
// these local structural faces bridge until fw-slots lifts them.
|
||||
|
||||
/** Store handle face as the engine actually ships it (scope-key-aware create). */
|
||||
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
|
||||
|
||||
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
|
||||
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
|
||||
|
||||
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
|
||||
interface StoreAxisRecord {
|
||||
/** Scope of the slot the handle mounted under (the core validated cross-scope conflicts). */
|
||||
scope: SlotScope
|
||||
/** Live registrations holding the handle. */
|
||||
refs: number
|
||||
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
|
||||
instances: Map<string, EngineStoreInstance>
|
||||
}
|
||||
|
||||
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
|
||||
interface ErasedRegisterOptions {
|
||||
name: string
|
||||
children?: Record<string, SlotSpec<SlotEntryDef>>
|
||||
store?: StoreDecl
|
||||
inject?: (...args: never[]) => Record<string, unknown>
|
||||
key?: string
|
||||
id?: string
|
||||
order?: number
|
||||
label?: string
|
||||
registrant?: string
|
||||
}
|
||||
|
||||
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
|
||||
interface ErasedCore { register(options: object, component: unknown): () => void }
|
||||
|
||||
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
|
||||
export class SlotsService extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
|
||||
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
|
||||
private _renderer: SlotRenderer | undefined
|
||||
private _host: SlotRendererHost | undefined
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context.
|
||||
@@ -27,44 +89,92 @@ export class SlotsService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param spec - kind/scope spec.
|
||||
* @returns disposer.
|
||||
* The single registration API. The typed face IS the core's register
|
||||
* (both overloads reused verbatim — one authority, no structural copy;
|
||||
* see SlotCore.register for children declaration, store seat, inject
|
||||
* face, load-time validation, and the unload cascade). This layer adds:
|
||||
* disposal through the caller's ctx.effect (fiber unload = cascade),
|
||||
* exclusive-factory minting (`store: createXxxStore` becomes a per-entry
|
||||
* handle), the registrant diagnostics stamp, and store-instance lifecycle
|
||||
* on the entry axis.
|
||||
*
|
||||
* Declared here, implemented by prototype assignment below the class: it
|
||||
* MUST stay a prototype method (never an instance arrow) — the cordis
|
||||
* service proxy binds `this.ctx` to the CALLER's context at call time,
|
||||
* which is what routes the effect (and the unload cascade) into the
|
||||
* caller's fiber. An arrow property would freeze `this` to the service's
|
||||
* own root ctx and silently break per-plugin disposal.
|
||||
*/
|
||||
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
|
||||
declare readonly register: SlotCore['register']
|
||||
|
||||
/**
|
||||
* Install the shell's renderer (web-react's createSlotRenderer product).
|
||||
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
|
||||
* so shell fiber unload uninstalls the renderer.
|
||||
* @param renderer - the outlet machinery implementing SlotRenderer.
|
||||
*/
|
||||
install(renderer: SlotRenderer): void {
|
||||
if (this._renderer !== undefined) throw new Error('slot renderer already installed (install() is boot-once)')
|
||||
this.ctx.effect(() => {
|
||||
this._renderer = renderer
|
||||
return () => {
|
||||
if (this._renderer === renderer) this._renderer = undefined
|
||||
}
|
||||
}, 'slots.install()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param component - contributed component.
|
||||
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
|
||||
* inject factory's binding is pinned to ClientContext.
|
||||
* @returns disposer.
|
||||
* The single ctx-level render entry: the shell renders 'root'; every other
|
||||
* key renders inside components through the props renderSlot face. All
|
||||
* three guards are fail-loud boot-order checks, no fallback.
|
||||
* @param key - must be 'root' (runtime-enforced for dynamically composed callers).
|
||||
* @param owner - owner share for the root entry (the shell supplies {}).
|
||||
* @returns the rendered root tree.
|
||||
*/
|
||||
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
|
||||
// Client-context registrations have exactly one ctx shape: pin Ctx to
|
||||
// ClientContext so inject factories dot services without a cast.
|
||||
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
|
||||
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
|
||||
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): ReturnType<SlotRenderer['renderRoot']> {
|
||||
// Widened: in this package's own program SlotMap holds only 'root', which
|
||||
// would fold the guard to constant-false; the check exists for plain-JS
|
||||
// and cross-program callers where K is wider.
|
||||
if ((key as string) !== 'root') {
|
||||
throw new Error(`ctx-level renderSlot only renders 'root' (got "${key}"); child slots render through the component props face`)
|
||||
}
|
||||
if (this._renderer === undefined) {
|
||||
throw new Error("slot renderer not installed — boot must call ctx.slots.install(createSlotRenderer()) before rendering 'root'")
|
||||
}
|
||||
if (this._core.entries('root').length === 0) {
|
||||
throw new Error("'root' has no registration — a layout entry must register into 'root' before the shell renders it")
|
||||
}
|
||||
return this._renderer.renderRoot(this.hostFace(), owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot entries for a key.
|
||||
* @param key - SlotMap key.
|
||||
* @returns registered entries (stable reference between mutations).
|
||||
* Drop the per-session store instances of a dead session (the sessions
|
||||
* service calls this on scope teardown; root-scoped records are untouched).
|
||||
* Persisted state goes with the session — a never-rendered dead session can
|
||||
* still own keys from an earlier page load, so the instance is materialized
|
||||
* transiently just to clear storage (no-op for unpersisted stores).
|
||||
* @param sessionId - the torn-down session.
|
||||
*/
|
||||
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
|
||||
pruneStoreScope(sessionId: string): void {
|
||||
for (const [handle, record] of this._stores) {
|
||||
if (record.scope !== 'session') continue
|
||||
const instance = record.instances.get(sessionId) ?? handle.create(sessionId)
|
||||
instance.clearPersisted()
|
||||
record.instances.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot entries for a key (render-erased view; stable reference between mutations).
|
||||
* @param key - SlotMap key.
|
||||
* @returns registered entries.
|
||||
*/
|
||||
entries(key: keyof SlotMap & string): readonly StoredEntry[] {
|
||||
return this._core.entries(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a defined spec.
|
||||
* Look up a declared spec (register-declared or the built-in 'root').
|
||||
* @param key - SlotMap key.
|
||||
* @returns spec or undefined.
|
||||
*/
|
||||
@@ -72,15 +182,6 @@ export class SlotsService extends Service {
|
||||
return this._core.spec(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
|
||||
* @param key - candidate slot key.
|
||||
* @returns wide-typed spec or undefined.
|
||||
*/
|
||||
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
|
||||
return this._core.specDynamic(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
* @param key - SlotMap key.
|
||||
@@ -100,8 +201,114 @@ export class SlotsService extends Service {
|
||||
return this._core.getVersion(key)
|
||||
}
|
||||
|
||||
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
|
||||
get core(): SlotCore {
|
||||
return this._core
|
||||
/** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */
|
||||
private _register(options: ErasedRegisterOptions, component: unknown): () => void {
|
||||
// Exclusive stores pass the factory itself: minted here into a per-entry
|
||||
// handle so the stored entry always carries a resolvable handle (the
|
||||
// core's shared-handle scope pinning applies to it harmlessly).
|
||||
const store = typeof options.store === 'function' ? options.store() : options.store
|
||||
const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name
|
||||
const erased: ErasedRegisterOptions = {
|
||||
...options,
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(registrant !== undefined ? { registrant } : {}),
|
||||
}
|
||||
// Core write first: all load-time validation (undeclared target,
|
||||
// duplicate declaration, kind conflicts, cross-scope handle) throws
|
||||
// there before this layer commits anything.
|
||||
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
|
||||
this._acquire(store, scope)
|
||||
}
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
dispose()
|
||||
if (store !== undefined) this._release(store)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
|
||||
private hostFace(): SlotRendererHost {
|
||||
if (this._host !== undefined) return this._host
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) {
|
||||
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// Identity-stable view: current rides the list snapshot (arbitrated), but
|
||||
// the provider consumes it as its own observable; one cached object keeps
|
||||
// the renderer's per-source hook cache stable.
|
||||
const current = {
|
||||
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
|
||||
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
|
||||
}
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
entriesOf: key => this._core.entries(key),
|
||||
specOf: key => this._core.specDynamic(key),
|
||||
isLive: entry => this._core.isLive(entry),
|
||||
storeOf: (entry, scopeKey) =>
|
||||
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
cell: id => sessions.cell(id),
|
||||
},
|
||||
}
|
||||
return this._host
|
||||
}
|
||||
|
||||
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
|
||||
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
|
||||
const record = this._stores.get(handle)
|
||||
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
|
||||
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
|
||||
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
|
||||
let instance = record.instances.get(key)
|
||||
if (instance === undefined) {
|
||||
// Session instances get the scope key (the engine suffixes the persist
|
||||
// key per session); root instances stay keyless.
|
||||
instance = record.scope === 'session' ? handle.create(key) : handle.create()
|
||||
record.instances.set(key, instance)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
|
||||
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
|
||||
const record = this._stores.get(handle)
|
||||
if (record === undefined) {
|
||||
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
|
||||
return
|
||||
}
|
||||
record.refs += 1
|
||||
}
|
||||
|
||||
/** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */
|
||||
private _release(handle: EngineStoreHandle): void {
|
||||
const record = this._stores.get(handle)
|
||||
/* v8 ignore next -- defensive: release only runs from a disposer whose
|
||||
* register acquired the same handle, so the record must exist; kept so a
|
||||
* future call site cannot underflow the axis. */
|
||||
if (record === undefined) return
|
||||
record.refs -= 1
|
||||
if (record.refs === 0) this._stores.delete(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// register's implementation (prototype assignment pairs with the `declare`
|
||||
// inside the class — see its JSDoc for why it must live on the prototype).
|
||||
// Element access reaches the private _register legally and keeps it a
|
||||
// TS-visible read.
|
||||
;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register
|
||||
= function register(this: SlotsService, rawOptions: object, component: unknown): () => void {
|
||||
// The core's overloads proved the shares; the implementation works on
|
||||
// the erased view (same pattern as the core's own implementation arm).
|
||||
const options = rawOptions as ErasedRegisterOptions
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ describe('runtime client apply', () => {
|
||||
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
|
||||
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).
|
||||
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const sessions = bench.ctx.get('sessions')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
|
||||
* through the loader chain (execute → handoff → factory(require) → apply →
|
||||
* export re-registration). Skips when the bundle is not built (lib/client.js is a
|
||||
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import * as webReact from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createClientLoader } from '../src/client/loader/index.ts'
|
||||
import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { SlotsService } from '../src/client/slots.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).DSHClientProxy
|
||||
delete (globalThis as Win).window
|
||||
})
|
||||
|
||||
function readLayoutBundle(): string | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe('real tsdown bundle through the loader', () => {
|
||||
const code = readLayoutBundle()
|
||||
|
||||
it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
|
||||
// The bundle banner addresses window.DSHClientProxy; node has no window —
|
||||
// alias it to globalThis so the loader-installed proxy is reachable.
|
||||
;(globalThis as Win).window = globalThis
|
||||
const ctx = new Context()
|
||||
// The layout apply consumes the slots + sessions services; the real chain
|
||||
// loads the runtime bundle first — stand both up directly here.
|
||||
ctx.plugin(SlotsService)
|
||||
await ctx.fiber.await()
|
||||
new SessionsService(ctx, new FakeApiClient())
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
// The real bundle externals resolved from the seeded table. React is a
|
||||
// type-only import in the layout bundle today, but jsx-runtime is real.
|
||||
modules: {
|
||||
'react': await import('react'),
|
||||
'react/jsx-runtime': await import('react/jsx-runtime'),
|
||||
'@deepseek-ai/dsh-client-ui-slots': uiSlots,
|
||||
'@deepseek-ai/dsh-client-web-react': webReact,
|
||||
},
|
||||
boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
|
||||
fetchBundle: () => Promise.resolve(code as string),
|
||||
// node has no DOM: evaluate the bundle body directly (same synchronous
|
||||
// handoff contract as the <script> path).
|
||||
executeBundle: (bundleCode) => {
|
||||
// Node has no <script>: Function-evaluating the built bundle IS the
|
||||
// system under test (same synchronous handoff as the browser path).
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
|
||||
new Function(bundleCode)()
|
||||
},
|
||||
})
|
||||
loader.start()
|
||||
await loader.settled()
|
||||
expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
|
||||
const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
|
||||
expect(typeof surface.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -25,9 +25,11 @@ describe('runtime slots/changed invariant', () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
|
||||
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
|
||||
// A real define bumps the version first and re-emits through onMutate —
|
||||
// the audit sees version > 0 and stays quiet.
|
||||
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
|
||||
// A real registration bumps the version first and re-emits through
|
||||
// onMutate — the audit sees version > 0 and stays quiet. (Erased call:
|
||||
// the typed register face rides the wave-1 ui-slots types.)
|
||||
const slots = ctx.slots as unknown as { register(options: object, component: unknown): () => void }
|
||||
expect(() => slots.register({ name: 'root' }, () => null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails loud on a missing key and on an emission with no applied mutation', async () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* SessionsService: list store projection (manager → {ids, byId} with derived
|
||||
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
|
||||
* teardown with watch deferral), binding identity, ancestry walk, create.
|
||||
* SessionsService: list store projection (manager → {ids, byId, current}
|
||||
* with derived titles), the migrated current-selection account (open
|
||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with watch
|
||||
* deferral), binding identity, ancestry walk, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
@@ -112,6 +114,97 @@ describe('scope tree', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
it('open() writes list.current; unknown ids fail loud', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1')
|
||||
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
|
||||
})
|
||||
|
||||
it('masks (not destroys) the selection while its session is off the list', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1'))
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1')
|
||||
})
|
||||
|
||||
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
})
|
||||
const first = bench()
|
||||
await feedList(first, [{ id: 's1' }])
|
||||
first.svc.open(sid('s1'))
|
||||
expect(storage.get('dsh.sessions.current')).toContain('s1')
|
||||
// A fresh boot (same storage) recovers the selection once the list holds the session.
|
||||
const second = bench()
|
||||
await feedList(second, [{ id: 's1' }])
|
||||
expect(second.svc.list.getSnapshot().current).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// Bare-source form (store migration): the cell carries the Session
|
||||
// observable itself; hook binding happens in the React machinery.
|
||||
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the watch like binding(): switching cells sweeps a deferred removal', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.cell('s1') // watched
|
||||
await feedList(b, []) // removed while watched → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
await feedList(b, [{ id: 's2' }])
|
||||
b.svc.cell('s2') // watch moves → sweep tears s1 down
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot-store scope prune hook', () => {
|
||||
it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
|
||||
const b = bench()
|
||||
const pruneStoreScope = vi.fn()
|
||||
b.ctx.reflect.provide('slots', { pruneStoreScope })
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s2')) // s2 watched
|
||||
await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
|
||||
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
|
||||
await feedList(b, [{ id: 's3' }])
|
||||
b.svc.binding(sid('s3')) // watch moves → deferred sweep drops s2
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
|
||||
})
|
||||
|
||||
it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.scope(sid('s1'))
|
||||
await feedList(b, []) // teardown without ctx.slots must not throw
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ancestry', () => {
|
||||
it('walks parentId links root-first including self; broken links stop the walk', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -1,80 +1,385 @@
|
||||
/**
|
||||
* SlotsService: cordis Service wrapper semantics — core delegation, the
|
||||
* 'slots/changed' event bridge, and fiber-scoped registration disposal.
|
||||
* SlotsService terminal-design account (design.md §11-3 main landing):
|
||||
* 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
|
||||
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '../src/client/slots.ts'
|
||||
|
||||
// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
|
||||
// Test-only slot keys (merged so the typed entries/spec faces accept them).
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
't-single': { kind: 'single'; scope: 'root'; props: object }
|
||||
't-list': { kind: 'list'; scope: 'root'; props: object }
|
||||
't.host': { kind: 'single'; scope: 'root' }
|
||||
't.panel': { kind: 'single'; scope: 'session' }
|
||||
't.rows': { kind: 'list'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
const C: FC<object> = () => null
|
||||
|
||||
async function boot(): Promise<Context> {
|
||||
/**
|
||||
* Register/install/renderSlot through a type-erased view: the typed register
|
||||
* face rides wave-1 ui-slots types (red until that wave lands); the runtime
|
||||
* semantics under test are final.
|
||||
*/
|
||||
interface ErasedService {
|
||||
register(options: object, component: unknown): () => void
|
||||
install(renderer: object): void
|
||||
renderSlot(key: string, owner: object): unknown
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
svc: SlotsService
|
||||
erased: ErasedService
|
||||
}
|
||||
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
ctx.plugin(SlotsService)
|
||||
await ctx.fiber.await()
|
||||
return ctx
|
||||
// Service accessor (ctx.get reads the reflect store, which Service-class
|
||||
// plugins do not write; the accessor is the product path).
|
||||
const svc = ctx.slots
|
||||
return { ctx, svc, erased: svc as unknown as ErasedService }
|
||||
}
|
||||
|
||||
describe('SlotsService', () => {
|
||||
it('proxies define/register/entries/spec/getVersion to the core', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
|
||||
expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const v0 = ctx.slots.getVersion('t-single')
|
||||
ctx.slots.register('t-single', C)
|
||||
expect(ctx.slots.entries('t-single')).toHaveLength(1)
|
||||
expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
|
||||
expect(ctx.slots.core.spec('t-single')).toBeDefined()
|
||||
/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
|
||||
interface FakeInstance {
|
||||
getSnapshot: () => undefined
|
||||
subscribe: () => () => void
|
||||
actions: Record<string, never>
|
||||
clearPersisted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
/** Fake store handle factory (create-count and clearPersisted observable). */
|
||||
function fakeHandle() {
|
||||
const created: FakeInstance[] = []
|
||||
const handle = {
|
||||
create: vi.fn((_scopeKey?: string): FakeInstance => {
|
||||
const instance: FakeInstance = {
|
||||
getSnapshot: () => undefined, subscribe: () => () => undefined,
|
||||
actions: {}, clearPersisted: vi.fn(),
|
||||
}
|
||||
created.push(instance)
|
||||
return instance
|
||||
}),
|
||||
}
|
||||
return { handle, created }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a capturing renderer, occupy 'root' (declaring `children` in the
|
||||
* same call — 'root' is single, so the one occupant is also the declarer),
|
||||
* and pull the host face out through renderSlot('root').
|
||||
*/
|
||||
function captureHost(bench: Bench, children?: object): SlotRendererHost {
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({
|
||||
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
|
||||
})
|
||||
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
return host
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + cell). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
cell: (id: string) => (id === 'known'
|
||||
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
|
||||
: undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe("built-in 'root'", () => {
|
||||
it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
|
||||
expect(bench.svc.entries('root')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("re-emits every mutation as 'slots/changed' with the key", async () => {
|
||||
const ctx = await boot()
|
||||
const seen: string[] = []
|
||||
ctx.on('slots/changed', (key) => { seen.push(key) })
|
||||
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
|
||||
ctx.slots.register('t-list', C, { id: 'a' })
|
||||
expect(seen).toEqual(['t-list', 't-list'])
|
||||
it('rejects a second declaration of root, attributing the built-in row', async () => {
|
||||
const bench = await boot()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
|
||||
}, C)).toThrow(/already declared.*built-in/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('load-time validation', () => {
|
||||
it('throws on contributing into an undeclared slot', async () => {
|
||||
const bench = await boot()
|
||||
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
|
||||
})
|
||||
|
||||
it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
|
||||
const fiber = ctx.plugin({
|
||||
it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
|
||||
bench.erased.register({
|
||||
name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
expect(() => bench.erased.register({
|
||||
name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
|
||||
})
|
||||
|
||||
it('throws when one store handle is bound to two scopes', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.panel': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, C)
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.register({ name: 't.host', store: handle }, C)
|
||||
expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
|
||||
.toThrow(/one handle, one scope/)
|
||||
})
|
||||
|
||||
it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).toThrow(/already has a registration/)
|
||||
// The failing call's declaration must not have landed.
|
||||
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderer install seam', () => {
|
||||
it('throws on renderSlot before install (boot-order guidance)', async () => {
|
||||
const bench = await boot()
|
||||
expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
|
||||
})
|
||||
|
||||
it('throws on double install', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.install({ renderRoot: () => null })
|
||||
expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('throws on any non-root key (single ctx-level entry)', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.install({ renderRoot: () => null })
|
||||
expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
|
||||
})
|
||||
|
||||
it("throws on renderSlot('root') before any root registration", async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.install({ renderRoot: () => null })
|
||||
expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
|
||||
})
|
||||
|
||||
it('renders through the installed renderer and returns its product', async () => {
|
||||
const bench = await boot()
|
||||
const renderRoot = vi.fn(() => 'tree')
|
||||
bench.erased.install({ renderRoot })
|
||||
bench.erased.register({ name: 'root' }, C)
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
expect(bench.erased.renderSlot('root', {})).toBe('tree')
|
||||
expect(renderRoot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host face', () => {
|
||||
it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
|
||||
const dispose = bench.erased.register({ name: 't.host' }, C)
|
||||
const rootEntry = host.entriesOf('root')[0]
|
||||
expect(rootEntry).toBeDefined()
|
||||
expect(rootEntry?.component).toBe(C)
|
||||
expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const childEntry = host.entriesOf('t.host')[0]
|
||||
expect(host.isLive(childEntry as never)).toBe(true)
|
||||
dispose()
|
||||
expect(host.isLive(childEntry as never)).toBe(false)
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('store instance axis', () => {
|
||||
/** Boot with 'root' occupied and the three test children declared. */
|
||||
async function storeBench() {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench, {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.rows': { kind: 'list', scope: 'root' },
|
||||
't.panel': { kind: 'single', scope: 'session' },
|
||||
})
|
||||
return { bench, host }
|
||||
}
|
||||
|
||||
it('resolves one instance per (handle x root scope) shared across entries', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.register({ name: 't.host', store: handle }, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
|
||||
const [hostEntry] = host.entriesOf('t.host')
|
||||
const [rowEntry] = host.entriesOf('t.rows')
|
||||
const a = host.storeOf(hostEntry as never, undefined)
|
||||
const b = host.storeOf(rowEntry as never, undefined)
|
||||
expect(a).toBeDefined()
|
||||
expect(a).toBe(b) // shared handle, same scope key = same instance
|
||||
expect(handle.create).toHaveBeenCalledTimes(1)
|
||||
expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
|
||||
})
|
||||
|
||||
it('resolves per-session instances keyed by session id, created with the scope key', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const [entry] = host.entriesOf('t.panel')
|
||||
const s1 = host.storeOf(entry as never, 's1')
|
||||
const s2 = host.storeOf(entry as never, 's2')
|
||||
expect(s1).not.toBe(s2)
|
||||
expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
|
||||
expect(handle.create).toHaveBeenCalledWith('s1')
|
||||
expect(handle.create).toHaveBeenCalledWith('s2')
|
||||
expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
|
||||
})
|
||||
|
||||
it('mints a fresh handle per register for the factory (exclusive) form', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const factory = vi.fn(() => fakeHandle().handle)
|
||||
bench.erased.register({ name: 't.host', store: factory }, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
|
||||
const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
|
||||
expect(a).not.toBe(b) // two mints, two instances
|
||||
})
|
||||
|
||||
it('drops instances with the last holding entry and refuses stale resolution', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle } = fakeHandle()
|
||||
const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
|
||||
const rowEntry = host.entriesOf('t.rows')[0]
|
||||
const hostEntry = host.entriesOf('t.host')[0]
|
||||
const shared = host.storeOf(rowEntry as never, undefined)
|
||||
d1() // one holder left: record (and instance) survive
|
||||
expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
|
||||
expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
|
||||
// Note: dropping the row entry would sever the last reference; stale
|
||||
// resolution is covered through the cascade spec below.
|
||||
})
|
||||
|
||||
it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle, created } = fakeHandle()
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const [entry] = host.entriesOf('t.panel')
|
||||
const s1 = host.storeOf(entry as never, 's1')
|
||||
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
|
||||
bench.svc.pruneStoreScope('s1')
|
||||
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
|
||||
expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
|
||||
// Never-rendered dead session: a transient instance is created just to clear storage.
|
||||
const before = created.length
|
||||
bench.svc.pruneStoreScope('s-never')
|
||||
expect(created.length).toBe(before + 1)
|
||||
expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entry-unload cascade', () => {
|
||||
it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
|
||||
const bench = await boot()
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({
|
||||
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
|
||||
})
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
// The declarer here is NOT the root occupant: root stays occupied by a
|
||||
// separate entry so disposing the declarer only kills its children.
|
||||
const disposeRoot = bench.erased.register({ name: 'root' }, C)
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
disposeRoot()
|
||||
const disposeDeclarer = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.host' }, C)
|
||||
const [childEntry] = host.entriesOf('t.host')
|
||||
expect(childEntry).toBeDefined()
|
||||
|
||||
disposeDeclarer()
|
||||
expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
|
||||
expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
|
||||
expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
|
||||
// The freed key is re-declarable by a new entry (no residue).
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
})
|
||||
|
||||
it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const fiber = bench.ctx.plugin({
|
||||
name: 'occupant',
|
||||
inject: ['slots'],
|
||||
apply: (pluginCtx: Context) => {
|
||||
pluginCtx.slots.register('t-single', C)
|
||||
;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('t-single')).toHaveLength(1)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('t-single')).toHaveLength(0)
|
||||
// The slot definition (registered from root) survives; a new occupant may register.
|
||||
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
|
||||
})
|
||||
|
||||
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
|
||||
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
|
||||
let notified = 0
|
||||
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
|
||||
ctx.slots.register('t-list', C, { id: 'row' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
|
||||
unsubscribe()
|
||||
it('disposer is idempotent (stale second call is a no-op)', async () => {
|
||||
const bench = await boot()
|
||||
const dispose = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
dispose()
|
||||
expect(() => { dispose() }).not.toThrow()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event bridge', () => {
|
||||
it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
|
||||
const bench = await boot()
|
||||
const seen: string[] = []
|
||||
bench.ctx.on('slots/changed', (key) => { seen.push(key) })
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a' }, C)
|
||||
expect(seen).toEqual(['root', 't.rows', 't.rows'])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
|
||||
|
||||
interface State {
|
||||
a: { n: number }
|
||||
@@ -124,6 +124,98 @@ describe('createSnapshotStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineStore', () => {
|
||||
const declare = () => defineStore({
|
||||
init: () => ({ selection: null as string | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, target: string) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
|
||||
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
|
||||
const inst = declare().create()
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
|
||||
inst.actions.setDraft('hello')
|
||||
inst.actions.select('m1')
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
|
||||
inst.actions.clearDraft()
|
||||
expect(inst.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
|
||||
const inst = declare().create()
|
||||
const before = inst.store.getSnapshot()
|
||||
inst.actions.setDraft('x')
|
||||
const after = inst.store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
|
||||
})
|
||||
|
||||
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
|
||||
const handle = declare()
|
||||
const a = handle.create()
|
||||
const b = handle.create()
|
||||
a.actions.setDraft('only-a')
|
||||
expect(b.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const handle = defineStore({
|
||||
init: () => ({ draft: '' }),
|
||||
persist: 'spec.chat',
|
||||
actions: { setDraft: (d, text: string) => { d.draft = text } },
|
||||
})
|
||||
handle.create('s1').actions.setDraft('one')
|
||||
handle.create('s2').actions.setDraft('two')
|
||||
handle.create().actions.setDraft('root')
|
||||
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
|
||||
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
|
||||
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
|
||||
// Rehydration honors the same suffixed key.
|
||||
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
|
||||
// Scope-death cleanup removes exactly the suffixed key.
|
||||
handle.create('s1').clearPersisted()
|
||||
expect(backing.has('spec.chat.s1')).toBe(false)
|
||||
expect(backing.has('spec.chat.s2')).toBe(true)
|
||||
expect(backing.has('spec.chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
|
||||
const inst = declare().create('s1') // no persist key declared
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
const persisting = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.nostorage',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
// jsdom-less lane: localStorage may exist here, so simulate its absence.
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(() => { persisting.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => { throw new Error('quota / private mode') },
|
||||
})
|
||||
const inst = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.throwing',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowEqual', () => {
|
||||
it('matches one-level-equal objects and rejects deeper drift', () => {
|
||||
const leaf = { deep: 1 }
|
||||
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -36,7 +36,6 @@ export const CLIENT_EXTERNALS = [
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-web-react/store',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-connection/client',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
@@ -81,6 +80,21 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
dts: false,
|
||||
clean: false,
|
||||
external: CLIENT_EXTERNALS,
|
||||
// Browser bundles inline node-idiom deps (zustand/immer read
|
||||
// process.env.NODE_ENV; zustand's esm build also probes
|
||||
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
|
||||
// EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
|
||||
// needs the substitutions here or the factory throws ReferenceError at
|
||||
// boot / the build gate reds. Both keys honor the build's NODE_ENV so a
|
||||
// dev build keeps the dev-branch semantics; artifacts default to production.
|
||||
// The bare `import.meta.env` key is required alongside the precise MODE
|
||||
// key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
|
||||
// the truthiness probe would otherwise survive as an empty import.meta.
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||
},
|
||||
// tsdown auto-externalizes package dependencies; anything NOT in the
|
||||
// loader module table must inline instead (wire/type layers, zod, clsx —
|
||||
// every non-shared dep). A require() the table cannot answer is a
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# @deepseek-ai/dsh-client-ui-conversation
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
|
||||
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
* Client plugin body: provide the conversation service and toolview registry,
|
||||
* register the conversation/details slot occupants and the no-session empty
|
||||
* state, and mount the chat view with its samples. Assembly only — components
|
||||
* receive everything through inject factories; nothing here renders directly.
|
||||
* receive everything through props: the framework standard kit and store
|
||||
* faces arrive automatically from the declarations below; the inject
|
||||
* factories contribute the plain-data-and-callbacks business face (design §5).
|
||||
*/
|
||||
import { createElement, Fragment, type ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { scopedSlots, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionsService, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
|
||||
import type { SelectionTarget } from './contract/views.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
import { childSessionScope, registerChat } from './chat/register.ts'
|
||||
@@ -37,20 +35,13 @@ function need<T>(ctx: Context, name: string): T {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */
|
||||
const cwdsCache = new WeakMap<SessionListState, readonly string[]>()
|
||||
function cwdsOf(state: SessionListState): readonly string[] {
|
||||
let cached = cwdsCache.get(state)
|
||||
if (cached === undefined) {
|
||||
const seen = new Set<string>()
|
||||
for (const id of state.ids) {
|
||||
const cwd = state.byId[id]?.cwd
|
||||
if (cwd !== undefined && cwd !== '') seen.add(cwd)
|
||||
}
|
||||
cached = [...seen]
|
||||
cwdsCache.set(state, cached)
|
||||
}
|
||||
return cached
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
|
||||
const conversation = scoped.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
|
||||
return conversation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,106 +71,64 @@ export function apply(ctx: Context): void {
|
||||
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
|
||||
'ui-conversation: bash toolview samples')
|
||||
|
||||
// ConvViewProps.slots is ScopedSlots<never>: a real outlet with an empty
|
||||
// whitelist (uncallable by type, correct runtime shape for future grants).
|
||||
const emptySlots = scopedSlots<never>(slots.core)
|
||||
// Shared store handle, constructed here so its identity lives and dies with
|
||||
// this fiber (a module-level handle would be a de-facto singleton). Both
|
||||
// session-slot registrations declare it; same scope key = same instance, so
|
||||
// conversation writes and details reads meet in one store.
|
||||
const chat = createChatStore()
|
||||
|
||||
/** conversation slot: skeleton surface assembled once per (entry x session). */
|
||||
const conversationInject = (b: SessionBinding): ConversationInjected => {
|
||||
const bctx = b.ctx as Context
|
||||
const scoped = need<ConversationService>(bctx, 'conversation')
|
||||
const id = b.sessionId as SessionId
|
||||
const useSession = b.session.useSelector as UseSession
|
||||
const selectionStore = scoped.selection
|
||||
const draftsStore = scoped.drafts
|
||||
const session = sessions.manager.get(id)
|
||||
// Watch-driven history pull: assembling the surface IS the watch signal
|
||||
// (once per entry x session; open() is idempotent and self-recovers).
|
||||
void session.open()
|
||||
|
||||
const viewProps: Omit<ConvViewProps, 'slots'> = {
|
||||
sessionId: id,
|
||||
useSession,
|
||||
useSelection: selectionStore.useSelector,
|
||||
actions: {
|
||||
openDetails: (target: SelectionTarget) => { scoped.openDetails(target) },
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
},
|
||||
}
|
||||
|
||||
const injected: ConversationInjected = {
|
||||
useAncestry: () => sessions.list.useSelector(
|
||||
() => sessions.ancestry(id),
|
||||
(a, b) => shallowEqual(a, b)),
|
||||
views: {
|
||||
list: () => conversation.views(),
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
},
|
||||
// layout's viewFor value type is its own looser ViewId; the registry is
|
||||
// the runtime validator (unknown ids fall back to the first view).
|
||||
useActiveView: () => layout.current.useSelector(s => s.viewFor[id]) as ViewId | undefined,
|
||||
composer: {
|
||||
useDraft: () => draftsStore.useSelector(s => s),
|
||||
setDraft: (text) => { draftsStore.set(text) },
|
||||
send: (mode) => {
|
||||
const text = draftsStore.getSnapshot().trim()
|
||||
if (text === '') return
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
store: chat,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
|
||||
const session = sessions.manager.get(sessionId)
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
// Watch-driven history pull: assembling the surface IS the watch signal
|
||||
// (once per entry x session; open() is idempotent and self-recovers).
|
||||
void session.open()
|
||||
return {
|
||||
views: {
|
||||
list: () => conversation.views(),
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
// Optimistic clear with failure restore (choreography lives with the
|
||||
// sender; the business failure also lands in snapshot.promptError).
|
||||
draftsStore.set('')
|
||||
void scoped.send(text, mode).catch(() => {
|
||||
if (draftsStore.getSnapshot() === '') draftsStore.set(text)
|
||||
})
|
||||
// The store write path stays inside the declared actions set:
|
||||
// restoreDraft itself no-ops once the user typed something new.
|
||||
actions.clearDraft()
|
||||
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
|
||||
},
|
||||
stop: () => {
|
||||
scoped.cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
openView: (view: ViewId) => { layout.openView(id, view) },
|
||||
open: (target: SessionId) => { layout.open(target) },
|
||||
},
|
||||
renderView: (entry: ViewEntry): ReactNode => {
|
||||
const children: ReactNode[] = []
|
||||
if (entry.chrome?.header !== undefined) {
|
||||
children.push(createElement(entry.chrome.header, { key: 'header', sessionId: id, useSession }))
|
||||
}
|
||||
children.push(createElement(entry.component, { key: 'view', ...viewProps, slots: emptySlots }))
|
||||
if (entry.chrome?.footer !== undefined) {
|
||||
children.push(createElement(entry.chrome.footer, { key: 'footer', sessionId: id, useSession }))
|
||||
}
|
||||
return createElement(Fragment, null, ...children)
|
||||
},
|
||||
}
|
||||
return injected
|
||||
}
|
||||
openDetails: (target: SelectionTarget) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
open: (target: SessionId) => { sessions.open(target) },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
|
||||
/** details slot: minimal selection-driven panel. */
|
||||
const detailsInject = (b: SessionBinding): DetailsInjected => {
|
||||
const bctx = b.ctx as Context
|
||||
const scoped = need<ConversationService>(bctx, 'conversation')
|
||||
const injected: DetailsInjected = {
|
||||
useSelection: scoped.selection.useSelector,
|
||||
actions: { closeDetails: () => { layout.closeDetails() } },
|
||||
}
|
||||
return injected
|
||||
}
|
||||
slots.register({
|
||||
name: 'details',
|
||||
store: chat,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
}),
|
||||
}, DetailsPanel)
|
||||
|
||||
/** conversation.empty root slot: the NEW SESSION hero. */
|
||||
const emptyInject = (): EmptyStateInjected => {
|
||||
const useCwds: SnapshotSelectorHook<readonly string[]> = (sel, eq) =>
|
||||
sessions.list.useSelector(s => sel(cwdsOf(s)), eq)
|
||||
const injected: EmptyStateInjected = {
|
||||
useCwds,
|
||||
actions: { startSession: opts => conversation.startSession(opts) },
|
||||
}
|
||||
return injected
|
||||
}
|
||||
|
||||
slots.register('conversation', ConversationRoot, { inject: conversationInject })
|
||||
slots.register('details', DetailsPanel, { inject: detailsInject })
|
||||
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
inject: (): EmptyStateInjected => ({
|
||||
startSession: opts => conversation.startSession(opts),
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
|
||||
import type { ToolViewProps } from '../contract/toolview.ts'
|
||||
@@ -38,7 +38,7 @@ const FOLLOW_THRESHOLD = 24
|
||||
|
||||
type OpenDetails = (target: SelectionTarget) => void
|
||||
|
||||
/** web-react's UseSession is deliberately wide (dependency direction); the
|
||||
/** ui-slots' UseSession is deliberately wide (dependency direction); the
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
@@ -122,7 +122,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const { toolviews, t } = deps
|
||||
|
||||
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
|
||||
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
|
||||
const useSession = useSessionWide as UseConversation
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
@@ -131,7 +131,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useSelection((sel) => sel?.callId)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChromeProps } from '../contract/views.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
|
||||
// (uSES over the registry version so unload falls back live) and renders it
|
||||
// behind a per-row error boundary. GenericToolCard is the render-side
|
||||
// fallback for both a registry miss and a crashed custom row. A registrant
|
||||
// inject factory is called once per (registration x binding) and cached,
|
||||
// mirroring the scoped-slots injection discipline.
|
||||
// fallback for both a registry miss and a crashed custom row. Pure props
|
||||
// machinery, zero React context: a registrant inject factory receives the
|
||||
// sessionId this outlet already holds, is called once per (registration x
|
||||
// session) and cached, mirroring the slot injection discipline.
|
||||
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import { useSessionBinding } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Component, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -19,19 +18,21 @@ export interface ToolViewOutletProps {
|
||||
viewProps: ToolViewProps
|
||||
}
|
||||
|
||||
/** Inject cache: per inject-factory (stable per registration) x binding object. */
|
||||
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
|
||||
/** Inject cache: per inject-factory (stable per registration) x session id.
|
||||
* The inner Map lives and dies with its factory (WeakMap entry), so entries
|
||||
* are bounded by the session count over the registration's lifetime. */
|
||||
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
|
||||
|
||||
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
|
||||
let perBinding = injectCache.get(inject)
|
||||
if (!perBinding) {
|
||||
perBinding = new WeakMap()
|
||||
injectCache.set(inject, perBinding)
|
||||
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
|
||||
let perSession = injectCache.get(inject)
|
||||
if (!perSession) {
|
||||
perSession = new Map()
|
||||
injectCache.set(inject, perSession)
|
||||
}
|
||||
let props = perBinding.get(binding)
|
||||
let props = perSession.get(sessionId)
|
||||
if (!props) {
|
||||
props = inject(binding)
|
||||
perBinding.set(binding, props)
|
||||
props = inject(sessionId)
|
||||
perSession.set(sessionId, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
@@ -61,16 +62,6 @@ class RowErrorBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
/** Split component: only inject-carrying registrations need the session
|
||||
* binding hook (keeps injectless rendering free of the Provider requirement). */
|
||||
function InjectedRow({ Row, inject, viewProps }: {
|
||||
Row: FC<ToolViewProps & object>; inject: ToolViewInject<object>; viewProps: ToolViewProps
|
||||
}) {
|
||||
const binding = useSessionBinding()
|
||||
const injected = cachedInject(inject, binding)
|
||||
return <Row {...{ ...injected, ...viewProps }} />
|
||||
}
|
||||
|
||||
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
|
||||
const version = useSyncExternalStore(
|
||||
(fn) => registry.subscribe(fn),
|
||||
@@ -83,7 +74,7 @@ export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: Too
|
||||
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
|
||||
{resolved.inject === undefined
|
||||
? <Row {...viewProps} />
|
||||
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
|
||||
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
|
||||
</RowErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,67 @@
|
||||
/**
|
||||
* Slot-ring contract for the conversation package: the composed props shapes
|
||||
* its registrants mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty — the SlotMap declarations live with ui-layout, the
|
||||
* slot owner). Per the share-ownership rule, the owner share is REFERENCED
|
||||
* from ui-layout and each registrant's injected share is declared here, next
|
||||
* to the component that receives it; full component props = owner share &
|
||||
* standard share & own injected share.
|
||||
* conversation.empty). Terminal slot design (§3): full component props are the
|
||||
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
|
||||
* (declared store's read/write faces) & the injected business face declared
|
||||
* here. No renderSlot share: none of the three registrations declares
|
||||
* children, so the zero-renderSlot inference applies.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { SelectionTarget, ViewEntry } from './views.ts'
|
||||
|
||||
/** Injected share of the conversation slot (assembled by apply's inject factory). */
|
||||
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
/**
|
||||
* Injected share of the conversation slot: plain data and callbacks only
|
||||
* (design §5 — hooks are framework-made). The store lines that used to ride
|
||||
* here live in the declared {@link ChatStore} now; ancestry derives from the
|
||||
* standard useSessions hook in-component; view rendering moved into the
|
||||
* component, which holds every share a view needs.
|
||||
*/
|
||||
export interface ConversationInjected {
|
||||
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
|
||||
useAncestry: () => readonly SessionSummary[]
|
||||
/** View registry read face (uSES triple from the conversation service). */
|
||||
views: {
|
||||
list(): readonly ViewEntry[]
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
}
|
||||
/** Active view accessor (layout.viewFor backed; undefined falls to 'chat'). */
|
||||
useActiveView: () => ViewId | undefined
|
||||
/** Composer surface: draft store hook pair + send/stop choreography. */
|
||||
composer: {
|
||||
useDraft: () => string
|
||||
setDraft(text: string): void
|
||||
send(mode: 'queue' | 'steer'): void
|
||||
stop(): void
|
||||
}
|
||||
actions: {
|
||||
openView(view: ViewId): void
|
||||
open(id: SessionId): void
|
||||
}
|
||||
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
|
||||
renderView: (entry: ViewEntry) => ReactNode
|
||||
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: owner share & standard share & injected share. */
|
||||
export type ConversationSlotProps = ConvOwnerProps & { useSession: UseSession } & ConversationInjected
|
||||
/** Full conversation-slot component props: runtime share & store share & injected share. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsStore<ChatStore> & ConversationInjected
|
||||
|
||||
/** Injected share of the details slot. */
|
||||
/**
|
||||
* Injected share of the details slot: the panel is otherwise a pure reader of
|
||||
* the shared chat store, but its close button is a layout orchestration call.
|
||||
*/
|
||||
export interface DetailsInjected {
|
||||
useSelection: SnapshotSelectorHook<SelectionTarget | null>
|
||||
actions: { closeDetails(): void }
|
||||
/** Close the details panel (layout geometry stays with ctx.layout). */
|
||||
closeDetails(): void
|
||||
}
|
||||
|
||||
/** Full details-slot component props. */
|
||||
export type DetailsSlotProps = DetailsOwnerProps & { useSession: UseSession } & DetailsInjected
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
|
||||
|
||||
/** Injected share of the no-session empty-state slot (root slot: no standard share). */
|
||||
/** Injected share of the no-session empty-state slot. */
|
||||
export interface EmptyStateInjected {
|
||||
/** cwd options derived from sessions.list (deduped; assembled by the inject factory). */
|
||||
useCwds: SnapshotSelectorHook<readonly string[]>
|
||||
actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> }
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
}
|
||||
|
||||
/** Full empty-state component props. */
|
||||
export type EmptyStateSlotProps = EmptyOwnerProps & EmptyStateInjected
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* implementation files import this, never each other.
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CallId, Translate } from './views.ts'
|
||||
|
||||
@@ -28,11 +27,13 @@ export interface ToolViewProps {
|
||||
|
||||
/**
|
||||
* Toolview inject factory: produces the registrant's private injected share
|
||||
* `I`, called once per (registration x session binding) and cached by the
|
||||
* render outlet. Session-bound by nature — tool rows always render inside a
|
||||
* session subtree.
|
||||
* `I`, called once per (registration x session) and cached by the render
|
||||
* outlet. Mirrors the slot inject shape (parameters derive from the
|
||||
* declaration): toolviews are session-domain by nature, so the factory
|
||||
* receives the session id only — service access goes through the
|
||||
* registrant's own apply-closure ctx (design §5; binding objects retired).
|
||||
*/
|
||||
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
|
||||
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
|
||||
|
||||
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
|
||||
export interface ToolViewOptions<I extends object = object> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* View-ring contract: the typed conversation view table and the props
|
||||
* surfaces handed to registered views. Shared face between the skeleton
|
||||
* domain (ConversationRoot renders views) and the chat domain (registers the
|
||||
* chat view); domain implementation files import this, never each other.
|
||||
* View-ring contract: the typed conversation view table, the chat store state
|
||||
* shared through it, and the props surfaces handed to registered views.
|
||||
* Shared face between the skeleton domain (ConversationRoot renders views)
|
||||
* and the chat domain (registers the chat view); domain implementation files
|
||||
* import this, never each other.
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
@@ -57,12 +57,33 @@ export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
|
||||
/** Selection target for the details linkage channel (toolcall is the step special case). */
|
||||
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
|
||||
|
||||
/** Props handed to registered conversation views. */
|
||||
/**
|
||||
* Chat store state (slot terminal design §4): the per-session store shared by
|
||||
* the conversation and details registrations. `createChatStore` implements
|
||||
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
|
||||
* `view` may carry a stale persisted id after a view plugin unloads — the
|
||||
* registry is the runtime validator (unknown ids fall back to the first view).
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
/** Details-linkage channel (conversation writes, details reads). */
|
||||
selection: SelectionTarget | null
|
||||
/** Composer draft (persisted; survives session switches and reloads). */
|
||||
draft: string
|
||||
/** Active conversation view id; null falls back to the first registered view. */
|
||||
view: ViewId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Props handed to registered conversation views. `useSession` and `useStore`
|
||||
* are the framework hooks ConversationRoot received as a slot registrant,
|
||||
* passed through unchanged (hook transfer is plain props passing; no
|
||||
* business-made subscription exists on this path). No renderSlot share: the
|
||||
* view ring delegates no sub-slots.
|
||||
*/
|
||||
export interface ConvViewProps {
|
||||
sessionId: SessionId
|
||||
useSession: UseSession
|
||||
useSelection: SnapshotSelectorHook<SelectionTarget | null>
|
||||
/** Chat store read face (selection is the only slice views consume today). */
|
||||
useStore: SnapshotSelectorHook<ChatStoreState>
|
||||
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
|
||||
/** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */
|
||||
slots: ScopedSlots<never>
|
||||
}
|
||||
|
||||
@@ -14,25 +14,17 @@ export { ConversationService } from './service.ts'
|
||||
export { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, ConvViewPropsOf,
|
||||
SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
|
||||
CallId, ChatStoreState, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps,
|
||||
ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
|
||||
} from './contract/views.ts'
|
||||
export type {
|
||||
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
|
||||
} from './contract/toolview.ts'
|
||||
export type {
|
||||
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
export type { ConversationRootProps } from './skeleton/ConversationRoot.tsx'
|
||||
export { InputBar } from './skeleton/InputBar.tsx'
|
||||
export type { InputBarError, InputBarProps } from './skeleton/InputBar.tsx'
|
||||
export { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
export type { EmptyStateProps } from './skeleton/EmptyState.tsx'
|
||||
export { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
export type { DetailsPanelProps } from './skeleton/DetailsPanel.tsx'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* ConversationService implementation: scope-addressed send/cancel, per-scope
|
||||
* selection/draft stores booked on the session scope fiber, view registry
|
||||
* with a uSES read face, openDetails orchestration, and the empty-state
|
||||
* startSession chain. Contract: api-contracts v3 section 7.
|
||||
* ConversationService implementation: scope-addressed send/cancel, view
|
||||
* registry with a uSES read face, and the empty-state startSession chain.
|
||||
* Contract: api-contracts v3 section 7. Selection/draft state moved to the
|
||||
* declared chat store (slot terminal design §4) — the per-scope store maps,
|
||||
* lazy construction, and prune bookkeeping this service used to carry are
|
||||
* retired; what remains is the send/stop orchestration face.
|
||||
*
|
||||
* Scope addressing rides the cordis Service tracker: property access through
|
||||
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
||||
@@ -20,11 +22,8 @@ import type { Context } from 'cordis'
|
||||
// SessionsService tags contexts with — scopeOf then always returns undefined
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SelectionTarget, ViewEntry, ViewId } from './index.ts'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ViewEntry, ViewId } from './index.ts'
|
||||
|
||||
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
|
||||
interface ViewsState {
|
||||
@@ -37,8 +36,6 @@ interface ViewsState {
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
private readonly selections = new Map<SessionId, SnapshotStore<SelectionTarget | null>>()
|
||||
private readonly draftStores = new Map<SessionId, SnapshotStore<string>>()
|
||||
private readonly viewsState: ViewsState = {
|
||||
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
|
||||
}
|
||||
@@ -71,37 +68,6 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/** Per-scope selection channel (details linkage); root access throws. */
|
||||
get selection(): SnapshotStore<SelectionTarget | null> {
|
||||
return this.scopeStore(this.selections, 'selection',
|
||||
() => createSnapshotStore<SelectionTarget | null>(null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope draft store, persisted per session id; root access throws.
|
||||
* Persistence is hand-rolled (raw string per key): the snapshot-store
|
||||
* engine's persist middleware object-spreads state on save, corrupting
|
||||
* primitive-state stores.
|
||||
*/
|
||||
get drafts(): SnapshotStore<string> {
|
||||
return this.scopeStore(this.draftStores, 'drafts', (id) => {
|
||||
const key = `dsh.conversation.draft.${id}`
|
||||
const store = createSnapshotStore<string>(loadDraft(key))
|
||||
store.subscribe(() => { saveDraft(key, store.getSnapshot()) })
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the scoped selection and open the details panel. Orchestration
|
||||
* only — panel geometry stays with ctx.layout.
|
||||
* @param target - selection target.
|
||||
*/
|
||||
openDetails(target: SelectionTarget): void {
|
||||
this.selection.set(target)
|
||||
this.requireLayout().openDetails()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a conversation view. Duplicate ids throw; the registration is an
|
||||
* effect on the caller's fiber (plugin unload collects it).
|
||||
@@ -170,9 +136,9 @@ export class ConversationService extends Service {
|
||||
const sessions = this.requireSessions()
|
||||
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
|
||||
// The manager notifier flushes per microtask; one await guarantees the
|
||||
// list-store projection landed before layout.open validates against it.
|
||||
// list-store projection landed before sessions.open validates against it.
|
||||
await Promise.resolve()
|
||||
this.requireLayout().open(id)
|
||||
sessions.open(id)
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
|
||||
// ctx.get, not scoped.conversation: property access walks the fiber
|
||||
@@ -185,34 +151,11 @@ export class ConversationService extends Service {
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag; root contexts fail loud. */
|
||||
private scopeId(op: string): SessionId {
|
||||
const id = scopeOf(this.ctx)
|
||||
if (id === undefined) {
|
||||
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope store account: lazily created, booked on the scope fiber so the
|
||||
* scope teardown (SessionsService prune) collects the entry.
|
||||
*/
|
||||
private scopeStore<T>(
|
||||
map: Map<SessionId, SnapshotStore<T>>, op: string,
|
||||
make: (id: SessionId) => SnapshotStore<T>): SnapshotStore<T> {
|
||||
const id = this.scopeId(op)
|
||||
let store = map.get(id)
|
||||
if (store === undefined) {
|
||||
store = make(id)
|
||||
map.set(id, store)
|
||||
this.ctx.effect(() => () => { map.delete(id) }, `conversation.${op} scope account`)
|
||||
}
|
||||
return store
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
@@ -223,12 +166,6 @@ export class ConversationService extends Service {
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
|
||||
private requireLayout(): LayoutService {
|
||||
const layout = this.ctx.get('layout')
|
||||
if (layout === undefined) throw new Error('conversation: layout service unavailable')
|
||||
return layout
|
||||
}
|
||||
}
|
||||
|
||||
function bumpViews(state: ViewsState): void {
|
||||
@@ -236,16 +173,3 @@ function bumpViews(state: ViewsState): void {
|
||||
state.tick += 1
|
||||
for (const fn of [...state.listeners]) fn()
|
||||
}
|
||||
|
||||
function loadDraft(key: string): string {
|
||||
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
return localStorage.getItem(key) ?? ''
|
||||
}
|
||||
|
||||
function saveDraft(key: string, text: string): void {
|
||||
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (text === '') localStorage.removeItem(key)
|
||||
else localStorage.setItem(key, text)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,82 @@
|
||||
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
|
||||
// Tab_Group + view area + composer). Zero framework imports — everything
|
||||
// arrives via props from the inject factory: breadcrumb feed, view registry
|
||||
// read face, per-view render, and the composer's draft/send choreography.
|
||||
// The active view id lives in layout.viewFor (shell viewing state), read and
|
||||
// written through injected accessors.
|
||||
// Tab_Group + view area + composer). Pure component — everything arrives via
|
||||
// props: the framework standard kit (useSession/sessionId/useSessions), the
|
||||
// declared chat store's useStore/actions, and the injected business face.
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||
import type { ConvViewProps, ViewEntry } from '../contract/views.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/**
|
||||
* Full props = owner share (sessionId) & standard share (useSession) &
|
||||
* injected share — composed by reference from the contract, never re-typed
|
||||
* here (share-ownership rule).
|
||||
*/
|
||||
/** Full props = the automatic shares & injected share — composed by reference
|
||||
* from the contract, never re-typed here (share-ownership rule). */
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
|
||||
* empty when unknown; a broken link stops the walk). Pure twin of the
|
||||
* sessions service's ancestry — components derive, they don't subscribe. */
|
||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = list.byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, send, stop, openDetails, loadOlder, open,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
const activeId = useActiveView() ?? 'chat'
|
||||
// The store's persisted view id may be stale (view plugin unloaded); the
|
||||
// registry is the runtime validator — unknown ids fall to the first view.
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = list.find(v => v.id === activeId) ?? list[0]
|
||||
|
||||
const ancestry = useAncestry()
|
||||
const draft = composer.useDraft()
|
||||
const running = useSession(s => (s as { running: boolean }).running)
|
||||
const removed = useSession(s => (s as { removed: boolean }).removed)
|
||||
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
|
||||
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const draft = useStore(s => s.draft)
|
||||
const running = useSession(s => s.running)
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
|
||||
// Views receive the shares this component already holds (hook transfer is
|
||||
// plain props passing); the callback slice is referentially stable per
|
||||
// injected identity so memoized view rows hold.
|
||||
const viewProps = useMemo<ConvViewProps>(() => ({
|
||||
sessionId, useSession, useStore,
|
||||
actions: { openDetails, loadOlder },
|
||||
}), [sessionId, useSession, useStore, openDetails, loadOlder])
|
||||
|
||||
const renderView = (entry: ViewEntry): ReactNode => {
|
||||
const Header = entry.chrome?.header
|
||||
const Footer = entry.chrome?.footer
|
||||
const View = entry.component
|
||||
return (
|
||||
<>
|
||||
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
|
||||
<View {...viewProps} />
|
||||
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<header className={css.header}>
|
||||
@@ -52,7 +91,7 @@ export function ConversationRoot({
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { actions.open(s.id) }}
|
||||
onClick={() => { open(s.id) }}
|
||||
>
|
||||
{s.title}
|
||||
</button>
|
||||
@@ -74,7 +113,7 @@ export function ConversationRoot({
|
||||
role="tab"
|
||||
aria-selected={v.id === active?.id}
|
||||
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.openView(v.id) }}
|
||||
onClick={() => { actions.setView(v.id) }}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
@@ -93,9 +132,9 @@ export function ConversationRoot({
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
onDraftChange={composer.setDraft}
|
||||
onSend={composer.send}
|
||||
onStop={composer.stop}
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onStop={stop}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Subscribes to the per-scope selection and
|
||||
// derives the call material from the session snapshot — no data of its own.
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (owner & standard & injected shares). */
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
@@ -41,13 +43,13 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanelProps) {
|
||||
const selection = useSelection(s => s)
|
||||
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
const material = useSession(
|
||||
s => (callId === undefined ? null : materialFor(s as ConversationSnapshot, callId)),
|
||||
s => (callId === undefined ? null : materialFor(s, callId)),
|
||||
(a, b) => shallowEqual(a, b))
|
||||
|
||||
return (
|
||||
@@ -58,7 +60,7 @@ export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanel
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.close} aria-label="关闭详情"
|
||||
onClick={() => { actions.closeDetails() }}
|
||||
onClick={() => { closeDetails() }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// EmptyState (figma NEW SESSION screen): centered hero card built around the
|
||||
// SAME InputBar component the resident composer uses (the empty→content
|
||||
// transition is one component changing position, never a swap). Project
|
||||
// picker: cwd set derived from sessions.list plus a free-form new-directory
|
||||
// input; submit runs the startSession chain (create → open → send) in one
|
||||
// service call.
|
||||
// picker: cwd set derived in-component from the standard useSessions hook
|
||||
// (subscription is the framework's, derivation is a pure function — design
|
||||
// §6) plus a free-form new-directory input; submit runs the startSession
|
||||
// chain (create → open → send) in one service call.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
@@ -15,11 +17,22 @@ import css from './EmptyState.module.css'
|
||||
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
|
||||
const NEW_DIR = '::new-directory'
|
||||
|
||||
/** Full props composed by reference from the contract (owner & injected shares; root slot has no standard share). */
|
||||
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||
export type EmptyStateProps = EmptyStateSlotProps
|
||||
|
||||
export function EmptyState({ useCwds, actions }: EmptyStateProps) {
|
||||
const cwds = useCwds(s => s)
|
||||
/** Deduped cwd set in list order (pure derivation over the sessions list). */
|
||||
function deriveCwds(state: SessionListState): readonly string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const id of state.ids) {
|
||||
const cwd = state.byId[id]?.cwd
|
||||
if (cwd !== undefined && cwd !== '') seen.add(cwd)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
@@ -35,14 +48,14 @@ export function EmptyState({ useCwds, actions }: EmptyStateProps) {
|
||||
setSending(true)
|
||||
setError(null)
|
||||
const chosen = cwd.trim()
|
||||
actions.startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
.catch((reason: unknown) => {
|
||||
// The empty state survives failure with the draft intact (no session
|
||||
// exists to carry promptError; this is the only local error surface).
|
||||
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
|
||||
setSending(false)
|
||||
})
|
||||
// Success needs no cleanup: layout.open swaps this slot out for the session body.
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
const picker = (
|
||||
|
||||
52
packages/client/ui-conversation/src/client/stores.ts
Normal file
52
packages/client/ui-conversation/src/client/stores.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Chat store factory (slot terminal design §4): selection + draft + active
|
||||
* view for one session, shared by the conversation and details registrations
|
||||
* (apply constructs one handle and passes it to both). Session-scope
|
||||
* derivation: both mount slots are scope=session, so the framework creates
|
||||
* one instance per session; the persist key is scope-suffixed by the
|
||||
* framework, aligning with the previous per-session draft persistence.
|
||||
*
|
||||
* Module exports the factory only — a module-level handle would pin identity
|
||||
* in the module cache (a de-facto singleton surviving plugin reloads).
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
* return type); drift fails assignability at the defineStore call.
|
||||
*/
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
setView: (draft: ChatStoreState, view: ViewId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the per-session chat store. `selection` is the details-linkage
|
||||
* channel (conversation writes, details reads); `draft` is the composer text
|
||||
* (persisted so it survives session switches and reloads); `view` is the
|
||||
* active conversation view id (previously layout.viewFor — store seat is the
|
||||
* cross-remount survival channel, null falls back to the first registered view).
|
||||
* @returns the store handle (spec + identity + factory in one value).
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
// Anchored to the contract shape: views consume the store through
|
||||
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
|
||||
// contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, target: SelectionTarget | null) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
// Optimistic-send failure restore: only when the user typed nothing new
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: ViewId) => { d.view = view },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply inject factories exercised end to end: the conversation slot surface
|
||||
// (ancestry feed, views triple, active view, composer choreography incl.
|
||||
// optimistic clear + failure restore, renderView chrome assembly, watch-driven
|
||||
// open), the details surface, and the empty-state surface (cwd derivation
|
||||
// cache). Complements chat-apply.spec.tsx, which stops at registration.
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, watch-driven open,
|
||||
// sessions.open navigation), the injectless-but-closeDetails details surface,
|
||||
// and the one-callback empty surface. Complements chat-apply.spec.tsx
|
||||
// (registration) and selection-survival.spec.ts (store axis).
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationService, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {
|
||||
ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
type ChatActions = ChatInstance['actions']
|
||||
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
@@ -35,28 +41,18 @@ const SCOPE_TAG: symbol = (() => {
|
||||
return symbol
|
||||
})()
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
})
|
||||
const snap = snapshotBase()
|
||||
current: ROOT,
|
||||
} as SessionListState)
|
||||
const sessionFake = {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: () => () => {},
|
||||
useSelector: undefined as unknown,
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
@@ -64,7 +60,6 @@ async function bench() {
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
}
|
||||
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
@@ -77,200 +72,143 @@ async function bench() {
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: () => sessionFake },
|
||||
ancestry: (id: SessionId) => {
|
||||
const s = listStore.getSnapshot().byId[id]
|
||||
return s === undefined ? [] : [s]
|
||||
},
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const layoutFake = {
|
||||
current: createSnapshotStore<{ sessionId?: SessionId; viewFor: Record<string, string> }>({ viewFor: {} }),
|
||||
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
|
||||
}
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('layout', layoutFake)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.define('conversation', { kind: 'single', scope: 'session' })
|
||||
slots.define('details', { kind: 'single', scope: 'session' })
|
||||
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
|
||||
// The AppFrame role: the three conversation-package slots must be declared
|
||||
// by a live entry before apply can contribute into them (the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
|
||||
const binding: SessionBinding = {
|
||||
sessionId: ROOT as never,
|
||||
session: { useSelector: sessionFake.useSelector } as never,
|
||||
ctx: mint(ROOT) as never,
|
||||
// Reach the render-side entry view (inject + store handle) the way the
|
||||
// renderer does: through the host face.
|
||||
let host: SlotRendererHost | undefined
|
||||
slots.install({ renderRoot: (h) => { host = h; return null } })
|
||||
slots.renderSlot('root', {})
|
||||
const hostFace = host!
|
||||
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
|
||||
const entries = slots.entries(key)
|
||||
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
|
||||
}
|
||||
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
|
||||
return { ctx, slots, hostFace, entryOf, conversationSurface, sessionFake, sessionsFake, layoutFake, mint }
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the full surface and pulls history through the watch signal', async () => {
|
||||
it('assembles the thin surface, pulls history through the watch signal, navigates via sessions.open', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
useAncestry: () => readonly { id: SessionId }[]
|
||||
views: { list(): readonly ViewEntry[]; version(): number; subscribe(fn: () => void): () => void }
|
||||
useActiveView: () => string | undefined
|
||||
composer: { useDraft: () => string; setDraft(t: string): void; send(m: string): void; stop(): void }
|
||||
actions: { openView(v: string): void; open(id: SessionId): void }
|
||||
renderView: (entry: ViewEntry) => unknown
|
||||
}
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
injected.actions.openView('chat')
|
||||
expect(b.layoutFake.openView).toHaveBeenCalledWith(ROOT, 'chat')
|
||||
injected.actions.open(ROOT)
|
||||
expect(b.layoutFake.open).toHaveBeenCalledWith(ROOT)
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
|
||||
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
composer: { setDraft(t: string): void; send(m: 'queue'): void; stop(): void }
|
||||
}
|
||||
const scoped = b.mint(ROOT).get('conversation') as ConversationService
|
||||
// Whitespace-only draft: no send.
|
||||
scoped.drafts.set(' ')
|
||||
injected.composer.send('queue')
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
|
||||
instance.actions.setDraft(' ')
|
||||
injected.send(' ', 'queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(instance.store.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
injected.composer.setDraft('hello')
|
||||
injected.composer.send('queue')
|
||||
expect(scoped.drafts.getSnapshot()).toBe('')
|
||||
instance.actions.setDraft('hello')
|
||||
injected.send('hello', 'queue')
|
||||
expect(instance.store.getSnapshot().draft).toBe('')
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.composer.setDraft('retry me')
|
||||
injected.composer.send('queue')
|
||||
instance.actions.setDraft('retry me')
|
||||
injected.send('retry me', 'queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(scoped.drafts.getSnapshot()).toBe('retry me')
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure with new typing: no clobber.
|
||||
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.composer.send('queue')
|
||||
injected.composer.setDraft('typed during flight')
|
||||
injected.send('retry me', 'queue')
|
||||
instance.actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
|
||||
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
injected.composer.stop()
|
||||
injected.stop()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
|
||||
it('openDetails writes the selection through the store actions and opens the panel', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
// viewProps rides renderView's closure; reach the actions through a rendered entry.
|
||||
renderView: (entry: ViewEntry) => React.ReactNode
|
||||
}
|
||||
let captured: { openDetails(t: { turnSeq: number; callId?: string }): void; loadOlder(): void } | undefined
|
||||
const Probe = (p: { actions: typeof captured }) => {
|
||||
captured = p.actions
|
||||
return null
|
||||
}
|
||||
render(createElement('div', null, injected.renderView({
|
||||
id: 'chat', label: 'Chat', component: Probe,
|
||||
} as unknown as ViewEntry)))
|
||||
captured!.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
|
||||
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
||||
const scoped = b.mint(ROOT).get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
|
||||
expect(scoped.selection.getSnapshot()).toEqual({ turnSeq: 2, callId: 'c1' })
|
||||
captured!.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renderView mounts chrome header/footer around the view body', async () => {
|
||||
it('views read face forwards to the service registry (subscribe/version)', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
renderView: (entry: ViewEntry) => React.ReactNode
|
||||
}
|
||||
const entry = {
|
||||
id: 'chat', label: 'Chat',
|
||||
component: () => createElement('div', { 'data-testid': 'body' }),
|
||||
chrome: {
|
||||
header: () => createElement('div', { 'data-testid': 'hd' }),
|
||||
footer: () => createElement('div', { 'data-testid': 'ft' }),
|
||||
},
|
||||
} as unknown as ViewEntry
|
||||
const view = render(createElement('div', null, injected.renderView(entry)))
|
||||
expect(view.getByTestId('hd')).toBeTruthy()
|
||||
expect(view.getByTestId('body')).toBeTruthy()
|
||||
expect(view.getByTestId('ft')).toBeTruthy()
|
||||
// Ancestry and draft/active-view hooks execute inside a component tree.
|
||||
const HookProbe = () => {
|
||||
const injected2 = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
useAncestry: () => readonly { title: string }[]
|
||||
useActiveView: () => string | undefined
|
||||
composer: { useDraft: () => string }
|
||||
}
|
||||
const chain = injected2.useAncestry()
|
||||
const active = injected2.useActiveView()
|
||||
const draft = injected2.composer.useDraft()
|
||||
return createElement('i', { 'data-testid': 'probe' }, `${chain.length}|${active ?? 'none'}|${draft}`)
|
||||
}
|
||||
const probe = render(createElement(HookProbe))
|
||||
// Draft content carries over from the composer case (per-scope store is
|
||||
// process-resident); the probe asserts hook wiring, not draft value.
|
||||
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
|
||||
// A list-store update while mounted drives the ancestry selector's
|
||||
// shallowEqual arm (same derived chain → short-circuit, no re-render churn).
|
||||
await act(async () => {
|
||||
b.sessionsFake.list.update((d: { byId: Record<string, { updatedAt: number }> }) => {
|
||||
d.byId[ROOT]!.updatedAt = 2
|
||||
})
|
||||
})
|
||||
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
|
||||
// The views read-face triple forwards to the service registry.
|
||||
const injected3 = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
views: { list(): readonly { id: string }[]; subscribe(fn: () => void): () => void; version(): number }
|
||||
}
|
||||
expect(injected3.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
const beforeVersion = injected3.views.version()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const before = injected.views.version()
|
||||
const listener = vi.fn()
|
||||
const unsub = injected3.views.subscribe(listener)
|
||||
const conversation = b.ctx.get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
|
||||
const offExtra = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
|
||||
const unsub = injected.views.subscribe(listener)
|
||||
const conversation = b.ctx.get('conversation') as
|
||||
import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
|
||||
const off = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
|
||||
offExtra()
|
||||
expect(injected.views.version()).toBeGreaterThan(before)
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
|
||||
off()
|
||||
unsub()
|
||||
})
|
||||
})
|
||||
|
||||
describe('details and empty inject surfaces', () => {
|
||||
it('details surface wires selection and closeDetails', async () => {
|
||||
it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('details').options.inject(b.binding) as {
|
||||
useSelection: unknown
|
||||
actions: { closeDetails(): void }
|
||||
}
|
||||
expect(injected.useSelection).toBeTypeOf('function')
|
||||
injected.actions.closeDetails()
|
||||
const entry = b.entryOf('details')
|
||||
const injected = (entry.inject as unknown as () => DetailsInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['closeDetails'])
|
||||
injected.closeDetails()
|
||||
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
||||
// The shared handle: details resolves the SAME instance conversation writes.
|
||||
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
|
||||
const details = b.hostFace.storeOf(entry, ROOT)
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
|
||||
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation.empty').options.inject({ ctx: b.ctx }) as {
|
||||
useCwds: (sel: (s: readonly string[]) => unknown, eq?: unknown) => unknown
|
||||
actions: { startSession(opts: { text: string; mode: 'queue' }): Promise<void> }
|
||||
}
|
||||
const CwdsProbe = () => {
|
||||
const cwds = injected.useCwds(s => s) as readonly string[]
|
||||
return createElement('i', { 'data-testid': 'cwds' }, cwds.join(','))
|
||||
}
|
||||
const view = render(createElement(CwdsProbe))
|
||||
expect(view.getByTestId('cwds').textContent).toBe('/proj')
|
||||
await injected.actions.startSession({ text: 'go', mode: 'queue' })
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession'])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user