Merge PR #500 into CI optimization

This commit is contained in:
Tianyi Cui
2026-07-22 18:31:28 +08:00
382 changed files with 33688 additions and 419 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129

View File

@@ -0,0 +1,253 @@
# Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier
Status: implemented
English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md)
> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md).
## Problem
We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities:
- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation)
- Launching inside Electron with the same Web technology shape as `dsh web`
That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly.
At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable.
## Decision
### Layering
Directories layer as follows:
- `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally
- the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Two kinds live here:
- **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`): ordinary root-index packages, statically bundled into the shell and seeded into the browser plugin loader's module table.
- **dshClient plugin packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the entire implementation and its types live under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle), and cross-package consumption imports the `/client` form. `runtime` additionally exports `./loader` (the shell-held browser bundle loader — a loader cannot load itself).
- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures.
- `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
- `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP.
- A future Electron shape reuses the same web client packages over an IPC fetch carrier.
```
apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch)
│ consume
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
webserver web-shape HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
harness core packages ──────────────────┘ (types reach the browser via import type)
```
Direction discipline (every rule auditable from package deps):
- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions.
- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`).
- `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.
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).
#### Layer roles
| Layer | Package | Responsibility | Key discipline |
|---|---|---|---|
| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx |
| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell |
| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy |
| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app |
#### Naming rule
Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the plugin packages' `/client` (and runtime's `/loader`) subpaths so source-level resolution matches the exports map.
#### How to integrate a new shape (operational checklist)
1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below).
2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports.
The two existing shapes are the template: `apps/cli/src/web.ts` (startHost + dist location + startWebServer + signal shutdown) and `headless.ts` (startHost + InProcessApiClient isomorphic direct calls, zero HTTP zero ports). ACP-class protocol bridges do not follow this checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch.
## Message protocol
The sections from here down are the protocol body carried by the front layer (`dsh-host-apiproxy`). The wire has exactly four message kinds (the four quadrants) — the Web carriage in the right column is only an example; swapping the carrier (in-process/IPC) leaves the quadrants unchanged:
```
client 发起 server 发起
request ① ClientRequest ③ ServerRequest
POST /api/<method> body SSE 帧session 事件、审批/问答 requested
response ② ServerResponse ④ ClientResponse
(该 POST 的 HTTP 应答体) POST /api/respond body回填 ③ 的 rpcId
```
### Wire full forms: a four-member named discriminated union (`api/rpc.ts`)
| Type | Discriminant tag | Fields | rpcId ownership | Web carriage |
|---|---|---|---|---|
| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/<method>` body |
| `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) |
| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line |
| `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body |
`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`.
**rpcId discipline** (`RpcId` is a branded string with constructor `RpcId()`):
- Whoever initiates mints; a response always echoes the corresponding request's rpcId and **never mints a new id**.
- server-requests split into two kinds, distinguished statically by `method` (= the frame type), with **no third kind**: answerable frames (`approval/requested`, `question/requested`) carry a stable logical request id (minted once on acceptance, reused verbatim on baseline replay, echoed by the client's answer); pure-push frames (`session/event` etc.) carry an rpcId identifying that one push (freshly minted each time).
- Business code never mints: unary minting funnels into the client base class `callUnary`, frame minting funnels into the host side.
### Signature narrow forms and carrier completion
Domain interface signatures perceive only the narrow forms: `RpcRequest<P> = { rpcId, payload }`, `RpcResponse<T> = { rpcId, result: RpcResult<T> }`. The carrier layer completes narrow forms into full forms (adding the `type` tag and `method`); direction is never inferred from the channel. `RpcResult<T> = { ok: true; value } | { ok: false; error: RpcError }` — methods do not throw business errors.
### RpcReceipt: the carrier receipt
The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames.
## The type system: signatures are the source of truth
### RpcMethodMap and derived generics (`api/rpc-map.ts`)
Method parameter/return structures **live only in the interface method signatures**; the map registers the methods themselves; every other position (handler, client, store, tests) references the derived generics — copying literals or introducing flat named types is banned:
```ts ignore-check
export interface RpcMethodMap {
'session.list': SessionsApi['list'] // map key 即 wire 路径段
// …其余方法同形登记,全集见 api/rpc-map.ts
}
// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束)
export type RequestPayload<K> = Parameters<RpcMethodMap[K]>[0]['payload']
export type ResponseValue<K> =
Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never
```
Stream methods (`events.mux`/`events.host`) stay out of the map (not unary); `respond` stays out of the map (it is a client-response, not a method call).
### The error model (`RpcErrorDetailsMap`)
One example row of an error code:
| code | details | when |
|---|---|---|
| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod validation failed |
The full code set is `RpcErrorDetailsMap` in `api/rpc.ts`. `RpcError` is the distributive union expanded from the map: `code` discriminates, `details` narrows automatically after a `switch`; **details is required** — a new code = one map row + one error-schema branch, and omission is a compile error. Transport failures (network down, host not up) are thrown by the carrier as exceptions; the two layers never mix.
### Bidirectional zod validation and anchoring
- **Two-level parse**: the full-form schema once (type/rpcId/method structure + the handler checking path==method) → the business payload dispatched by method/frame type for a second parse; rejection = `bad-request`.
- **Anchoring**: schemas uniformly `satisfies z.ZodType<Wire<T>>` (`api/rpc.schema.ts`). `Wire<T>` is a deep "| undefined" widening — the repo enables `exactOptionalPropertyTypes` while zod `.optional()` outputs `T | undefined`, so anchoring the original type is unusable across the board; on the JSON wire, absence and undefined are indistinguishable, so the widening loses no validation semantics. Passthrough wide branches (`SessionEvent`/`ContentBlock`/frame unions/`RpcError`) and brand-id schemas use explicit casts with comments.
- Brand casts have one point each: every schema file funnels its id cast into one place (`rpcIdSchema` is the only cast point in rpc.schema.ts).
## The contract face (ApiProxy)
The root interface is `ApiProxy = { sessions, host, events, respond }` (`api/index.ts`). A new client-request domain = one new file pair (`<domain>.ts` + `<domain>.schema.ts`) + one root-interface field + one map row.
### The unary method table
One example row (the table structure is the reading key):
| method key | request payload | return value | semantics |
|---|---|---|---|
| `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index |
The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
### Frames (server→client, named unions)
Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row:
| frame type | payload | when |
|---|---|---|
| `session/event` | `{ sessionId; event: SessionEvent }` | core passthrough: core events pass verbatim, `assistant/chunk` IS the token stream, no separate delta frame |
The remaining frame types are not re-copied here; the full unions are `MuxFrame`/`HostFrame` in `api/events.ts`. Three semantic points to know: `session/subscribed` carries lastSeq for history seam-race detection; the `approval/question` requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; `host/agent-error` is the only outlet for live failures with no turn position.
**Passthrough discipline**: events/messages/content blocks on the wire ARE the core types (`SessionEvent`/`ContentBlock`) — no second DTO set; types reach the browser through the `import type` dependency chain. `SessionEventMap` is merge-extensible: the client applies its documented default (ignore) to unknown types, and the event schema keeps a "valid envelope + unknown type" branch — the envelope stays strict; this is not field-level passthrough.
### Session semantics (impl-side commitments)
- **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks.
- **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo.
- **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam.
- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it).
- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`.
## The client carrier: the AbstractApiClient class family (`fetch/client.ts`)
**Protocol invariants live in the base class; platform differences are two aspects**: the abstract method `doFetch(url, init)` (transport) + the overridable `onEnvelope` (observation).
### IApiClient: the caller view
The same domain tree as `ApiProxy`, but unary methods **take the business payload directly** — the carrier mints the rpcId and wraps the envelope; business code never mints, and code needing this call's rpcId reads it from the returned `RpcResponse` echo. `ApiProxy` is the narrow-form signature contract the impl side implements; `IApiClient` is the payload-direct view clients consume; `AbstractApiClient` bridges the two. Methods derive per key from `RpcMethodMap` — a map row addition updates them mechanically.
### Protocol paths held by the base class
| Path | Content |
|---|---|
| `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form |
| `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest<frame>` |
| `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` |
| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) |
| `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority |
### The instance-level envelope observation aspect
All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier).
### The subclass table (transport carriage)
| Subclass | Package | doFetch | Purpose |
|---|---|---|---|
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer |
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC |
| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged |
## How to extend (operational checklists)
**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `<domain>.schema.ts` (anchored `Wire<RequestPayload<'…'>>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture RFC); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables.
**Add a frame type (3 steps)**: ① add a branch to the `MuxFrame`/`HostFrame` union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed.
**Add an error code (2 steps)**: ① add one `RpcErrorDetailsMap` row (details required); ② add one `rpcErrorSchema` discriminatedUnion branch.
**Plug in a new carrier**: subclass `AbstractApiClient` implementing only `doFetch`; to intercept at the protocol layer (like the fixture), override the `callUnary`/`openMux`/`openHost` virtuals instead. Contract and base class stay unchanged.
**Promote a reserved seam**: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement.
## Consequences
Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages |
| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable |
| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | A second command plane bypasses the contract, losing wire validation/observability/multi-client consistency; ctx keeps exactly two formal uses — front doors and headless event subscription |
| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer |
| Package names without the group prefix (continuing dsh-<tail>) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package |
| Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention |
| A three-envelope model (Request/Response/Frame envelopes, signatures direction-blind) | rpcId correlation is logical-layer; frame and response direction semantics inferred from the channel break the moment the carrier changes |
| Named Request/Response type pairs as the source of truth (map registering type pairs) | Flat named types are a second name for the same fact; signature inference makes adding a method a one-place change |
| REST-style paths | The consumer is our own client with no third-party REST expectations; RPC mapping straight onto the method table is more mechanical |
| A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax |
| Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer |
| A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope |

View File

@@ -0,0 +1,251 @@
# RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体
Status: implemented
[English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文
> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现HTTP+SSE见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。
## Problem
需要提供 UI 对接层,除已有 ACP/stdio基础版本外还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持:
-`dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留)
- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动
那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。
同时各消费端的物理通道不同HTTP/SSE、进程内直调、将来 IPC还需要一个通道无关的消息模型和单一契约事实源让「加一个方法」「换一种载体」互不牵连且 wire 上的每条消息可类型校验、可观测、可对账。
## Decision
### 分层
目录按照如下分层:
- `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
- 统一后端协议fetch、HTTP、流式接口等定义和支持见本篇「消息协议」起各节
- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住两类包:
- **纯库**`ui-slots``web-react``ui-primitives`):普通根入口包,静态打包进壳,并播种进浏览器插件 loader 的模块表。
- **dshClient 插件包**`connection``runtime``ui-theme``i18n``ui-layout``ui-sidebar``ui-conversation``ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现与类型全部住在 `src/client/` 下,经 `./client` 子路径发布tsdown 闭包工厂 bundle跨包消费一律 import `/client` 形式。`runtime` 额外导出 `./loader`(壳持有的浏览器 bundle loader——loader 加载不了自己)。
- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。
- `apps/web``dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`
- `apps/cli``@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist`dsh -p` = headless 进程内直调,零 HTTP。
- 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。
```
apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch)
│ consume
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
webserver web-shape HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
harness core packages ──────────────────┘ (types reach the browser via import type)
```
方向纪律(每条都由包 deps 可核):
- `runtime → apiproxy` 单向apiproxy 仅依赖类型定义。
- client 侧包**永不 import** host 侧包的运行时(只吃 `/api``/client` 两个浏览器安全子路径)。
- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系不是包依赖。
- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundletsdown 纯度门禁会改写或拒收)。
TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client``tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions``loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。
协议侧TS interface`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 importwire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形两轴四格后文称四象限与物理通道解耦客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
#### 分层角色
| 层 | 包 | 职责 | 关键纪律 |
|---|---|---|---|
| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/handler + 客户端基类) | 做简单、所有接入方都要Node/浏览器皆可 import协议内容见下文「消息协议」起各节client 不得经 ctx 绕开 api |
| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树host 级配置归属地defaults/persistenceRoot将来用户 profile | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
| 承载层 | `dsh-host-webserver` | Web 形态 HTTP静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest元数据清单注入由 web 插件注册表供给) | Web浏览器访问专用零 workspace 依赖注册表经结构注入到达Electron 不复用它 |
| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 |
| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹——见 Web 客户端架构 RFC | 双入口node 半边=空 apply实现在 `src/client/`);消费面唯一经 ApiProxy |
| 应用态 | `@deepseek-ai/dsh`apps/cli+ `dsh-frontend`apps/webvite 应用) | bin 粗分发 + 每形态一个拼装模块web.ts / headless.tsvite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载dist 定位等 workspace 知识留在 app |
#### 命名规则
`packages/host/*``packages/client/*` 下的包名**必须含目录组前缀**host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀host/ 已表达)。因此包名尾段 ≠ 目录名tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且插件包的 `/client`(以及 runtime 的 `/loader`)子路径要单列条目,使源码级解析与 exports map 一致。
#### 怎么接入一个新形态(操作清单)
1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC见下文「子类表」
2. **在 `apps/` 下写拼装模块**`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。
现有两形态即模板:`apps/cli/src/web.ts`startHost + dist 定位 + startWebServer + 信号停机)与 `headless.ts`startHost + InProcessApiClient 同构直调,零 HTTP 零端口。ACP 类协议桥不走本清单:它把 core 暴露给外部生态,直接 `ctx.plugin(前门插件)` 挂载、不套 fetch。
## 消息协议
以下各节是前置层(`dsh-host-apiproxy`承载的协议本体。wire 上只有四种消息(四象限)——右列的 Web 承载只是示例,换载体(进程内/IPC时四象限不变
```
client 发起 server 发起
request ① ClientRequest ③ ServerRequest
POST /api/<method> body SSE 帧session 事件、审批/问答 requested
response ② ServerResponse ④ ClientResponse
(该 POST 的 HTTP 应答体) POST /api/respond body回填 ③ 的 rpcId
```
### wire 全形:四具名判别 union`api/rpc.ts`
| 类型 | 判别 tag | 字段 | rpcId 归属 | Web 承载 |
|---|---|---|---|---|
| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/<method>` body |
| `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200 |
| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 |
| `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body |
`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse``switch (message.type)` 窄化。
**rpcId 纪律**`RpcId` 是 branded string构造函数 `RpcId()`
- 谁发起谁 mint应答一律回填对应 request 的 rpcId**绝不 mint 新 id**。
- server-request 分两类,静态按 `method`=帧 type区分**不设第三种 kind**:可应答帧(`approval/requested``question/requested`)的 rpcId 是稳定逻辑请求 id受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint
- 业务代码不 mintunary 的 mint 收口在客户端基类 `callUnary`,帧的 mint 收口在 host 侧。
### 签名窄形与载体补全
域接口签名只感知窄形:`RpcRequest<P> = { rpcId, payload }``RpcResponse<T> = { rpcId, result: RpcResult<T> }`。载体层把窄形补全为全形(补 `type` tag 与 `method`),方向不靠通道推断。`RpcResult<T> = { ok: true; value } | { ok: false; error: RpcError }`——方法不 throw 业务错误。
### RpcReceipt载体回执
`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessageresponse 不再有 response迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。
## 类型体系:函数签名即事实源
### RpcMethodMap 与派生泛型(`api/rpc-map.ts`
方法的参数/返回结构**只住在接口方法签名里**map 登记方法本身其余一切位置handler、client、store、测试引用派生泛型禁止复写字面量或另起平铺具名类型
```ts ignore-check
export interface RpcMethodMap {
'session.list': SessionsApi['list'] // map key 即 wire 路径段
// …其余方法同形登记,全集见 api/rpc-map.ts
}
// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束)
export type RequestPayload<K> = Parameters<RpcMethodMap[K]>[0]['payload']
export type ResponseValue<K> =
Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never
```
流方法(`events.mux`/`events.host`)不进 map不是 unary`respond` 不进 map是 client-response 不是方法调用)。
### 错误模型(`RpcErrorDetailsMap`
错误码示例一行:
| code | details | 何时 |
|---|---|---|
| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod 校验失败 |
码全集见 `api/rpc.ts` 的 `RpcErrorDetailsMap`。`RpcError` 是 map 展开的分布式 union`code` 判别、`switch` 后 `details` 自动窄化;**details 必填**——新码=map 加一行+错误 schema 加一支漏填是编译错误。transport 故障断网、host 没起)由载体抛异常,与业务错误两层不混。
### zod 双向校验与锚定
- **两级 parse**:全形 schema 一次type/rpcId/method 结构 + handler 校验 path==method→ 业务 payload 按 method/帧型分派二次 parse拒收 = `bad-request`。
- **锚定**schema 统一 `satisfies z.ZodType<Wire<T>>``api/rpc.schema.ts`)。`Wire<T>` 是深度「| undefined」宽化——仓库开 `exactOptionalPropertyTypes` 而 zod `.optional()` 输出 `T | undefined`直接锚原类型全线不可用JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(`SessionEvent`/`ContentBlock`/帧 union/`RpcError`)与 brand id schema 用显式 cast + 注释。
- brand cast 单点:每个 schema 文件的 id cast 收口一处(`rpcIdSchema` 是 rpc.schema.ts 唯一 cast 点)。
## 契约面ApiProxy
根接口 `ApiProxy = { sessions, host, events, respond }``api/index.ts`)。新 client-request 域 = 新的一对文件(`<域>.ts` + `<域>.schema.ts`+ 根接口一个字段 + map 加行。
### unary 方法表
方法示例一行(表结构即读法):
| method key | 请求 payload | 返回 value | 语义 |
|---|---|---|---|
| `session.list` | `{ cursor?: string }`cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 sessionupdatedAt 倒序v1 不建索引 |
其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
### 帧server→client具名 union
两条 SSE 流mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`host 级事件)。帧示例一行:
| 帧 type | 载荷 | 何时发 |
|---|---|---|
| `session/event` | `{ sessionId; event: SessionEvent }` | 核心透传core 事件原样过,`assistant/chunk` 即 token 流,无独立 delta 帧 |
其余帧型不在此复写union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 补缝竞态检测;`approval/question` 的 requested 帧可应答rpcId 稳定、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。
**透传纪律**wire 上的事件/消息/内容块就是 core 类型(`SessionEvent`/`ContentBlock`),不造第二套 DTO类型经 `import type` 依赖链直达浏览器。`SessionEventMap` merge-extensibleclient 对未知 type documented-default忽略事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。
### 会话语义impl 侧承诺)
- **历史 = 事件重放**:一套 foldclient 侧),历史分页与 live 增量同一条代码路径server 不做物化快照第二套。history **页边界对齐消息边界**绝不从消息中间截断chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。
- **prompt 关联**prompt 的 rpcId 经 MessageSource`'user-rpc'`)透传进 `user/message` 事件client 以此把乐观回显转正。
- **重连 = 重建**:不做续传 cursor`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。
- **冷 session 隐式 resume**`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume并发触发用在途表去重attach 与否不对客暴露(`running` 已覆盖)。
- **审批/问答**requested 帧受理时 mint 稳定 rpcId先到先赢host 内存 pending 表keyed by rpcId是唯一裁判mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shippedhost 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub恒回 `not-pending`PendingCard v1 只展示。
- **不设协议版本**client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
- **预留接缝纪律**map 只含已实现方法,未知 method 在信封 parse 即 fail loud`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。
## 客户端载体AbstractApiClient 类体系(`fetch/client.ts`
**协议不变量住基类,平台差异是两个切面**:抽象方法 `doFetch(url, init)`(传输)+ 可覆写 `onEnvelope`(观测)。
### IApiClientcaller 视图
与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名契约,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。
### 基类持有的协议路径
| 路径 | 内容 |
|---|---|
| `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw→ tap → 吐窄形 |
| `readSse` | streaming fetch非 EventSource、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` |
| `respond` | client-response 透传rpcId 是回填,此处不 mint应答体 `rpcReceiptSchema` parse |
| unary 超时 | `AbortSignal.timeout`(默认 30s构造参数可调流不设超时长连接本性 |
| `resolveBase` | 浏览器=同源 origin无 location 环境Node=`http://dsh.internal` 假 authority |
### 实例级 envelope 观测切面
四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`返回退订函数listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。
### 子类表(传输承载)
| 子类 | 所在包 | doFetch | 用途 |
|---|---|---|---|
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 |
| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*` | 浏览器形态HTTP+SSE 承载落地见 Web 客户端架构 RFC |
| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server帧 rpcId 由它 mint语义自洽 |
| 将来IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch契约/基类零改 |
## 怎么扩展(操作清单)
**加一个 unary 方法5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire<RequestPayload<'…'>>`④handler `UNARY_ROUTES` 加一行handler 的 Web 承载见 Web 客户端架构 RFC⑤impl 实现(回显 `request.rpcId`。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。
**加一个帧型3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。
**加一个错误码2 步)**:①`RpcErrorDetailsMap` 加一行details 必填);②`rpcErrorSchema` discriminatedUnion 加一支。
**接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture再覆写 `callUnary`/`openMux`/`openHost` 虚方法。契约与基类零改。
**升格一个预留接缝**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。
## Consequences
所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目预留接缝fork/inject/task.list/listModels/hostInstanceId在真实消费者出现前保持休眠。
## Alternatives considered
| 放弃项 | 一句话理由 |
|---|---|
| 按「产品形态」分包web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 |
| 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app建包是无主抽象拼装写在 app 里可读可弃 |
| 消费型 client 直连 ctx省 apiproxy 一层) | 第二命令面绕开契约wire 校验/观测/多端一致性全失ctx 只留给前门与 headless 事件订阅两个正式用途 |
| webserver 依赖 runtime省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 |
| 包名不带组前缀(沿用 dsh-<尾段> | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths |
| 复用仓内 JSON-RPC 2.0dsh-jsonrpc | 数字错误码退化成单码兜底、契约双份人肉对齐、命名无 convention 自然漂移 |
| 三信封模型Request/Response/Frame 各一信封,签名不感知方向) | rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效 |
| 具名 Request/Response 类型对为事实源map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 |
| REST 风格路径 | 消费者是自家 client无第三方 REST 体验诉求RPC 直映方法表更机械 |
| DTO 层wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本DTO 是永久的双向同步税 |
| cursor 续传mux since 实装) | 重连=重建opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 |
| createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 |

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646
2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025

View File

@@ -0,0 +1,148 @@
# Agent Note: Web client architecture — the client cordis plugin tree, the slot system, and the React-free object layer
Status: implemented
English | [中文](2026-07-19-gui-web-client-architecture.zh.md)
> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol RFC](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots.
## Problem
Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, conversation, theme, locale) must be independently loadable plugins — composed at runtime from a host-served manifest, not compiled into one bundle — without giving up compile-time type safety across plugin boundaries.
## Decision
Both ends run cordis. The host is a cordis plugin tree; the browser runs a second, client-side cordis tree whose every UI capability is a plugin loaded dynamically by a shell-held loader. Inside that tree, cordis ctx hosts all runtime facts (services, stores, session scopes) and React is pure projection: components import nothing from the framework, receive everything through props, and subscribe to immutable snapshots via `useSyncExternalStore` (uSES below).
```
┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐
│ sessions/agents/SessionLog │ │ client cordis root ctx │
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ loader壳静态持有不能经自己装载
│ webserver: │ │ ├ immediately 先行组: connection/runtime/ │
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n动态 bundle并行先装
│ └ GET / 注入 __DSH_BOOT__ │ │ ├ 后续组: layout/sidebar/conversation/trajectory │
└────────────────────────────────┘ │ └ session scope ×N观看驱动惰性建
│ React: loading 页 → settled → 整 UI 一次成型 │
└────────────────────────────────────────────────────┘
```
## The client cordis tree and the loading chain
Every UI plugin is simultaneously a host plugin (dual-entry package): the node half sits in the host's plugin tree so the host Loader governs its lifecycle, and the browser half is a tsdown closure bundle under the package's `exports["./client"]`. The host webserver derives the boot manifest from loaded plugins carrying a `dshClient` manifest field and injects it into the page as `window.__DSH_BOOT__` — the HTML alone tells the browser everything to fetch, zero extra round trips.
The loading chain, end to end:
1. `GET /` → the shell boots, mounts `ctx.loader` (the loader mechanism is held statically by the shell — a loader cannot load itself; its code home is `packages/client/runtime/src/client/loader/`, imported through the `./loader` subpath so the shell bundle does not swallow the rest of the runtime package), seeds the require module table with the pure-library instances (react, react-dom, cordis, ui-slots, web-react, ui-primitives), and renders a plugin-independent loading page.
2. `loader.start()` reads `__DSH_BOOT__`. Entries flagged `immediately` form the early-load group (connection, runtime, ui-theme, i18n): fetched in parallel, applied in intra-group `inject` topological order, and **the whole group must land before anything else loads**. Remaining plugins then load in inject order.
3. Each bundle executes `window.DSHClientProxy.loadPlugin({ id, factory })`. The loader calls `factory(require)` — bundles are closure factories whose external dependencies arrive through the injected `require`, resolved against the module table (no globals, no import maps; an unresolvable specifier fails loud). The factory returns its module export surface (including the cordis `apply`); the loader runs `ctx.plugin(apply)`, then **registers that export surface into the module table under the package name**, so inject topology guarantees later plugins can `require` earlier ones. Plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` (CSS Modules hashing + ownership tag = isolation).
4. `await loader.settled()` → the shell flips from the loading page to the real UI in one pass. A single failed plugin fails loud on the loading page; there is no partial-availability mode (progressive rendering is deferred work).
**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.
## 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"):
```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`.
## 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).
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).
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
## The data object layer (`packages/client/runtime/src/client/sessions/`)
Frames enter, snapshots exit, the fold sits between — React-free (zero React imports, grep-assertable):
```
mux/host 帧ConnectionController 泵入sinks 注入)
SessionManager.handleMuxEnvelope / handleHostEnvelope
│ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
Session.handleMuxEnvelope ──► events 窗口seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
│ FoldAdapter PartialAccumulator
│ (→ nodes (→ partial
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental fold; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (folded, surface-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references.
- **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
- **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
- **FoldAdapter / PartialAccumulator**: the fold reuses the core SurfaceManager (`@deepseek-ai/dsh-session/surface`), padding sentinel events so a paged window starting at seq > 0 satisfies the core's `seq === index` assertion; a cross-window replace degrades to a tolerant linear scan and sets `foldDegraded`. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory.
## The React face (`packages/client/web-react`)
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`).
- `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.
## Directory shape
Twelve `packages/client/*` packages (ui-slots, ui-primitives, web-react, connection, runtime, ui-layout, ui-sidebar, ui-conversation, ui-trajectory, ui-theme, i18n, web) plus `apps/web` — the vite application, a thin `main` over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). Dependency direction: `ui-slots ← web-react ← runtime ← ui-* (peers) ← web`, with ui-primitives/ui-theme/i18n as zero-dependency side paths.
A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar:
```
src/client/
contract/ the only shared face between domains (types + composed props shares)
service.ts cross-domain orchestration (imports contract only)
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
chat/ domain: the chat view
toolviews/ domain: the tool-row registry and samples
apply.ts the ONLY file allowed to import across domains (assembly point)
index.ts thin re-export shell (contract + apply + components)
```
Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. chat consumes the tool registry through a `ToolViewResolver` read-face interface, not the registry class). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
## 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.
- **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.
- **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`.
## Consequences
Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build |
| window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently |
| Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable |
| String-keyed global component registry for tool rows | Tool views are consumed by multiple views and need per-session differentiation — a named service (`ctx.toolviews`) with scope filters is the honest shape |
| Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture |

View File

@@ -0,0 +1,148 @@
# RFC: Web 客户端架构——client cordis 插件树、slot 体系与 React-free 对象层
Status: implemented
[English](2026-07-19-gui-web-client-architecture.md) | 中文
> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/契约面/客户端基类)见 [分层与 RPC 协议 RFC](2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。
## Problem
浏览器客户端受两股力塑形。其一是流式:事件驱动的对话 UI 里,若业务状态(事件窗口、流式累积、待答交互、连接状态机)散落在 React 组件与全局 store 中,每个 token 分片都会震荡渲染树,且换 UI 库等于重写业务逻辑。其二是模块化UI 功能(布局、侧栏、对话、主题、语言包)必须是可独立装载的插件——按 host 下发的 manifest元数据清单在运行时组合而非编译进单一 bundle——同时不放弃跨插件边界的编译期类型安全。
## Decision
两端都跑 cordis。host 是一棵 cordis 插件树;浏览器里跑第二棵 client 侧 cordis 树,其中每一项 UI 能力都是插件,由壳静态持有的 loader 动态装载。树内 cordis ctx 承载一切运行时事实服务、store、会话 scopeReact 是纯投影:组件对框架零 import一切经 props 注入,经 `useSyncExternalStore`(下称 uSES订阅不可变快照。
```
┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐
│ sessions/agents/SessionLog │ │ client cordis root ctx │
│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ loader壳静态持有不能经自己装载
│ webserver: │ │ ├ immediately 先行组: connection/runtime/ │
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n动态 bundle并行先装
│ └ GET / 注入 __DSH_BOOT__ │ │ ├ 后续组: layout/sidebar/conversation/trajectory │
└────────────────────────────────┘ │ └ session scope ×N观看驱动惰性建
│ React: loading 页 → settled → 整 UI 一次成型 │
└────────────────────────────────────────────────────┘
```
## client cordis 树与装载链
每个 UI 插件同时是一个 host 插件双入口包node 半边住在 host 的插件树里,由 host Loader 管辖其生命周期;浏览器半边是 tsdown 闭包 bundle挂在包的 `exports["./client"]` 下。host webserver 从带 `dshClient` manifest 字段的已加载插件推导启动清单,注入页面为 `window.__DSH_BOOT__`——HTML 到手即知要拉什么,零额外往返。
装载链全程:
1. `GET /` → 壳启动,挂 `ctx.loader`loader 机件由壳静态持有——装载器不能经自己装载;其代码家在 `packages/client/runtime/src/client/loader/`,壳经 `./loader` 子路径 import避免壳 bundle 吞掉 runtime 包其余部分把纯库实体react、react-dom、cordis、ui-slots、web-react、ui-primitives播种进 require 模块表,渲染一张不依赖任何插件的 loading 页。
2. `loader.start()` 读取 `__DSH_BOOT__`。带 `immediately` 标记的条目构成先行装载组connection、runtime、ui-theme、i18n并行拉取、按组内 `inject` 拓扑序 apply**全组就位后才开始装载其余插件**。其余插件随后按 inject 序装载。
3. 每个 bundle 执行 `window.DSHClientProxy.loadPlugin({ id, factory })`。loader 调 `factory(require)`——bundle 是闭包工厂external 依赖经注入的 `require` 到达,从模块表解析(无全局变量、无 import map解析不到的标识符即刻大声失败。factory 返回其模块导出面(含 cordis `apply`loader 执行 `ctx.plugin(apply)`,随后**以包名把该导出面登记进模块表**——inject 拓扑保证后装插件可 `require` 先装插件。插件 CSS 内联在 bundle 里,注入为 `<style data-plugin="<id>">`CSS Modules 哈希 + 归属标记 = 隔离)。
4. `await loader.settled()` → 壳从 loading 页一次切换到真 UI。单插件装载失败在 loading 页大声报错;不存在部分可用模式(渐进渲染为后置工作)。
**双实例禁令**:模块表包若被内联进插件 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。
## slot 体系:页面怎么拼
页面是一棵坑位树;谁拥有区域谁声明坑位。契约只有一个家——`@deepseek-ai/dsh-client-ui-slots``SlotMap` 接口经声明合并扩展。entry 只声明坑的轴与 **owner 份额**;注册方的注入 props 永不进全局表(「谁注入的放谁那里」):
```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` 闭包(坑位所有权留在 layoutprovider 不认识坑名)。
实现的家:注册表纯核在 `packages/client/ui-slots`(零依赖),出口组件/provider/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 过滤)。
SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` 组合基座+扩展无声明的视图免费得基座ui-trajectory 的两个 entry 带真 per-view props与**工具环**tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` unionregister 同 slots 一样推断注册方注入份额)。
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同no-op 插件 fiber + scope 键 extend首次观看时惰性建只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope冻结为只读视窗
## 数据对象层(`packages/client/runtime/src/client/sessions/`
帧从这里进、快照从这里出、fold 坐在中间——React-free零 React importgrep 可断言):
```
mux/host 帧ConnectionController 泵入sinks 注入)
SessionManager.handleMuxEnvelope / handleHostEnvelope
│ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
Session.handleMuxEnvelope ──► events 窗口seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
│ FoldAdapter PartialAccumulator
│ (→ nodes (→ partial
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session**session.ts懒建、常驻——建成后在后台持续吃帧切走切回秒显。操作面`prompt`/`cancel`RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history幂等、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open。订阅面`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`Session 本身就是 uSES 源。帧分发是一个 switch`session/event` 帧按 seq 去重唯一去重键open 在途时缓冲,否则追加 + 增量 foldopen/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
- **ConversationSnapshot**conversation.ts不可变快照契约——`nodes`fold 产物surface 序)、`partial`、`runningCalls`、`pending`、`running`、`removed`、`openState`、`hasMore`、`promptError` 等。**引用纪律**memo 与 uSES 的前提顶层对象每变必新nodes 数组重建但元素引用来自缓存;未变的子结构复用上一快照的引用。
- **SessionManager**manager.ts实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
- **Notifier**notifier.ts两条通知通道按变更来源取用。`markDirty()`默认帧驱动一律用它按微任务合批——N 次变更、一次通知、一次重渲染flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
- **FoldAdapter / PartialAccumulator**fold 复用核心 SurfaceManager`@deepseek-ai/dsh-session/surface`),垫哨兵事件使 seq > 0 起头的分页窗口满足核心的 `seq === index` 断言;跨窗口 replace 时降级为容错线性扫描并置 `foldDegraded`。分片完全不进 foldO(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。
- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入代际围栏之内指数退避重连500ms 翻倍至 10s 封顶、抖动、无限重试sinks 单向注入Controller 不认识 Session。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`Web 承载HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。
## React 面(`packages/client/web-react`
胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
- `createSnapshotStore<T>(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>``getSnapshot`/`subscribe`)。
- `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` 浅比较。深比较全链禁止。
## 目录形态
十二个 `packages/client/*` 包ui-slots、ui-primitives、web-react、connection、runtime、ui-layout、ui-sidebar、ui-conversation、ui-trajectory、ui-theme、i18n、web加 `apps/web`——vite 应用,壳 boot 导出之上的薄 `main`。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。依赖方向:`ui-slots ← web-react ← runtime ← ui-*(并列)← web`ui-primitives/ui-theme/i18n 为零依赖旁路。
多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板:
```
src/client/
contract/ the only shared face between domains (types + composed props shares)
service.ts cross-domain orchestration (imports contract only)
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
chat/ domain: the chat view
toolviews/ domain: the tool-row registry and samples
apply.ts the ONLY file allowed to import across domains (assembly point)
index.ts thin re-export shell (contract + apply + components)
```
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 chat 经 `ToolViewResolver` 读面接口消费工具注册表,不碰注册表类)。`scripts/verify-client-domain-graph.ts` 把守分层contract=0、域=1、apply/index=2import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
## 怎么开发
- **新 UI 功能** = 新插件包package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot 与 toolview无 host 逻辑时 node 半边保持空 apply用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
- **新 slot**:契约合并进 `SlotMap`owner 处 `define`,经 owner 自己的 `ScopedSlots` 白名单渲染;注册方 `register`,按需带 inject 工厂。永不全局导出组件。
- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支host 级 → Manager 路由表UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
- **状态住哪**per-session 且要跨切换存续 → Session 对象 / scope 挂账 store单视图私有选中、滚动→ 组件状态;壳观看态(导航、面板宽、偏好)→ `ctx.layout` 的 store业务数据 → 永远对象层,永不进观看态 store。
- **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。
## Consequences
token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位,对被订阅视图每微任务一次合批重渲染(帧驱动 store 走 raf 合批。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 |
| window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 |
| 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 |
| 工具行走字符串键的全局组件注册表 | 工具视图被多个视图共同消费且要按会话差异化——带 scope 过滤的具名服务(`ctx.toolviews`)才是诚实形态 |
| P-I 就做渐进/Suspense 启动 | 一次成型严格更简单loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 |

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-slot-type-chain-implementation.md: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6
2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2

View File

@@ -0,0 +1,47 @@
# Agent Note: Slot type-chain hardening — the non-obvious implementation rulings
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.
## 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.
## Decision
### 1. `SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position
`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.
### 2. `NoInfer<I>` pins the registrant share's inference to the inject factory
`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.
### 3. `ComposedProps` dispatches on the entry's `owner` key for progressive migration
`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.
### 4. The standard share is bottom-typed, and bare `register` bivariance is accepted, not fought
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.
### 5. `ChildrenChecked` is an opt-in validation layer keyed on the entry's `children` declaration
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.
## 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.
## 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 |

View File

@@ -0,0 +1,47 @@
# Agent Note: slot 类型链硬化——五条非显然实现裁定
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本文钉住五条实现决定——不写下来将来的编辑者要么重新争论一遍要么不经意地回退它们。
## Problem
硬化后的类型链给从 `SlotMap` 声明到组件渲染的每一跳定型owner 份额 + 框架标配份额 + 注册方注入份额组合成组件 props`register()` 处校验。让这条约束既成立又不误伤,逼出了五个单看代码显得任意的选择——每一个的存在都是因为显然的替代方案会以一种具体的、可复现的方式失败。
## Decision
### 1. 注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`
`register()``SlotComponent<ComposedProps<K, NoInfer<I>>>` 约束组件,其中 `SlotComponent<P> = (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes``defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。
### 2. `NoInfer<I>` 把注册方份额的推断钉在 inject 工厂上
`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer`TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer<I>` 移除了那个候选位负样本⑥owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。
### 3. `ComposedProps` 按条目的 `owner` 键分派,支撑渐进迁移
`ComposedProps<K, I>` 只在 SlotMap 条目声明了 `owner` 份额时才组合 `owner & standard & I`;未声明的条目回落到 legacy 全量 `props` 约束(`PropsShape`。这个条件类型就是迁移接缝legacy 声明原样编译,条目逐个转入组合模型,两种形态走同一个 `register()`——无平行 API、无开关旗。删掉回落分支的那一刻=全仓切换时刻,不是一次清理。
### 4. 标配份额 bottom 型化;裸 `register` 的双变接受面认账不硬测
session 坑的框架供给 hook 约束为 `{ useSession: never }``StandardOf`):参数性位置上的 `never` 意味着任何注册方收窄(如 runtime 定型的会话 hook都被接受实际到达什么的类型责任归注入侧渲染器。已知边界搭车项对以方法语法定型或参数位本就双变的组件TS 可能接受一个严格意义上不该过的 `register` 调用(参数双变是 TS 的有意不健全)。这个立场以文档记账而不加测试:我们不写依赖 TS 并不承诺的严格性的负样本——那钉住的是编译器版本行为,不是我们的契约。真正钉住的六个 expect-error 位(`packages/client/ui-slots/tests/type-chain.spec.tsx`)全部因契约原因失败。
### 5. `ChildrenChecked` 是按条目 `children` 声明挂载的 opt-in 校验层
子坑转授权威仍是手写白名单(组件自己 props 上的 `slots: ScopedSlots<'a' | 'b'>`)。`ChildrenChecked<K, P>` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。
## Consequences
register 调用点成为全链唯一收口份额漂移、inject 键缺失、越权子坑面、keyed/list options 缺省全部在编译期于此暴露,六样本负样本 spec 逐一钉住失败模式。代价:条件类型让 register 位的悬停签名明显变宽bottom 型标配份额把到达类型的责任转给 web-react 渲染器(记录于 `StandardOf`);双变边界意味着一类不健全接受被知情容忍。
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| 保留 `FC`、在 register 位 cast | cast 恰好藏起类型链要抓的漂移FC 静态位的协变噪音是机械成因,该移除噪音而非移除检查 |
| 从组件参数位推断 `I` | 推断渗漏静默吸收 props 漂移——负样本⑥无从写起 |
| 组合 props 一次性全仓迁移 | 所有 SlotMap 声明方挤进一个 PR`owner` 键分派让条目逐个迁移、两形态共存 |
| 给双变接受边缘加负样本 | 钉住的是我们不拥有的 TS 健全性行为;编译器升级会在契约零变化时打红 spec |
| 从 `children` 声明派生转授白名单 | 手写面才是组件作者读到的 API派生反转所有权设计层已否——`ChildrenChecked` 做校验不做生成 |

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-web-styling-system.md: c80ef0d56a0e57b38fbb52bd07cbc0f69ec85912
2026-07-19-web-styling-system.zh.md: 59013a4a950196f3a065ac18415f9b5ed42f3ec3

View File

@@ -0,0 +1,61 @@
# Agent Note: Web styling system — the token framework and engineering constraints
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.
English | [中文](2026-07-19-web-styling-system.zh.md)
> Division of labor: this RFC fixes the framework and constraints (rarely changes); [docs/web-styling.md](../../../../docs/web-styling.md) is the living spec (authoritative token values, the coding-rule checklist, the deviation record — it evolves with the implementation). Token changes and new rules go there; only changes to the framework itself come back here (overturning it requires a new RFC).
## Problem
The GUI has no designer supply; styles are written by an agent and reviewed. Without a machine-checkable token system and coding rules, colors/radii/motion drift as literals across components, and dark mode grows into conditional branches scattered inside components.
## Decision
| # | Decision | Content |
|---|---|---|
| 1 | **Visual baseline = Chat alignment** | Every value comes from the Chat front-end survey (brand blue `--accent: #3964fe`, gray scale, bubble/sidebar geometry, shadow tiers…); deviation is allowed but must be recorded in the web-styling.md deviation table |
| 2 | **Two token layers, not three** | The baseline repo uses static→alias→specific three layers; at our size this compresses to "a semantic layer holding real values directly (comments cite the base palette source) + a handful of component-specific slots (`--bg-sidebar`/`--bubble-bg`)" — two layers, all living in `web-ui/src/style/global.css` |
| 3 | **Font sizes/spacing are not tokenized** | Same decision as the baseline repo: font sizes are written in px inside components and **always paired with a line height** (16/24, 14/22, 12/18); spacing uses multiples of 4; tokenization covers only colors/radii/motion/font stacks/shadows |
| 4 | **Borders and interaction states use the opacity scheme** | Borders `rgba(0,0,0,.04/.1)`, hover/active `rgba(38,49,72,.06/.1)` — they hold when layered on any elevation background, no new solid grays |
| 5 | **Dark mode happens only in the token table** | `:root` holds light real values + `[data-theme='dark']` overrides the same-named variables; **component CSS has zero theme selectors**; when a non-token value genuinely must vary by theme, use the "CSS variable bridge" (the component defines a local variable, the theme block only overrides the variable) |
## Engineering constraints
- **CSS Modules + clsx, no component library, no tailwind**: each component has a same-named `.module.css` in the same directory; class names are camelCase, single-adjective state classes are attached via clsx; components pass `className` through.
- **`composes` is banned**; `:global` only pierces third-party/cross-package class names and never defines new global classes; global utility classes live only in global.css and stay in the single digits (currently `.scrollable`).
- **PostCSS plugins are currently zero** (vite has no postcss config; flat CSS suffices — adopting nested/custom-media requires recording it in web-styling.md first); CSS Modules type declarations use the wildcard declare in `css-modules.d.ts` (re-evaluate typed-css-modules per-file generation past 20 components).
- **Dynamic styles go through the CSS variable bridge**: JS writes only variables (`style={{'--x': v}}`), rules stay in CSS; assembling style objects in TSX for theme/state branches is banned.
- Transitions are always `var(--dur*) var(--ease)` and only transition opacity/transform/background-color/shadow; scroll containers uniformly use `.scrollable` (writing `::-webkit-scrollbar` inside components is banned).
## The execution shape for agents
The spec is maintained as a **review checklist** (web-styling.md §3, 12 items): each item is a decidable "see X, reject" — not a style suggestion — and writing styles and reviewing styles share the same table.
Entry points for common tasks (operational checklists):
- **Styling a new component**: same-named `.module.css` in the same directory, self-check against web-styling.md §3 item by item; colors/radii/motion reference only §1 tokens.
- **Adding a token**: first add a row to the web-styling.md §1 table (light value + dark column + base palette source comment) → update both the global.css `:root` and `[data-theme='dark']` blocks → only then reference it in a component.
- **Deviating from a visual-baseline constant** (the geometry/shadow values of web-styling.md §2): record a row in the §5 deviation table first (date/item/reason), then land the code.
- **A non-token value that must vary by theme** (gradient endpoints and the like): the component defines a local CSS variable and the theme block only overrides the variable (the variable bridge); component CSS keeps zero `[data-theme]` selectors.
## Division of labor with web-styling.md
| Content | Home |
|---|---|
| The five framework rules, engineering constraints, why two layers / why font sizes are not tokenized | This RFC (changing it = a new superseding RFC) |
| Per-token authoritative values (dark included), visual-baseline constants (sidebar/bubble/session-row/input-card geometry), the RPC four-quadrant direction-marker visual vocabulary, the 12 coding rules, the deviation record | web-styling.md (living document, evolves with the implementation) |
| Value evidence (deepseekchat file:line) | The survey archive has served its purpose; git history keeps it |
## Consequences
Styles converge machine-checkably: colors/radii/motion/shadows reference only the §1 tokens of web-styling.md, dark mode is a single attribute-selector override table, and review runs off the same 12-item checklist the author self-checks against. The cost accepted: font sizes/spacing rely on the paired-line-height and multiples-of-4 disciplines rather than tokens, and any framework change requires a superseding RFC.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Tokenizing font sizes/spacing | The baseline repo demonstrates convergence without it (the paired-line-height discipline substitutes); a bloated token table dilutes the authority of the color tokens |
| Dark mode via `prefers-color-scheme` or in-component branches | Attribute-selector whole-table override keeps components oblivious; system preference can be layered onto the toggle later without touching the token mechanism |

View File

@@ -0,0 +1,61 @@
# RFC: Web 样式体系——token 框架与工程约束
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。
[English](2026-07-19-web-styling-system.md) | 中文
> 分工:本 RFC 定框架与约束(少变);[docs/web-styling.md](../../../../docs/web-styling.md) 是活规范token 权威值、编码规范打勾清单、偏离记录,随实现演进)。改 token/加规则去那边;动框架本身才回这里(推翻须新 RFC
## Problem
GUI 无设计师供给,样式由 agent 编写并 review没有一套机器可对照的 token 体系与编码规范,颜色/圆角/动效会在组件间字面量漂移,暗色主题会长成组件内散落的条件分支。
## Decision框架五条
| # | 决策 | 内容 |
|---|---|---|
| 1 | **视觉基线 = Chat 对齐** | 取值全部来自对 Chat 前端调研(品牌蓝 `--accent: #3964fe`、灰阶、气泡/侧边栏几何、阴影分级……);允许偏离但须在 web-styling.md 偏离表记录 |
| 2 | **token 两层不三层** | 基线仓是 static→alias→specific 三层;我们体量下压成「语义层直接持实值(注释标 base 色板出处)+ 极少数组件专属槽位(`--bg-sidebar`/`--bubble-bg`)」两层,全部住 `web-ui/src/style/global.css` |
| 3 | **字号/间距不 token 化** | 基线仓同款决策:字号在组件里写 px 且**成对写行高**16/24、14/22、12/18间距用 4 的倍数token 化只覆盖颜色/圆角/动效/字体栈/阴影 |
| 4 | **边框与交互态用透明度制** | 边框 `rgba(0,0,0,.04/.1)`、hover/active `rgba(38,49,72,.06/.1)`——叠加在任意海拔底色上都成立,不新造实色灰 |
| 5 | **暗色只在 token 表做** | `:root` 亮色实值 + `[data-theme='dark']` 覆盖同名变量;**组件 CSS 零主题选择器**;确需按主题换非 token 值时用「CSS 变量桥」(组件定义局部变量、主题块只覆写变量) |
## 工程约束
- **CSS Modules + clsx无组件库、无 tailwind**:每组件同目录同名 `.module.css`;类名 camelCase、状态类单形容词由 clsx 挂载;组件透传 `className`
- **禁 `composes`**`:global` 仅穿透第三方/跨包类名,不定义新全局类;全局工具类只住 global.css 且个位数(现状 `.scrollable`)。
- **PostCSS 插件现状为零**vite 无 postcss 配置,平铺 CSS 即够用;引入 nested/custom-media 前需先记入 web-styling.mdCSS Modules 类型声明用 `css-modules.d.ts` 通配 declare组件数超 20 再评估 typed-css-modules 逐文件生成)。
- **动态样式走 CSS 变量桥**JS 只写变量(`style={{'--x': v}}`),规则留在 CSS禁止 TSX 内拼样式对象做主题/状态分支。
- 过渡一律 `var(--dur*) var(--ease)` 且只过渡 opacity/transform/背景色/阴影;滚动容器统一 `.scrollable`(组件内禁写 `::-webkit-scrollbar`)。
## 给 agent 的执行形态
规范以 **review 对照打勾清单**形态维护web-styling.md §312 条):每条是可判定的「见 X 即打回」,不是风格建议——写样式与 review 样式共用同一张表。
常见事项的入口(操作清单):
- **写新组件样式**:同目录同名 `.module.css`,对照 web-styling.md §3 逐条自查;颜色/圆角/动效只引 §1 token。
- **加一个 token**:先进 web-styling.md §1 表补一行(亮色值+暗色列+base 色板出处注释)→ global.css `:root``[data-theme='dark']` 两块同步 → 再在组件里引用。
- **偏离视觉基线常数**web-styling.md §2 的几何/阴影值):先在 §5 偏离表记一行(日期/项/理由)再落码。
- **需要按主题变化的非 token 值**(渐变端点等):组件定义局部 CSS 变量、主题块只覆写变量(变量桥),组件 CSS 保持零 `[data-theme]` 选择器。
## 与 web-styling.md 的分工
| 内容 | 归属 |
|---|---|
| 框架五条、工程约束、为何两层/为何不 token 化字号 | 本 RFC改=新 RFC 供替) |
| token 逐项权威值(含暗色)、视觉基线常数(侧边栏/气泡/会话列/输入卡片几何、RPC 四象限方向符视觉词汇、编码规范 12 条、偏离记录 | web-styling.md活文档随实现演进 |
| 取值证据deepseekchat file:line | 调研归档已完成使命git 历史留档 |
## Consequences
样式收敛到机器可对照:颜色/圆角/动效/阴影只引 web-styling.md §1 token暗色是单一属性选择器覆盖表review 与自查共用同一张 12 条清单。接受的代价:字号/间距靠成对行高与 4 倍数纪律而非 token动框架本身须新 RFC 供替。
## Alternatives considered
| 放弃项 | 一句话理由 |
|---|---|
| 字号/间距 token 化 | 基线仓实证不 token 化也能收敛成对写行高纪律替代token 表膨胀降低颜色 token 的权威性 |
| 暗色用 `prefers-color-scheme` 或组件内分支 | 属性选择器整表覆盖让组件零感知;系统偏好可后续在 toggle 层适配,不动 token 机制 |

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112
2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933

View File

@@ -0,0 +1,59 @@
# Agent Note: GUI testing system — the three-tier structure
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.
English | [中文](2026-07-20-gui-testing-system.zh.md)
> Division of labor: this note covers only the test structure specific to the GUI (`packages/{client,host}/*` + `apps/web`); repo-wide testing policy (tiering principles, the with-key policy, real-implementation-first, REAL-composition) lives in [docs/testing.md](../../../../docs/testing.md) and is not restated here.
## Problem
The GUI stack spans multiple application shapes, and within one shape multiple runtime environments (the Node host, the data protocol layer, the browser object layer, React/DOM); a single-lane test suite cannot give a meaningful signal. Every link needs effective tests of its own, plus the base capability for full-chain testing.
## Decision
Cut along the architecture's natural test seams into three tiers, bottom-up:
| Tier | Under test | Key technique | File location |
|---|---|---|---|
| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` |
| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` |
| 3 Browser smoke | Build artifacts × a real browser (the page boots, one conversation round-trips) | Bare playwright library (chromium headless, no @playwright/test framework), minimal pass-through; fixture level + real-host level (self-skips without a key) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` |
Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — smoke only proves the wiring is alive (the fixture level asserts zero `/api` requests and zero pageerror), interaction detail belongs to the verify scripts (see the lane map), wire semantics to tier 1, data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
- **Host side** (apiproxy/runtime/webserver): under the repo-wide `test:coverage` gate, per-file 100%.
- **Client side**: web-runtime **is already under the per-file 100% gate** (12 defensive unreachable arms carry reasoned `/* v8 ignore */` comments); the `vitest.config.ts` coverage.exclude is down to `packages/client/web-ui/src/**` (temporary — lifted progressively as component specs fill in after the component redo); tests still run, the exclusion only keeps web-ui src out of the thresholds. web-ui takes the **jsdom route (landed)**: jsdom + @testing-library/react entered root devDependencies (dev-only), first spec `web-ui/tests/utils.spec.tsx` (utils pure functions + component RTL render + hook uSES probe); the environment uses the per-file `// @vitest-environment jsdom` pragma, zero impact on the other node-env packages.
- The exclusion is an **explicitly annotated ruling**, not a silent waiver; the lift path = delete the exclude line + add a justified exclusion or the missing tests.
## Lane map
| Scenario | Command | Content | When to run |
|---|---|---|---|
| Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source |
| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery |
| Gate | `pnpm run test:coverage` | The repo-wide gate (host-side GUI packages included, client side excluded) | The PR window |
**Division of labor between the verify scripts and vitest**: verify owns browser black-box regression (sequential steps = a user-operation script, one shared browser session, streaming PASS/FAIL output for the agent to locate the break), vitest owns first-class data-layer semantic assertions (reference stability `toBe`, state-machine timing, wire shapes). The two lanes complement each other, neither absorbs the other — scripts do not migrate to vitest (tearing apart an ordered script is a net loss); promoting one means wrapping a spawn shell hooked into the e2e lane, never rewriting the script body.
## Anti-regression discipline
- **Every bug fix pins an assertion**: a browser-visible bug is pinned into the regression section of its owning verify script (one pin = one report line); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
- **All-green on fixture is not done, the real host must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run `verify-session-real`.
- The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging.
## Consequences
Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in node env, and the browser carries only wiring-liveness smoke. On the gate surface, the host side is fully under per-file 100%; on the client side web-runtime is under the gate while web-ui waits behind the explicitly annotated exclude. The accepted cost: the inter-tier discipline (upper tiers never re-test lower ones) is upheld by review rather than a machine gate, and web-ui's coverage gap persists until component specs fill in after the component redo.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Single e2e (everything through the browser) | Browser startup is seconds × N slower and timing is uncontrollable; wire/object-layer invariants can be fully asserted in milliseconds in node env |
| Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface |
| Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm |
| A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config |
| Deferring hooks/component-layer unit tests (the original ruling) | Once deferred as "components are consumables, revisit after the redo"; overturned by the user on 2026-07-20 — **the jsdom mainline enters coverage** (no browser infrastructure in CI is the decisive reason, playwright demoted to a local enhancement), the RTL dependencies entered devDependencies, the first spec landed |

View File

@@ -0,0 +1,59 @@
# RFC: GUI 测试体系——三层结构
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。
[English](2026-07-20-gui-testing-system.md) | 中文
> 分工线:本篇只讲 GUI`packages/{client,host}/*` + `apps/web`特有的测试结构全仓测试政策分层原则、with-key 政策、真实体优先、REAL-composition见 [docs/testing.md](../../../../docs/testing.md),不在此复述。
## Problem
GUI 栈需要考虑多种应用形态同应用形态内的不同运行环境Node host、数据协议层、浏览器对象层、React/DOM单一车道的测试给不了有效信号。需要对各环节都进行有效测试并具备全链路测试的基础能力
## Decision三层结构
贴架构天然测试缝切三层,自底向上:
| 层 | 被测物 | 关键手段 | 文件落点 |
|---|---|---|---|
| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` |
| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` |
| 3 浏览器 smoke | 构建产物 × 真浏览器(页面起得来、一轮对话跑得通) | playwright 裸库chromium headless@playwright/test 框架最简跑通fixture 级 + 真 host 级(无 key self-skip | `apps/web/tests/smoke-{fixture,real}.e2e.ts` |
层间纪律:**下层各测各的,上层不重测下层**——smoke 只证接线活着fixture 级断零 `/api` 请求、零 pageerror交互细节归 verify 脚本见车道地图wire 语义归 1 层,数据语义归 2 层。纯函数层lineage/partial/notifier/fold-adapter随 2 层同包 tests/ 零假体直测。
- **host 侧**apiproxy/runtime/webserver进全仓 `test:coverage` 门禁per-file 100%。
- **client 侧**web-runtime **已进 per-file 100% 门禁**12 处防御性不可达臂带理由 `/* v8 ignore */` 注释);`vitest.config.ts` coverage.exclude 只剩 `packages/client/web-ui/src/**`(暂时——组件重做后随组件 specs 铺满逐步解除),测试照跑,只是不拉 web-ui src 进阈值。web-ui 走 **jsdom 路线(已落地)**jsdom + @testing-library/react 入 root devDepsdev-only首个 spec `web-ui/tests/utils.spec.tsx`utils 纯函数 + 组件 RTL render + hook uSES 探针);环境用 per-file `// @vitest-environment jsdom` pragmanode env 的其他包零影响。
- 排除是**显式注释的裁决**不是静默豁免;解除路径=删 exclude 行 + 补 justified 排除或补测。
## 车道地图
| 场景 | 命令 | 内容 | 何时跑 |
|---|---|---|---|
| 基础 | `pnpm run test:gui` | 1+2 层 vitest`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 |
| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist再跑 3 层双级 smokefixture 级 + 真 host 级 self-skip | 改构建面/boot/承载后;交付前 |
| 门禁 | `pnpm run test:coverage` | 全仓 gatehost 侧 GUI 包在内client 侧 excluded | PR 窗口 |
**verify 脚本与 vitest 的分工**verify 管浏览器黑盒回归(顺序步骤=用户操作剧本共享一次浏览器会话PASS/FAIL 流式输出供 agent 定位断点vitest 管数据层语义一等断言(引用稳定性 `toBe`、状态机时序、wire 形)。两车道互补不收编——脚本不迁 vitest拆散有序剧本是负收益转正时包一层 spawn 壳挂 e2e 车道即可,脚本本体不改写。
## 防回归纪律
- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属 verify 脚本的回归节(一钉一行 report数据层 bug 钉进对应 spec先例res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
- **fixture 全绿不算完,真 host 也要过**fixture 短路的恰是 wire 承载链node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,`verify-session-real` 必跑。
- 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/契约为裁),不留悬红。
## Consequences
各车道各测各层:改任意 GUI 源码有秒级 `test:gui` 反馈wire/对象层语义在 node env 毫秒级断言,浏览器只承担接线存活冒烟。门禁面上 host 侧全量进 per-file 100%client 侧 web-runtime 已进门web-ui 暂留显式注释的 exclude 之后。接受的代价:层间纪律(上层不重测下层)靠 review 而非机器门禁维持web-ui 的覆盖缺口持续到组件重做后组件 specs 铺满为止。
## Alternatives considered
| 放弃项 | 一句话理由 |
|---|---|
| 单一 e2e (全走浏览器) | 浏览器起步秒级×N 倍慢+时序不可控wire/对象层不变量在 node env 可毫秒级全断言 |
| verify 脚本迁 vitest | 有序剧本共享浏览器会话,拆 case 要么形式化sequential+共享 page要么重走前置×NPASS/FAIL 流式输出正是 agent 定位接口 |
| 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 |
| GUI 包独立 vitest config曾设计 vitest.gui.config.ts | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config |
| hooks/组件层暂缓单测(原裁决) | 曾以「组件是耗材、等重做后再议」暂缓2026-07-20 用户改判——**jsdom 主线进覆盖率**CI 无浏览器基建是决定性理由playwright 降级为本地增强RTL 依赖入 devDeps、首个 spec 已落 |