Merge remote-tracking branch 'origin/master' into feat/plan-mode

17 master merges (GUI host/web stack, compact header, dshweb, fast local
hooks, windows coverage skips, worktree runtime). Conflicts:
- packages/ui/user-interaction/src/index.ts: master extracted the question
  types to types.ts; took the re-export and grafted our detail field (the
  plan-review payload) into the extracted AskUserQuestionItem.
- generated catalogs regenerated over both sides.

The module graph crossed mermaid's default 500-edge render guard with the
GUI packages plus our plan-mode edges; raised maxEdges in verify-mermaid
(a secure config settable only at initialize).

Note: the remote branch also renamed packages/mode -> packages/plan
(PR #512 plan-mode-simplification, fast-forwarded before this merge).
This commit is contained in:
kingwl
2026-07-22 19:42:14 +08:00
393 changed files with 33738 additions and 428 deletions
@@ -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
@@ -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 |
@@ -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` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。
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 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × 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.ts);vite 应用是 `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)。
- 业务代码不 mint:unary 的 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 侧承诺)
- **历史 = 事件重放**:一套 fold(client 侧),历史分页与 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 在协议层替换而不是包一层假信封 |
@@ -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
@@ -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 |
@@ -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、会话 scope),React 是纯投影:组件对框架零 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` 闭包(坑位所有权留在 layout;provider 不认识坑名)。
实现的家:注册表纯核在 `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 在途时缓冲,否则追加 + 增量 fold;open/缝合按 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`。分片完全不进 fold(O(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 的按插件状态面已保留,渐进点亮日后可落地而无需重构 |
@@ -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
@@ -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 |
@@ -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` 做校验不做生成 |
@@ -31,7 +31,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis"
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.
`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md).
`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`.
### Automatic pressure runs after successful durable step work
@@ -108,6 +108,7 @@ Two failure paths, both documented:
- **The full algorithm as concrete interface methods** — rejected because it recouples the contract to one retention strategy. Both core methods are abstract; reusable measurement is a separate LLM-family service and `summarize()` is basic's sole hook.
- **Compaction on `agent/request` or provisional `agent/pre-step` inputs** — rejected because neither proves the final durable request and both couple generic lifecycle to compaction-specific envelope data. Post-step replay plus canonical overflow recovery covers both successful and rejected calls.
- **A `compact` boolean or untyped request metadata map** — rejected because multiple auxiliary call kinds would become mutually exclusive flags, while an open bag would discard compiler-checked vocabulary. One typed `purpose` discriminant extends with additional call kinds without adding another `GenerateOptions` field.
- **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling.
- **Teaching core turn-repair about `compact/*`** — rejected: the log-only orphan is inert, and a core module patched for every future `xxx/start … xxx/end` plugin pair is exactly the coupling the capability-seam architecture exists to avoid.
@@ -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
@@ -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 |
@@ -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.md);CSS 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 §3,12 条):每条是可判定的「见 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 机制 |
@@ -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
@@ -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 |
@@ -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 已落 |
+2
View File
@@ -25,3 +25,5 @@ python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*
python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/
python/**/__pycache__/
python/**/.pytest_cache/
apps/web/dist/
.artifacts/
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: `dsh web` serves the built web UI over HTTP; `dsh -p` runs one headless task through the in-process ApiProxy carrier",
"version": "0.0.1",
"private": true,
"type": "module",
"bin": {
"dsh": "lib/bin.js"
},
"files": [
"lib/bin.js",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
/**
* dsh — command-line entry. Coarse dispatch only; each subcommand module owns
* its parseArgs. Dynamic imports keep the shapes independent: `web` never
* loads the headless consumer, `-p` never loads node:http or the static server.
*/
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
loadEnv('dsh')
const argv = process.argv.slice(2)
if (argv[0] === 'web') {
const { runWeb } = await import('./web.ts')
await runWeb(argv.slice(1))
} else if (argv.includes('-p') || argv.includes('--prompt')) {
const { runHeadless } = await import('./headless.ts')
await runHeadless(argv)
} else {
process.stderr.write('usage: dsh web [--port N] | dsh -p "task"\n')
process.exit(1)
}
+104
View File
@@ -0,0 +1,104 @@
/**
* `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
* injection (InProcessApiClient over the host handler, so the full carrier
* chain — wire serialization, zod, SSE framing — really runs; this is the
* protocol's second real consumer). No HTTP server, no port, no dist
* resolution. Runs one task turn, prints the final assistant text, exits
* (completed → 0, else 1).
*/
import { parseArgs } from 'node:util'
import { startHost } from '@deepseek-ai/dsh-host-runtime'
import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
text: string
reason: string
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
process.stderr.write(`dsh: ${code}: ${message}\n`)
await dispose()
process.exit(1)
}
/**
* Consume mux frames until the task turn ends, per the cli-demo runOneShot
* correlation precedent: anchor on the first turn/start whose trigger kind is
* 'message' (startup-injected turns are skipped), aggregate text from that
* turn's assistant/message events (last one wins), finish on its turn/end.
*/
async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
let targetTurn: number | undefined
let text = ''
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') {
process.stderr.write(`dsh: stream error: ${payload.error.message}\n`)
return { text, reason: 'error' }
}
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
if (targetTurn === undefined) {
if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
continue
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
const joined = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
return { text, reason: event.data.reason.kind }
}
}
} catch (error: unknown) {
process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
return { text, reason: 'error' }
}
export async function runHeadless(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: { prompt: { type: 'string', short: 'p' } },
allowPositionals: false,
})
const task = values.prompt
if (task === undefined || task === '') {
process.stderr.write('usage: dsh -p "task"\n')
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
const api = new InProcessApiClient(host.handler)
const created = await unwrap(await api.sessions.create({}), host.dispose)
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
// to a remote HTTP carrier unchanged.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const done = consumeUntilTurnEnd(frames, created.sessionId)
await unwrap(await api.sessions.prompt({
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), host.dispose)
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await host.dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}
+89
View File
@@ -0,0 +1,89 @@
/**
* `dsh web` — the web-shape assembly: startHost + dist resolution +
* startWebServer + the URL line + signal wiring. Mixing host and carrier
* concerns is this app module's job (packages stay single-sided).
*/
import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { createRequire } from 'node:module'
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: { port: { type: 'string', default: '3080' } },
allowPositionals: false,
})
const port = Number(values.port)
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
// Web UI plugin chain: in-memory Loader tree over the eight UI packages,
// then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.
const mounted = await mountWebPlugins(host.ctx)
const webPlugins = createHostWebPluginRegistry({
ctx: host.ctx,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
})
// Published so the webserver invariant companion can audit manifest/bundle
// consistency; nothing else reads this key.
host.ctx.reflect.provide('webPlugins', webPlugins)
// Dist location is workspace knowledge of this app: resolved through
// @deepseek-ai/dsh-frontend's package exports, not configured.
const require = createRequire(import.meta.url)
let distIndex: string
try {
distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
await host.dispose()
process.exit(1)
}
let exiting = false
async function shutdown(code: number): Promise<void> {
if (exiting) return
exiting = true
try {
await server.close()
await host.dispose()
} finally {
process.exit(code)
}
}
let server: Awaited<ReturnType<typeof startWebServer>>
try {
server = await startWebServer(
{ port, distIndex, apiHandler: host.handler, webPlugins },
(err: Error) => {
process.stderr.write(`dsh web: ${String(err)}\n`)
void shutdown(1)
},
)
} catch (error: unknown) {
// listen failed (EADDRINUSE…): no server to close, dispose the host directly.
process.stderr.write(`dsh web: ${String(error)}\n`)
await host.dispose()
process.exit(1)
}
// The server binds 0.0.0.0 (remote-container + LAN-browser is the primary scenario);
// print the LAN address alongside loopback so the printed URL is copy-usable from outside.
const lan = Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
console.log(`dsh web: http://127.0.0.1:${server.port}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
process.on('SIGTERM', () => { void shutdown(0) })
process.on('SIGINT', () => { void shutdown(130) })
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{ "path": "../../vendor/cordis" },
{ "path": "../../packages/host/apiproxy" },
{ "path": "../../packages/host/runtime" },
{ "path": "../../packages/host/webserver" },
{ "path": "../../packages/core/session" },
{ "path": "../../packages/ui/app-boot" }
]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DeepSeek Harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./dist/*": "./dist/*",
"./package.json": "./package.json"
},
"scripts": {
"build": "vite build",
"dev": "vite",
"watch": "vite build --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-web": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@vitejs/plugin-react": "^4.0.0",
"playwright": "^1.49.0",
"typescript": "^6.0.3",
"vite": "^6.0.0",
"vitest": "^4.1.8"
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Web application entry: thin bootstrap over the shell library. Everything —
* loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
* in @deepseek-ai/dsh-client-web; this file only finds the mount point.
*/
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
const el = document.getElementById('root')
if (el === null) throw new Error('web app: missing #root')
bootWebShell(el)
+147
View File
@@ -0,0 +1,147 @@
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
// chromium. First describe: manifest injection + fail-loud half. Second
// describe: the settled success pass — five REAL tsdown bundles (the
// infrastructure four + layout) load through the DI chain in ?fixture mode
// and the three-column frame appears in one flip. The full conversation
// round lands in smoke-real under the W5 real-host standard.
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver'
import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const bundlePath = (dir: string): string =>
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
/** id ↔ bundle table for the success pass (immediately four + layout). */
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
]
/** Manifest served by the fake registry: one live bundle row, one missing row. */
const ROWS: WebPluginBootEntry[] = [
{ id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] },
{ id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] },
]
const LAYOUT_BUNDLE = bundlePath('ui-layout')
describe('web boot chain (keyless, real carrier)', () => {
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
let page: Page
const pageErrors: string[] = []
beforeAll(async () => {
requireDist()
const port = await probeFreePort()
const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
server = await startWebServer({
port,
distIndex: DIST_INDEX,
apiHandler,
webPlugins: {
snapshot: () => ROWS,
clientPath: (id) => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined),
},
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
browser = await chromium.launch()
page = await browser.newPage()
page.on('pageerror', (e) => pageErrors.push(String(e)))
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
})
afterAll(async () => {
await browser?.close()
await server?.close()
})
it('GET / injects the manifest verbatim', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
expect(boot).toEqual({ plugins: ROWS })
})
it('serves a real bundle through the plugins endpoint', async () => {
const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`)
expect(res.status()).toBe(200)
expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
})
it('boots to the loading page and fail-louds the absent plugin', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
// The real UI must not have flipped in: the gate opens only on settled().
expect(await page.locator('[class*="frame"]').count()).toBe(0)
})
it('applies the token sheets before any plugin CSS', async () => {
const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
expect(family.trim().length).toBeGreaterThan(0)
})
})
describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => {
const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir)))
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
let page: Page
const pageErrors: string[] = []
beforeAll(async () => {
requireDist()
if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map((m) => m.dir).join(', ')}`)
const port = await probeFreePort()
const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => {
const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject }
if (p.immediately === true) row.immediately = true
return row
})
const byId = new Map(REAL_PLUGINS.map((p) => [p.id, bundlePath(p.dir)]))
// ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
server = await startWebServer({
port,
distIndex: DIST_INDEX,
apiHandler,
webPlugins: { snapshot: () => rows, clientPath: (id) => byId.get(id) },
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
browser = await chromium.launch()
page = await browser.newPage()
page.on('pageerror', (e) => pageErrors.push(String(e)))
await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
})
afterAll(async () => {
await browser?.close()
await server?.close()
})
it('settles and flips to the three-column frame in one pass', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
// Loading page is gone; the grid carries the three tracks.
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
const template = await page.locator('[class*="frame"]').evaluate((el) => getComputedStyle(el).gridTemplateColumns)
expect(template.split(' ').length).toBe(3)
})
it('every plugin CSS landed with its ownership tag', async () => {
const owners = await page.evaluate(() =>
[...document.querySelectorAll('style[data-plugin]')].map((s) => (s as HTMLElement).dataset['plugin']))
expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
})
it('stayed clean: no page errors across the whole load chain', () => {
expect(pageErrors).toEqual([])
})
})
+235
View File
@@ -0,0 +1,235 @@
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
// list in a real chromium, screenshot every screen into .artifacts/ for the
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
// convention); the runner loads the repo-root .env explicitly because the CLI
// only auto-loads .env from its cwd (a temp dir here, so sessions never land
// in the repo's .sessions).
//
// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
// selectors are unreliable — anchor on data-* attributes (data-variant /
// data-clickable / data-sample) or visible text. The one [class*=] use below
// (frame/handle) rides local names that survive hashing as suffixes; prefer
// data-* for anything new.
//
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
// and theme after, reload recovery last. Tests run sequentially in-file.
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
/** Repo-root .env → process.env (never overrides an already-set variable). */
function loadRootEnv(): void {
const envPath = join(REPO_ROOT, '.env')
if (!existsSync(envPath)) return
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
}
}
loadRootEnv()
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
let out = ''
const timer = setTimeout(() => reject(new Error(`dsh web not ready in 90s; output:\n${out}`)), 90_000)
const onData = (chunk: Buffer): void => {
out += chunk.toString()
const match = /dsh web: (http:\/\/[^\s]+)/.exec(out)
if (match?.[1] !== undefined) {
clearTimeout(timer)
resolveReady(match[1])
}
}
child.stdout?.on('data', onData)
child.stderr?.on('data', onData)
child.once('exit', (code) => {
clearTimeout(timer)
reject(new Error(`dsh web exited early (code ${code}); output:\n${out}`))
})
})
}
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
async function screen(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
}
/** First column track (px string) of the frame grid. */
async function firstTrack(page: Page): Promise<string> {
return (await page.locator('[class*="frame"]').evaluate(
(el) => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
}
/** Last column track (details) as a number of pixels. */
async function detailsTrack(page: Page): Promise<number> {
const cols = await page.locator('[class*="frame"]').evaluate(
(el) => getComputedStyle(el).gridTemplateColumns)
return Number(cols.split(' ').pop()!.replace('px', ''))
}
// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory']
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
})
if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
let child: ChildProcess
let sessionsDir: string
let baseUrl: string
let browser: Browser
let page: Page
const pageErrors: string[] = []
beforeAll(async () => {
requireDist()
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
const port = await probeFreePort()
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a
// temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader
// and tsconfig paths pointed at explicitly.
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
{
cwd: sessionsDir,
env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') },
stdio: ['ignore', 'pipe', 'pipe'],
},
)
baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page.on('pageerror', (e) => pageErrors.push(String(e)))
await page.goto(baseUrl, { waitUntil: 'load' })
}, 120_000)
afterAll(async () => {
await browser?.close()
if (child !== undefined && child.exitCode === null) {
const gone = new Promise<void>((resolveExit) => child.once('exit', () => resolveExit()))
child.kill('SIGTERM')
await Promise.race([gone, new Promise((r) => setTimeout(r, 10_000).unref())])
if (child.exitCode === null) child.kill('SIGKILL')
}
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
})
it('1 cold start: loading page settles into the three-column frame', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
const template = await page.locator('[class*="frame"]').evaluate((el) => getComputedStyle(el).gridTemplateColumns)
expect(template.split(' ').length).toBe(3)
await screen(page, '01-cold-start')
})
it('2+3 empty-state first send completes a real model round', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
await screen(page, '02-empty-state')
await input.fill('请简单介绍事件溯源,两句话即可,最后以「介绍完毕」结尾')
await input.press('Enter')
// startSession chain: session mounts, composer moves to the bottom.
// Regression pin (P0, 585671106): this send used to white-screen the tree
// (scope tag lost to a duplicate inlined runtime instance) — body going
// near-empty here means that class of bug is back.
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([])
await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 120_000 })
await screen(page, '04-round-complete')
}, 150_000)
it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
await page.locator('button', { hasText: /Trajectory/i }).first().click()
await screen(page, '05-trajectory-tab')
await page.locator('button', { hasText: /Waterfall/i }).first().click()
await screen(page, '06-waterfall-tab')
await page.locator('button', { hasText: /^Chat$/i }).first().click()
await screen(page, '07-back-to-chat')
})
it('5 bash differential rendering: tool row click opens the details column', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
const input = page.locator('textarea').first()
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
await input.press('Enter')
// Wait for the tool ROW, not response text (the reply echoes any marker).
// bash renders through the third-party sample registration (data-sample) —
// that IS the differential-rendering acceptance; the generic path renders
// data-variant rows with the handler on the data-clickable inner row.
const toolRow = page.locator('[data-sample], [data-variant] [data-clickable]').first()
await toolRow.waitFor({ timeout: 120_000 })
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)
await toolRow.click()
// Selection channel: click writes selection + layout.openDetails.
await page.waitForFunction(() => {
const frame = document.querySelector('[class*="frame"]')
if (frame === null) return false
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
}, undefined, { timeout: 10_000 })
await screen(page, '09-details-open')
}, 150_000)
it('6 sidebar drag widens the column and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
const before = await firstTrack(page)
const handle = page.locator('[class*="handle"]').first()
const box = await handle.boundingBox()
expect(box).not.toBeNull()
await page.mouse.move(box!.x + box!.width / 2, box!.y + 300)
await page.mouse.down()
await page.mouse.move(box!.x + 70, box!.y + 300, { steps: 6 })
await page.mouse.up()
const after = await firstTrack(page)
expect(after).not.toBe(before)
await screen(page, '10-sidebar-dragged')
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await firstTrack(page)).toBe(after)
})
it('7 dark mode: the body attribute cascades the token sheets', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
// in P-I, so the acceptance drives the documented mechanism directly.
const dark = await page.evaluate(() => {
document.body.setAttribute('data-ds-dark-theme', '')
return getComputedStyle(document.body).backgroundColor
})
await screen(page, '11-dark-mode')
const light = await page.evaluate(() => {
document.body.removeAttribute('data-ds-dark-theme')
return getComputedStyle(document.body).backgroundColor
})
expect(dark).not.toBe(light)
})
it('8 reload recovery: history replays after a fresh boot', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 30_000 })
await screen(page, '12-reload-recovery')
})
it('stayed clean: no page errors across every flow', () => {
expect(pageErrors).toEqual([])
})
})
+47
View File
@@ -0,0 +1,47 @@
// Shared plumbing for the web smoke tests (dist location, free port, failure shots).
import { existsSync, mkdirSync } from 'node:fs'
import { createServer } from 'node:net'
import { fileURLToPath } from 'node:url'
import type { Page } from 'playwright'
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
export function requireDist(): void {
if (!existsSync(DIST_INDEX)) {
throw new Error('web app dist not built — run `pnpm --filter @deepseek-ai/dsh-frontend build` (pnpm run test:web does this first)')
}
}
/**
* OS-assigned free port, released before use. startWebServer echoes
* options.port instead of the bound one, so passing 0 directly is unusable.
*/
export function probeFreePort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const probe = createServer()
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
if (address === null || typeof address === 'string') {
probe.close(() => reject(new Error('port probe returned no address')))
return
}
probe.close(() => resolvePort(address.port))
})
})
}
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
export async function saveFailureShot(page: Page, name: string): Promise<void> {
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
mkdirSync(dir, { recursive: true })
try {
await page.screenshot({ path: `${dir}/${name}.png`, fullPage: true })
} catch {
// Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": ["node"]
},
"include": [
"src",
"tests"
],
"references": [
{ "path": "../../packages/client/web" },
{ "path": "../../packages/host/webserver" }
]
}
+26
View File
@@ -0,0 +1,26 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
// Only the shell's static surface is aliased — UI plugin packages are NOT
// bundled here; they arrive as dynamic bundles through the client loader.
// Order matters — subpath aliases must win over bare-name prefixes.
alias: [
{ find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') },
{ find: /^@deepseek-ai\/dsh-client-web-react\/store$/, replacement: src('../../packages/client/web-react/src/store/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-runtime\/loader$/, replacement: src('../../packages/client/runtime/src/client/loader/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-runtime$/, replacement: src('../../packages/client/runtime/src/index.ts') },
],
},
})
+16 -1
View File
@@ -1586,7 +1586,7 @@ export interface Config {
export type ApprovalPolicy = 'ask' | 'never'
```
Source: [`packages/ui/user-approval/src/index.ts:214`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts)
## `@deepseek-ai/dsh-web`
@@ -1752,6 +1752,14 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont
These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-client-connection` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts))
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
- `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
@@ -1788,8 +1796,15 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
- `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
- `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
- `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
- `@deepseek-ai/dsh-host-apiproxy` ([`packages/host/apiproxy/src/index.ts`](../packages/host/apiproxy/src/index.ts))
- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts))
- `@deepseek-ai/dsh-host-webserver` ([`packages/host/webserver/src/index.ts`](../packages/host/webserver/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))
+1 -1
View File
@@ -417,7 +417,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [ApprovalService](../core-data-structures/approval.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:30`](../../packages/ui/user-approval/src/index.ts)
## `commands/*`
+2 -2
View File
@@ -246,7 +246,7 @@ async request(req: ApprovalRequest): Promise<ApprovalOutcome>
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
Source: [`packages/ui/user-approval/src/index.ts:229`](../../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts)
## `ctx.bash` — `BashExecutor` (abstract seam)
@@ -1509,7 +1509,7 @@ async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
Types: [AskUserQuestionAnswer](../core-data-structures/user-interaction.md) · [AskUserQuestionRequest](../core-data-structures/user-interaction.md) · [UserInteractionProvider](../core-data-structures/user-interaction.md)
Source: [`packages/ui/user-interaction/src/index.ts:84`](../../packages/ui/user-interaction/src/index.ts)
Source: [`packages/ui/user-interaction/src/index.ts:50`](../../packages/ui/user-interaction/src/index.ts)
## `ctx.web` — `WebService`
+6
View File
@@ -235,6 +235,12 @@ interface GenerateOptions {
* it; replay uses it to keep concurrent parent and child cursors independent.
*/
sessionId?: Branded<'SessionId'>
/**
* Provider-neutral classification for an auxiliary model call. Adapters may
* map the purpose to model-hidden transport metadata. Ordinary conversation
* requests leave it unset.
*/
purpose?: 'compaction'
}
```
+9 -7
View File
@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:201`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
@@ -20,20 +20,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -57,7 +57,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event string | Dispatchers | Listeners |
| --- | --- | --- |
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `slots/changed` | `runtime` (`emit`) | - |
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
+49
View File
@@ -129,6 +129,20 @@ flowchart TD
pkg_user_approval["user-approval"]
pkg_user_interaction["user-interaction"]
end
subgraph group_client["packages/client"]
pkg_client_connection["client-connection"]
pkg_client_i18n["client-i18n"]
pkg_client_runtime["client-runtime"]
pkg_client_ui_conversation["client-ui-conversation"]
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_primitives["client-ui-primitives"]
pkg_client_ui_sidebar["client-ui-sidebar"]
pkg_client_ui_slots["client-ui-slots"]
pkg_client_ui_theme["client-ui-theme"]
pkg_client_ui_trajectory["client-ui-trajectory"]
pkg_client_web["client-web"]
pkg_client_web_react["client-web-react"]
end
subgraph group_code_runtime["packages/code-runtime"]
pkg_code_runtime["code-runtime"]
pkg_code_runtime_worker["code-runtime-worker"]
@@ -147,6 +161,11 @@ flowchart TD
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
end
subgraph group_host["packages/host"]
pkg_host_apiproxy["host-apiproxy"]
pkg_host_runtime["host-runtime"]
pkg_host_webserver["host-webserver"]
end
subgraph group_lsp["packages/lsp"]
pkg_lsp["lsp"]
pkg_lsp_local["lsp-local"]
@@ -185,8 +204,23 @@ flowchart TD
pkg_acp_snapshot --> pkg_invariants
pkg_loader_smoke --> pkg_invariants
pkg_app_boot --> pkg_invariants
pkg_client_connection --> pkg_invariants
pkg_client_i18n --> pkg_invariants
pkg_client_runtime --> pkg_invariants
pkg_client_ui_conversation --> pkg_invariants
pkg_client_ui_layout --> pkg_invariants
pkg_client_ui_primitives --> pkg_invariants
pkg_client_ui_sidebar --> pkg_invariants
pkg_client_ui_slots --> pkg_invariants
pkg_client_ui_theme --> pkg_invariants
pkg_client_ui_trajectory --> pkg_invariants
pkg_client_web --> pkg_invariants
pkg_client_web_react --> pkg_invariants
pkg_code_runtime --> pkg_invariants
pkg_jsonrpc_demo --> pkg_invariants
pkg_host_apiproxy --> pkg_invariants
pkg_host_runtime --> pkg_invariants
pkg_host_webserver --> pkg_invariants
pkg_llm --> pkg_brand
pkg_llm --> pkg_invariants
pkg_code_runtime_worker --> pkg_code_runtime
@@ -677,8 +711,23 @@ flowchart TD
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) |
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants) |
| [`client-connection`](../packages/client/connection) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
+3 -3
View File
@@ -106,7 +106,7 @@ Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src
Types: [CallId](core-data-structures/core.md)
Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approval/src/index.ts)
#### `approval/decided` — log-only
@@ -122,7 +122,7 @@ Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approv
}
```
Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approval/src/index.ts)
#### `approval/policy` — log-only
@@ -138,7 +138,7 @@ Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approv
'approval/policy': { policy: ApprovalPolicy }
```
Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts)
### `assistant/*`
+107
View File
@@ -0,0 +1,107 @@
# Web GUI 样式规范
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写);组件对账基准=`missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md`。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace——这些已收编进 architecture.md §15。
> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。
## 1. 设计 token 表(权威定义)
所有 token 住 `packages/client/web-ui/src/style/global.css``:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。
### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名)
| token | 亮色实值 | 暗色(占位) | 用途 |
| --- | --- | --- | --- |
| `--bg-base` | `#ffffff` | `#151517` | 页面底 |
| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 |
| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 |
| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 |
| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 |
| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 |
| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) |
| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 |
| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 |
| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 |
| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) |
| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) |
| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 |
| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 |
| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 |
| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 |
| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 |
| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) |
| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) |
| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) |
### 1.2 非颜色
| token | 值 | 说明 |
| --- | --- | --- |
| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 |
| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) |
| `--fw-strong` | `600` | 粗体统一权重 |
| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 |
| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 |
| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` |
| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3 |
| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) |
| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) |
字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。
## 2. 视觉基线(源自 deepseekchat
- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`
- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14pxhover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。
- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。
- 会话列:`max-width: 840px` 居中,<1024px 降 712px。
- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`**助手侧纯文档流无底色**。
- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。
- 输入卡片:与会话列同宽(840px<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea16px/24pxmin 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。
- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。
- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。
- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE):
| 符号 | 象限 | 徽章配色 |
| --- | --- | --- |
| `↑` | client-requestunary 出站) | `--accent` / `--accent-soft` |
| `↓` | server-responseunary 回包) | ok `--ok`/`--ok-soft`error `--error`/`--error-soft` |
| `⇟` | server-requestSSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`host `--color-frame-host`/`--frame-host-soft` |
| `⇞` | client-responseSSE 侧回应) | `--accent`/`--accent-soft` 降透明度 |
## 3. 样式编码规范(review 对照打勾)
1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。
2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。
3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`
4. 对外组件必须透传 `className` 并合入根元素。
5. 禁用 `composes`;复用靠 token 与组件抽取。
6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。
7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`
8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。
9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`
10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。
11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。
12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。
## 4. 文件组织
- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。
- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。
- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`
- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。
## 5. 演进规则与偏离记录
- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。
- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。
- **暗色表补全验收**`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。
| 日期 | 偏离项 | 理由 |
| --- | --- | --- |
| (空) | | |
## 6. 相关文档
- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录)
- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)
+16
View File
@@ -18,6 +18,8 @@ export default tseslint.config(
'**/*.js',
'**/*.mjs',
'*.config.ts', // root tool configs (vitest, tsdown) — no project service
'**/tsdown.config.ts', // package build configs — in no tsconfig program, and TS syntax breaks the parserless fallback
'packages/client/tsdown.client.ts', // shared client build preset, same standing
],
},
@@ -108,6 +110,20 @@ export default tseslint.config(
},
},
// --- client tests: the root program excludes packages/client (host/client
// Context merges collide), so the shared project service cannot resolve
// them — parse these through the client aggregate explicitly.
{
files: ['packages/client/*/tests/**/*.ts', 'scripts/client-bundle-purity.spec.ts'],
languageOptions: {
parserOptions: {
projectService: false,
project: ['./tsconfig.client.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
// --- file-local duplication (all owned TypeScript) ---------------------
{
files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
+449 -87
View File
@@ -1,11 +1,29 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"exclude": ["duplicates"],
"ignoreBinaries": ["bwrap", "python3", "sandbox-exec"],
"ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"],
"exclude": [
"duplicates"
],
"ignoreBinaries": [
"bwrap",
"python3",
"sandbox-exec"
],
"ignoreWorkspaces": [
"vendor/*",
"python/sdk-runtime"
],
"ignoreDependencies": [
"lightningcss"
],
"workspaces": {
".": {
"project": ["scripts/**/*.ts"]
"entry": [
"scripts/**/*.mjs"
],
"project": [
"scripts/**/*.ts",
"scripts/**/*.mjs"
]
},
"examples": {
"entry": [
@@ -20,11 +38,93 @@
"*/tests/**/*.e2e.ts",
"*/tests/**/*.snapshot.ts"
],
"project": ["**/*.ts"],
"ignoreDependencies": ["@deepseek-ai/.+", "@cordisjs/.+"]
"project": [
"**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/.+",
"@cordisjs/.+"
]
},
"packages/util/home": {
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/host/webserver": {
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/host/runtime": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-client-.+"
]
},
"packages/client/web-ui": {
"entry": [
"tests/**/*.spec.{ts,tsx}"
],
"project": [
"src/**/*.{ts,tsx}",
"tests/**/*.{ts,tsx}"
]
},
"packages/client/runtime": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/client/ui-primitives": {
"entry": [
"tests/**/*.spec.tsx"
],
"project": [
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.tsx"
]
},
"packages/client/ui-layout": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.spec.tsx"
],
"project": [
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-client-ui-slots"
]
},
"website": {
"project": ["**/*.ts"],
"project": [
"**/*.ts"
],
"ignoreDependencies": [
"@braintree/sanitize-url",
"cytoscape",
@@ -34,162 +134,424 @@
]
},
"packages/*/*": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/bash/bash-sandbox": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/context/time-context": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/lsp/lsp-local": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["typescript-language-server"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts",
"tests/fixture-server.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"typescript-language-server"
]
},
"packages/sandbox/sandbox-local": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/util/brand": {
"project": ["src/**/*.ts"]
"project": [
"src/**/*.ts"
]
},
"packages/util/timeout": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/util/retention": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/support/acp-snapshot": {
"entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/fixtures/fake-acp-agent.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/support/loader-smoke": {
"entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/fixtures/*.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/core/agent-loop": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/goal/goal": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/goal/goal-session": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/goal/tool-goal": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/code-runtime/code-runtime-worker": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/llm/llm-deepseek": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/llm/llm-pi-ai": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/session-title/session-title-first-message-llm": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/context/workspace-context": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/util/paths": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/web/web-search-exa": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/web/web-search-perplexity": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/workflow/workflow-workerthread": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/web/web-search-deepseek": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/examples/acp-demo": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/ui/jsonrpc": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/ui/commands": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/examples/tui-demo": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/examples/cli-demo": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/ui/tui": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.snapshot.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.snapshot.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/examples/jsonrpc-demo": {
"project": ["src/**/*.ts"]
"project": [
"src/**/*.ts"
]
},
"packages/sdk/create-sdk": {
"entry": ["src/bin.ts", "tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/**/*.snapshot.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"src/bin.ts",
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts",
"tests/**/*.snapshot.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/sdk/scripts": {
"entry": ["src/bin.ts", "tests/**/*.spec.ts", "tests/**/*.snapshot.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["node-addon-require-builtin"]
"entry": [
"src/bin.ts",
"tests/**/*.spec.ts",
"tests/**/*.snapshot.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"node-addon-require-builtin"
]
},
"packages/subagent/subagent-spawn": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/subagent/subagent-acp": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts",
"tests/mock-acp-server.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/subagent/subagent-subprocess": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/fs/tool-fs": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/fs/tool-fs-search": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreBinaries": ["rg"]
"entry": [
"tests/**/*.spec.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreBinaries": [
"rg"
]
},
"packages/mcp/mcp-client": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"]
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts",
"tests/fixture-server.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"@modelcontextprotocol/server-everything",
"@modelcontextprotocol/server-filesystem"
]
},
"packages/client/web": {
"project": [
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.tsx"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-client-ui-theme",
"@deepseek-ai/dsh-client-connection"
]
},
"apps/web": {
"entry": [
"tests/**/*.e2e.ts",
"tests/support.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-web-react",
"@types/react",
"@types/react-dom",
"react",
"react-dom"
]
}
}
}
+10 -1
View File
@@ -10,12 +10,14 @@
"workspaces": [
"vendor/*",
"packages/*/*",
"apps/*",
"website"
],
"scripts": {
"build": "tsc -b tsconfig.build.json && tsdown",
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
"clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo",
"typecheck": "tsc -b tsconfig.json",
"typecheck": "tsc -b tsconfig.json tsconfig.client.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"duplication": "jscpd --config .jscpd.json packages scripts",
@@ -25,6 +27,8 @@
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
"test:web": "npm run build:web && vitest run --config vitest.web.config.ts",
"test:gui": "vitest run packages/client packages/host",
"check:ci": "tsx scripts/run-gates.ts ci-primary",
"check:ci:static": "tsx scripts/run-gates.ts ci-static",
"check:ci:lint": "tsx scripts/run-gates.ts ci-lint",
@@ -59,6 +63,7 @@
"verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
"verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts",
"gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
"verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
"gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
@@ -84,11 +89,14 @@
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:cordis": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml",
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
"demo:web": "npm run build:web && node --import tsx apps/cli/src/bin.ts web",
"postinstall": "node scripts/install-lefthook.mjs"
},
"devDependencies": {
"@agentclientprotocol/sdk": "0.25.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/js-yaml": "^4.0.9",
"@types/jsdom": "^28.0.3",
"@types/mdast": "^4.0.4",
@@ -102,6 +110,7 @@
"jsdom": "29.1.1",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"lightningcss": "^1.32.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mermaid": "11.16.0",
+71
View File
@@ -0,0 +1,71 @@
# AGENTS.md — Web client stack
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
## Layering red lines
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
1. **Data object layer** (`web-runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
Non-negotiables across the layers:
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (`web-ui/src`)
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
Two-level feature directories, one contributor per directory — physical conflict avoidance:
```
web-ui/src/
shell/ # AppShell + the three slot registries + builtins
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
components/ # shared leaves (MessageText, JsonBlock, …)
hooks/ utils/ style/ # cross-cutting; not feature-owned
```
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
## Styling
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
## Testing and coverage
The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
## Before you push: the local check ladder
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
## New component checklist
1. Claim the slot (see the directory regime above): one feature, one directory.
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.
+16
View File
@@ -0,0 +1,16 @@
# @deepseek-ai/dsh-client-connection
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-client-connection",
"description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,47 @@
// Central contract re-export point: every contract import inside
// web-runtime goes through this single file.
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
// The ./api and ./client subpath exports are the browser-safe channels added for this.
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/**
* Unwrap a unary response: RpcResponse<T> -> RpcResult<T> (business code only
* cares about the result slot).
* @param response - the unary response.
* @returns its result slot.
*/
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
return response.result
}
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code).
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}
@@ -0,0 +1,190 @@
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
export interface ConnectionConfig {
/** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
backoffBaseMs?: number
/** Exponential growth factor per consecutive failed attempt. */
backoffFactor?: number
/** Upper bound for the backoff cap in ms. */
backoffMaxMs?: number
/** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
* (audit C2) waits for mux+host stream establishment plus describe; a carrier that never
* fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
* generation proceeds as connected and the live-gap repair path (audit S3) covers stragglers. */
streamOpenTimeoutMs?: number
}
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
backoffBaseMs: 500,
backoffFactor: 2,
backoffMaxMs: 10_000,
streamOpenTimeoutMs: 3_000,
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(done, ms)
signal.addEventListener('abort', done, { once: true })
function done(): void {
clearTimeout(t)
signal.removeEventListener('abort', done)
resolve()
}
})
}
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
export type ConnectionState = 'connected' | 'reconnecting'
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
* SessionManager. */
export interface ConnectionSinks {
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
onConnected?: () => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
onStateChange?: (state: ConnectionState) => void
}
/**
* Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap
* never fires unless someone for-awaits), reconnecting with exponential backoff on loss.
* State (generation/attempt) is instance-private, never in the store.
* The pump body feeds each frame to a sink (sink exceptions must
* not kill the pump — a broken business layer must not drag down the connection layer).
*/
export class ConnectionController {
private generation = 0
private attempt = 0
private current: AbortController | null = null
private running = false
private lastState: ConnectionState | null = null
private readonly config: Required<ConnectionConfig>
constructor(
private readonly api: IApiClient,
private readonly sinks: ConnectionSinks = {},
config: ConnectionConfig = {},
) {
this.config = { ...CONNECTION_DEFAULTS, ...config }
}
/** Idempotent: begin the connect/pump/reconnect loop. */
start(): void {
if (this.running) return
this.running = true
void this.loop()
}
/** Stop the loop and abort the current generation's streams. */
stop(): void {
this.running = false
this.current?.abort()
this.current = null
}
private backoffDelay(attempt: number): number {
const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config
const cap = Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1))
return cap / 2 + Math.random() * (cap / 2)
}
/** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
private isRunning(): boolean {
return this.running
}
private async loop(): Promise<void> {
while (this.running) {
const gen = ++this.generation
const ac = new AbortController()
this.current = ac
/* v8 ignore next -- initializer placeholder: the Promise executor
* below runs synchronously and replaces it before anyone can call it. */
let muxOpened = (): void => {}
/* v8 ignore next -- same placeholder pattern as muxOpened. */
let hostOpened = (): void => {}
const streamsOpen = Promise.all([
new Promise<void>((resolve) => { muxOpened = resolve }),
new Promise<void>((resolve) => { hostOpened = resolve }),
])
const failed = new Promise<void>((resolve) => {
const settle = (): void => {
if (gen === this.generation && !ac.signal.aborted) ac.abort()
resolve()
}
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
})
try {
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
// proves each SSE transport is established (response headers in, before any frame) —
// only then may onConnected fire, so the resync it triggers cannot outrun the
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
// (see ConnectionConfig.streamOpenTimeoutMs).
const timeout = new AbortController()
await Promise.all([
this.api.host.describe({}),
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
])
timeout.abort()
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')
this.callSink(this.sinks.onConnected)
} catch {
// Transport failure: treat as generation failure, fall through to the shared backoff.
if (!ac.signal.aborted) ac.abort()
}
await failed
if (!this.isRunning()) return
this.emitState('reconnecting')
this.attempt += 1
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
const idle = new AbortController()
await sleep(this.backoffDelay(this.attempt), idle.signal)
}
}
/** Deduplicated state emission (sink isolation applies). */
private emitState(state: ConnectionState): void {
if (this.lastState === state) return
this.lastState = state
this.callSink(() => this.sinks.onStateChange?.(state))
}
private async pumpStream<F extends { type: string }>(
stream: AsyncIterable<RpcRequest<F>>,
sink: ((envelope: RpcRequest<F>) => void) | undefined,
onEnd: () => void,
): Promise<void> {
try {
for await (const envelope of stream) {
if (envelope.payload.type === 'stream/error') break
if (sink !== undefined) this.callSink(() => { sink(envelope) })
}
} catch {
// Stream loss: converge on onEnd, which triggers the shared reconnect.
}
onEnd()
}
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
private callSink(fn: (() => void) | undefined): void {
if (fn === undefined) return
try {
fn()
} catch (error) {
console.error('[web-runtime] connection sink threw:', error)
}
}
}
@@ -0,0 +1,560 @@
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(crypto.randomUUID()), payload }
}
function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
function sid(id: string): SessionId {
return id as SessionId
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / steering / context. */
function buildAlphaLog(): SessionEvent[] {
const events: Record<string, unknown>[] = []
let time = Date.now() - 3_600_000
const push = (e: Record<string, unknown>): number => {
const seq = events.length
events.push({ seq, time: (time += 800), ...e })
return seq
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2
const withReasoning = turn % 3 === 1
const blocks: ContentBlock[] = []
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'step/start', data: { turn, step: 1 } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化(turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'step/end', data: { turn, step: 1 } })
} else {
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'step/end', data: { turn, step: 0 } })
}
if (turn % 13 === 6) {
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, content: text(`插话 ${turn}fixture steering 消息。`), source: { kind: 'user' } } })
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}${name} 样本。`), source: { kind: 'user' } } })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
type: 'assistant/message', surfaceOp: 'append',
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
return events as unknown as SessionEvent[]
}
/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
let args: Record<string, unknown>
try {
args = JSON.parse(argsRaw) as Record<string, unknown>
} catch {
/* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
return undefined
}
switch (name) {
case 'fx-bash':
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
case 'fx-write':
return {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'fx-note':
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
}
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
switch (call.card) {
case 'terminal':
return { card: 'terminal', output: resultText, exitCode: 0 }
case 'diff':
return { card: 'diff', diffs: call.diffs }
case 'generic':
return { card: 'generic', content: text(resultText) }
}
}
/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined {
if (event.type === 'tool/call') {
const view = presentCall(event.data.name, event.data.arguments)
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const callId = String(event.data.callId)
for (let i = log.length - 1; i >= 0; i--) {
const candidate = log[i]
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
so the undefined arm needs a sparse log no code path builds. */
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
return view === undefined ? undefined : { for: 'result', view }
}
}
return undefined // cross-page unpaired: documented default
}
return undefined
}
/**
* Message-boundary paging (mirrors the host's paging contract): count
* maxMessages messages
* backwards from end, cut at a turn/start boundary.
Entries carry pagination-time views
* (the host analogue computes viewFor per entry at page time). */
function pageOf(
log: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number,
): { events: HistoryEntry[]; hasMore: boolean } {
const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length))
let start = 0
let messages = 0
for (let i = end - 1; i >= 0; i--) {
const event = log[i]
/* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */
if (event === undefined) break
if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++
if (event.type === 'turn/start' && messages >= maxMessages) {
start = i
break
}
}
const events = log.slice(start, end).map((event): HistoryEntry => {
const view = viewFor(event, log)
return view === undefined ? { event } : { event, view }
})
return { events, hasMore: start > 0 }
}
interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
* client's signal (timing hook: simulated connection loss). */
class FxInbox<F> implements StreamConn<F> {
private readonly inbox: RpcRequest<F>[] = []
private wake: (() => void) | null = null
private broken = false
push(envelope: RpcRequest<F>): void {
this.inbox.push(envelope)
this.wake?.()
}
breakNow(): void {
this.broken = true
this.wake?.()
}
/** Read through a method: breakNow()/abort flip state across yields, so narrowing from the loop condition must not stick. */
private isLive(signal: AbortSignal): boolean {
return !signal.aborted && !this.broken
}
async *drain(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
const onAbort = (): void => this.wake?.()
signal.addEventListener('abort', onAbort)
try {
while (this.isLive(signal)) {
while (this.inbox.length > 0) yield this.inbox.shift() as RpcRequest<F>
if (!this.isLive(signal)) break
await new Promise<void>((resolve) => {
this.wake = resolve
})
this.wake = null
}
} finally {
signal.removeEventListener('abort', onAbort)
}
}
}
/**
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
*/
export function createFixtureApi(): ApiProxy {
const sessions: SessionSummary[] = [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const muxConns = new Set<StreamConn<MuxFrame>>()
const hostConns = new Set<StreamConn<HostFrame>>()
const emitMux = (frame: MuxFrame): void => {
for (const conn of muxConns) conn.push({ rpcId: mint(), payload: frame })
}
const emitHost = (frame: HostFrame): void => {
for (const conn of hostConns) conn.push({ rpcId: mint(), payload: frame })
}
/** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */
function ok<P, T>(request: RpcRequest<P>, value: T): Promise<RpcResponse<T>> {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
}
function err<P, T>(request: RpcRequest<P>, error: Extract<RpcResult<T>, { ok: false }>['error']): Promise<RpcResponse<T>> {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } })
}
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
const setRunning = (id: SessionId, running: boolean): void => {
const summary = summaryOf(id)
if (summary === undefined || summary.running === running) return
summary.running = running
emitHost({ type: 'host/session-status', sessionId: id, running })
}
const logOf = (id: SessionId): SessionEvent[] => {
let log = logs.get(id)
if (log === undefined) {
log = []
logs.set(id, log)
}
return log
}
const append = (id: SessionId, e: Record<string, unknown>): void => {
const log = logOf(id)
const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
log.push(event)
// Emission-time view derivation (mirrors the host's live path).
const view = viewFor(event, log)
/* v8 ignore next 3 -- the view-present arm needs a live tool/call emission,
but the fixture replay produces text-only turns; view vocabulary is
exercised through the history samples (turns 60-62). */
emitMux(view === undefined
? { type: 'session/event', sessionId: id, event }
: { type: 'session/event', sessionId: id, event, view })
}
/** At most one in-flight replay per session; cancel clears it. */
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
/** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
let historyDelayMs = 0
/** One-shot history failure (timing hook: the doomed in-flight request of the S4 reconnect scenario). */
let failNextHistory = false
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
const streamBreakers = new Set<() => void>()
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
// browser acceptance runs create slow-history, lost-frame, and reconnect
// windows a real host produces naturally.
const timingHooks = {
setHistoryDelay(ms: number): void {
historyDelayMs = ms
},
/** Fail the NEXT history call (after its transit delay) with a transport-level throw. */
failNextHistory(): void {
failNextHistory = true
},
/** Log append + mux emit (the normal live path). */
appendUser(id: string, msg: string): void {
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
},
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
},
/** End every open stream generator (client sees both streams close -> reconnect + resync path). */
breakStreams(): void {
for (const breakNow of [...streamBreakers]) breakNow()
},
}
;(globalThis as Record<string, unknown>).__fxTiming = timingHooks
/** Prompt replay: chunk typewriter (80ms/frame) -> assistant/message finalize -> turn/end + running flip. */
const startReply = (id: SessionId, turn: number, replyText: string): void => {
const step = 0
append(id, { type: 'step/start', data: { turn, step } })
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
let i = 0
const finish = (aborted: boolean): void => {
replays.delete(id)
const done = pieces.slice(0, i).join('')
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
append(id, { type: 'step/end', data: { turn, step } })
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
setRunning(id, false)
}
const tick = (): void => {
const piece = pieces[i]
if (piece === undefined) {
finish(false)
return
}
i++
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index: 0, text: piece } } })
replays.set(id, { timer: setTimeout(tick, 80), finish })
}
replays.set(id, { timer: setTimeout(tick, 80), finish })
}
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
create: (request) => {
const created: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
}
sessions.push(created)
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
return ok(request, { sessionId: created.sessionId })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
const doomed = failNextHistory
failNextHistory = false
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, page)
},
prompt: (request) => {
const { sessionId: id, mode, content } = request.payload
const summary = summaryOf(id)
if (summary === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
summary.updatedAt = Date.now()
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (mode === 'steer' && replays.has(id)) {
// Steering: insert a steering message into the current turn; the replay continues.
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
const turn = (nextTurn.get(id) ?? 1) - 1
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
nextTurn.set(id, turn + 1)
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
return ok(request, { accepted: true as const })
},
cancel: (request) => {
const replay = replays.get(request.payload.sessionId)
if (replay !== undefined) {
clearTimeout(replay.timer)
replay.finish(true)
} else {
setRunning(request.payload.sessionId, false)
}
return ok(request, { accepted: true as const })
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
},
events: {
async *mux(_request, signal) {
const conn = new FxInbox<MuxFrame>()
muxConns.add(conn)
const breakNow = (): void => { conn.breakNow() }
streamBreakers.add(breakNow)
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
for (const s of sessions) {
if (!s.running) continue
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
}
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
try {
yield* conn.drain(signal)
} finally {
streamBreakers.delete(breakNow)
muxConns.delete(conn)
}
},
async *host(_request, signal) {
const conn = new FxInbox<HostFrame>()
hostConns.add(conn)
const breakNow = (): void => { conn.breakNow() }
streamBreakers.add(breakNow)
// Periodic material (the RPC-panel acceptance's clear-then-new-frames step depends on it): flip fx-gamma every 5s.
// fx-gamma only: never touch fx-alpha's running semantics (the conversation replay drives that).
const timer = setInterval(() => {
const gamma = summaryOf(sid('fx-gamma'))
/* v8 ignore next -- the undefined arm needs fx-gamma deleted, but the fixture never removes sessions. */
if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running)
}, 5000)
try {
yield* conn.drain(signal)
} finally {
clearInterval(timer)
streamBreakers.delete(breakNow)
hostConns.delete(conn)
}
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
void message
return Promise.resolve({ accepted: false, reason: 'not-pending' })
},
}
}
/**
* Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
* overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch
* straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four
* named full forms, and feeding the same tap as a real carrier. Delete when the fixture moves
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
*/
export class FixtureApiClient extends AbstractApiClient {
private readonly api = createFixtureApi()
protected doFetch(): Promise<Response> {
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
}
protected override async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
): Promise<RpcResponse<ResponseValue<K>>> {
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
return response
}
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
switch (method) {
case 'session.list': return this.api.sessions.list(request)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
}
}
protected override openMux(
payload: { since?: Record<SessionId, number> },
signal: AbortSignal,
onOpen?: () => void,
): AsyncIterable<RpcRequest<MuxFrame>> {
return this.tapStream(this.api.events.mux(rpcRequest(payload), signal), onOpen)
}
protected override openHost(
payload: Record<never, never>,
signal: AbortSignal,
onOpen?: () => void,
): AsyncIterable<RpcRequest<HostFrame>> {
return this.tapStream(this.api.events.host(rpcRequest(payload), signal), onOpen)
}
private async *tapStream<F extends MuxFrame | HostFrame>(
stream: AsyncIterable<RpcRequest<F>>,
onOpen?: () => void,
): AsyncGenerator<RpcRequest<F>> {
// No HTTP here: the in-memory stream is established the moment iteration starts (mirrors
// readSse firing onOpen after response headers, before any frame).
onOpen?.()
for await (const envelope of stream) {
const full: ServerRequest = { type: 'server-request', rpcId: envelope.rpcId, method: envelope.payload.type, payload: envelope.payload }
this.onEnvelope(full)
yield envelope
}
}
/**
* Deliver a client response to the in-memory contract impl (no HTTP POST),
* echoing the envelope to the observation tap like every other path.
* @param message - the client-response envelope answering a server request.
* @returns the carrier receipt from the fixture impl.
*/
override async respond(message: ClientResponse): Promise<RpcReceipt> {
this.onEnvelope(message)
return this.api.respond(message)
}
}
@@ -0,0 +1,76 @@
/**
* Browser half of the wire consumer layer (contract: api-contracts v3
* section 3; export inventory = v3 §3.2). The wire is this package's client
* half in its entirety — apply mounts ctx.connection: the shared api client
* plus the connection controller handle. Mode selection (?fixture) happens
* here so the rest of the client tree is mode-blind; the controller's sinks
* are wired by the runtime plugin (object layer), which injects this service.
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
} from './api.ts'
export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'
// ---- Connection loop ----
export { ConnectionController } from './connection.ts'
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
// ---- Platform client subclasses ----
export { WebApiClient } from './web-api-client.ts'
export { FixtureApiClient, createFixtureApi } from './fixture.ts'
/** Required services (none — this is the wire root). */
export const inject: string[] = []
/**
* The ctx.connection service surface: the api client plus a one-shot
* controller starter (the runtime plugin supplies sinks when its object layer
* is ready — connection stays consumer-agnostic).
*/
export interface ConnectionHandle {
/** Shared api client (fixture or real, decided at boot from the page URL). */
readonly api: IApiClient
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
* throws.
* @param sinks - frame/state callbacks.
* @param config - reconnect/backoff tunables.
* @returns stop handle for the loop.
*/
start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
}
/**
* Client plugin body: pick the api by page mode and provide ctx.connection.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
let started = false
const handle: ConnectionHandle = {
api,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true
const controller = new ConnectionController(api, sinks, config ?? {})
controller.start()
return { stop: () => { controller.stop() } }
},
}
ctx.provide('connection', handle)
}
@@ -0,0 +1,12 @@
// WebApiClient: the browser platform subclass — transport = global fetch over same-origin
// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the
// base batching aspect; subscribers attach via subscribeEnvelopes (see boot).
import { AbstractApiClient } from './api.ts'
/** Browser platform subclass: transport = global fetch over same-origin /api/*. */
export class WebApiClient extends AbstractApiClient {
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
return globalThis.fetch(input, init)
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
*/
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-connection`.
* @module @deepseek-ai/dsh-client-connection/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection'
/** Cordis companion plugin name. */
export const name = 'client-connection-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the pure wire layer emits no cordis events and owns no
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
* directly by its behavior specs, and rpcId round-trip discipline is owned by
* the apiproxy contract layer.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,21 @@
/**
* Contract-layer helpers: transport-error folding and response unwrapping.
* (The assistant block classifier half of the legacy spec lives in
* runtime/tests — the classifier moved there.)
*/
import { describe, expect, it } from 'vitest'
import { RpcId, resultOf, transportError } from '../src/client/api.ts'
describe('transportError', () => {
it('folds an Error to internal keeping the message, and stringifies non-Errors', () => {
expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'internal', message: '线断了', details: {} } })
expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } })
})
})
describe('resultOf', () => {
it('unwraps the result slot', () => {
expect(resultOf({ rpcId: RpcId('r'), result: { ok: true, value: 7 } })).toEqual({ ok: true, value: 7 })
})
})
@@ -0,0 +1,65 @@
/**
* Connection plugin browser-half apply: ctx.connection handle mounting, mode
* selection off the page URL, and the single-consumer stream-loop ownership.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
afterEach(() => {
delete (globalThis as Win).location
})
async function mount(): Promise<ConnectionHandle> {
const ctx = new Context()
await ctx.plugin({ apply, inject: [] })
const handle = ctx.get('connection') as ConnectionHandle | undefined
if (handle === undefined) throw new Error('ctx.connection not provided')
return handle
}
describe('connection client apply', () => {
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
;(globalThis as Win).location = { search: '' }
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
})
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
;(globalThis as Win).location = { search: '?fixture' }
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
delete (globalThis as Win).location
expect((await mount()).api).toBeInstanceOf(WebApiClient)
})
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the surface.
const loop = handle.start({})
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
loop.stop() // teardown must not throw; the fixture streams abort quietly
})
it('WebApiClient carries requests over globalThis.fetch', async () => {
;(globalThis as Win).location = { search: '' }
const handle = await mount()
const original = globalThis.fetch
const seen: string[] = []
globalThis.fetch = (input: URL | RequestInfo) => {
seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
return Promise.resolve(new Response('{}', { status: 200 }))
}
try {
// Schema rejection is fine — the transport hop is the assertion.
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
} finally {
globalThis.fetch = original
}
expect(seen.some(u => u.includes('/api/'))).toBe(true)
})
})
@@ -0,0 +1,238 @@
/**
* ConnectionController: stream pumping into sinks, the strict readiness
* handshake (describe + both streams' onOpen, timeout-guarded), generation
* abort on loss, backoff reconnection, state transitions, and sink-exception
* isolation. Real (short) timers — the timeout and backoff are configurable,
* so tests run them at millisecond scale.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import type { ConnectionState } from '../src/client/connection.ts'
import { ConnectionController } from '../src/client/connection.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
const SID = 'fk-c1' as SessionId
const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }
function subscribedFrame(lastSeq = 0) {
return { type: 'session/subscribed', sessionId: SID, lastSeq } as const
}
describe('connection lifecycle', () => {
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
let connected = 0
const controller = new ConnectionController(api, {
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
onConnected: () => { connected++ },
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
api.pushMux(subscribedFrame())
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
expect(api.callsOf('host.describe')).toHaveLength(1)
} finally {
controller.stop()
}
})
it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => {
const api = new FakeApiClient()
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
api.failStreams(new Error('stream torn'))
await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live
} finally {
controller.stop()
warnSpy.mockRestore()
}
// stop() aborts the live generation (streams tear down) and no reconnect follows.
await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
await new Promise(resolve => setTimeout(resolve, 40))
expect(api.openMuxCount).toBe(0)
})
it('treats describe failure as generation failure and retries', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
api.onDescribe = () => {
describeCalls++
return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
expect(connected).toBe(0) // never announced during the failed generation
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
onConnected: () => { connected++ },
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } })
await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect
expect(muxSeen).toEqual([]) // never forwarded to the business sink
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('isolates sink exceptions from the pump', async () => {
const api = new FakeApiClient()
const seen: string[] = []
let connected = 0
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onMuxEnvelope: (envelope) => {
seen.push(envelope.payload.type)
throw new Error('business layer bug')
},
onConnected: () => { connected++ },
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
api.pushMux(subscribedFrame(1))
api.pushMux(subscribedFrame(2))
await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped
expect(connected).toBe(1) // no reconnect triggered by the sink throw
} finally {
controller.stop()
errorSpy.mockRestore()
}
})
it('holds onConnected until both streams establish even after describe succeeds', async () => {
const api = new FakeApiClient()
api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand
let connected = 0
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
await new Promise(resolve => setTimeout(resolve, 30))
expect(connected).toBe(0) // describe alone must not announce
api.releaseStreamOpens()
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
}
})
it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
const api = new FakeApiClient()
api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
let connected = 0
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 })
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged
} finally {
controller.stop()
}
})
it('emits deduplicated connected/reconnecting state transitions', async () => {
const api = new FakeApiClient()
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['connected'])
api.failStreams(new Error('torn'))
await vi.waitFor(() => { expect(connected).toBe(2) })
expect(states).toEqual(['connected', 'reconnecting', 'connected'])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
api.onDescribe = () => {
describeCalls++
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
}
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('runs with no sinks at all (every callback slot optional)', async () => {
const api = new FakeApiClient()
const controller = new ConnectionController(api, {}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently
await new Promise(resolve => setTimeout(resolve, 20))
} finally {
controller.stop()
}
})
it('start() is idempotent (one loop, one stream set)', async () => {
const api = new FakeApiClient()
let connected = 0
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(api.openMuxCount).toBe(1)
expect(api.callsOf('host.describe')).toHaveLength(1)
} finally {
controller.stop()
}
})
})
@@ -0,0 +1,160 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let nextRpc = 0
export function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
}
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
interface StreamConn<F> {
feed(item: StreamItem<F>): void
}
export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false
/** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
* Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
holdStreamOpen = false
private heldOpens: (() => void)[] = []
releaseStreamOpens(): void {
const held = this.heldOpens
this.heldOpens = []
for (const fire of held) fire()
}
readonly events: IApiClient['events'] = {
mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
this.openStream(this.muxConns, signal, onOpen),
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
pushMux(frame: MuxFrame, rpcId?: string): void {
for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
pushHost(frame: HostFrame, rpcId?: string): void {
for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
endStreams(): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
}
failStreams(error: unknown): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
}
get openMuxCount(): number {
return this.muxConns.length
}
callsOf(method: string): unknown[] {
return this.calls.filter(c => c.method === method).map(c => c.payload)
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
const inbox: StreamItem<F>[] = []
let wake: (() => void) | null = null
const conn: StreamConn<F> = {
feed: (item) => {
inbox.push(item)
wake?.()
},
}
registry.push(conn)
if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
else if (!this.suppressStreamOpen) onOpen?.()
try {
while (!signal.aborted) {
while (inbox.length > 0) {
const item = inbox.shift() as StreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
yield item.envelope
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
registry.splice(registry.indexOf(conn), 1)
}
}
}
@@ -0,0 +1,337 @@
/**
* Fixture impl semantics: the demo data source must honor the same contract
* shapes as the real host (paging boundaries, rpcId echo, replay lifecycle,
* baseline replay, timing hooks) — this is the vitest-side drift detector for
* the hand-written fixture/host parallel implementations.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
const sid = (id: string): SessionId => id as SessionId
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload })
let reqCount = 0
interface TimingHooks {
setHistoryDelay(ms: number): void
failNextHistory(): void
appendUser(id: string, msg: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
const timing = (): TimingHooks => (globalThis as Record<string, unknown>).__fxTiming as TimingHooks
/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */
async function collect<F>(stream: AsyncIterable<RpcRequest<F>>, abort: AbortController, done: (frames: F[]) => boolean): Promise<F[]> {
const frames: F[] = []
for await (const envelope of stream) {
frames.push(envelope.payload)
if (done(frames) || frames.length > 500) {
abort.abort()
break
}
}
return frames
}
describe('createFixtureApi', () => {
it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => {
const api = createFixtureApi()
const request = req({})
const response = await api.sessions.list(request)
expect(response.rpcId).toBe(request.rpcId)
if (!response.result.ok) throw new Error('list failed')
expect(response.result.value.items.map(s => s.sessionId)).toEqual(['fx-alpha', 'fx-beta', 'fx-gamma'])
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
if (!tail.result.ok) throw new Error('history failed')
const tailPage = tail.result.value
expect(tailPage.hasMore).toBe(true)
expect(tailPage.events[0]?.event.type).toBe('turn/start') // cut lands on a turn boundary
const boundary = tailPage.events[0]?.event.seq ?? 0
expect(boundary).toBeGreaterThan(0)
const older = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: boundary, maxMessages: 10 }))
if (!older.result.ok) throw new Error('older failed')
const olderTail = older.result.value.events.at(-1)?.event
expect((olderTail?.seq ?? -1) + 1).toBe(boundary) // pages stitch with no hole/overlap
// Out-of-range beforeSeq clamps instead of exploding.
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
if (!clamped.result.ok) throw new Error('clamped failed')
expect(clamped.result.value.events).toEqual([])
// Unknown session: empty page, not an error (history of a bare id).
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
expect(empty.result.value).toEqual({ events: [], hasMore: false })
})
it('create adds a session and pushes host/session-added to open host streams', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
if (seen.length >= 1) abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
await consuming
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed')
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
})
it('prompt replays a full streamed turn and cancel mid-replay freezes with (已中断)', async () => {
const api = createFixtureApi()
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
const abort = new AbortController()
const frames: MuxFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.mux(req({}), abort.signal)) {
frames.push(envelope.payload)
const last = envelope.payload
if (last.type === 'session/event' && last.event.type === 'turn/end') {
abort.abort()
}
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
// Unknown session → session-not-found with the id echoed in details.
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
// Real prompt: replay starts (running flips true), cancel freezes it.
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
await api.sessions.cancel(req({ sessionId: id }))
await consuming
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types).toContain('turn/start')
expect(types).toContain('user/message')
expect(types).toContain('assistant/chunk')
expect(types).toContain('assistant/message')
expect(types.at(-1)).toBe('turn/end')
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
// Idle cancel: no replay in flight, must not explode; running flips false.
const idleCancel = await api.sessions.cancel(req({ sessionId: id }))
expect(idleCancel.result).toMatchObject({ ok: true })
})
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
const api = createFixtureApi()
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
const abort = new AbortController()
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
await new Promise(resolve => setTimeout(resolve, 10))
await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] }))
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types).toContain('steering/message')
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
})
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
const api = createFixtureApi()
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
const abort = new AbortController()
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 2) abort.abort()
}
return envelopes
}
const first = await openOnce()
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
// steer while idle + a non-text content block (covers the '' arm of the text join).
await api.sessions.prompt(req({
sessionId: created.result.value.sessionId, mode: 'steer' as const,
content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never],
}))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
})
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
vi.useFakeTimers()
try {
const api = createFixtureApi()
const abort = new AbortController()
const hostSeen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload)
})()
await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists)
expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true })
// A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm).
const mabort = new AbortController()
const baseline: MuxFrame[] = []
const muxConsuming = (async () => {
for await (const envelope of api.events.mux(req({}), mabort.signal)) {
baseline.push(envelope.payload)
if (baseline.length >= 3) mabort.abort()
}
})()
await vi.advanceTimersByTimeAsync(10)
mabort.abort()
await muxConsuming
expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 })
abort.abort()
await vi.advanceTimersByTimeAsync(10)
await consuming
} finally {
vi.useRealTimers()
}
})
it('respond is a typed stub: always not-pending', async () => {
const api = createFixtureApi()
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
})
it('describe answers the fixture identity', async () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
})
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
const api = createFixtureApi()
const hooks = timing()
// One-shot transport failure after transit delay.
hooks.setHistoryDelay(5)
hooks.failNextHistory()
await expect(api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))).rejects.toThrow(/simulated history transport failure/)
hooks.setHistoryDelay(0)
// The failure was one-shot: the next call succeeds.
const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
expect(ok.result.ok).toBe(true)
// appendUser emits on the mux stream; appendSilent only lands in the log (lost frame).
const abort = new AbortController()
const seen: MuxFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload)
})()
await new Promise(resolve => setTimeout(resolve, 10))
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
// But history serves the silent event (the client's repull finds it).
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
if (!repull.result.ok) throw new Error('repull failed')
expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧')
// breakStreams force-ends BOTH stream kinds without the client abort.
const habort = new AbortController()
const hostConsuming = (async () => {
for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ }
})()
await new Promise(resolve => setTimeout(resolve, 10))
hooks.breakStreams()
await consuming // returns because the stream broke, not because we aborted
await hostConsuming
expect(abort.signal.aborted).toBe(false)
expect(habort.signal.aborted).toBe(false)
})
})
describe('FixtureApiClient (protocol-level fake carrier)', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
const client = new FixtureApiClient()
// Protected at compile time only; reach it directly to pin the tripwire message.
expect(() => (client as unknown as { doFetch(): Promise<Response> }).doFetch()).toThrow(/doFetch must be unreachable/)
})
it('mints request ids, taps all four full forms, and never touches doFetch', async () => {
const client = new FixtureApiClient()
const tapped: RpcMessage[] = []
client.subscribeEnvelopes(batch => tapped.push(...batch))
const response = await client.sessions.list({})
expect(response.result.ok).toBe(true)
await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } })
await vi.waitFor(() => {
const kinds = tapped.map(m => m.type)
expect(kinds).toContain('client-request')
expect(kinds).toContain('server-response')
expect(kinds).toContain('client-response')
})
const request = tapped.find(m => m.type === 'client-request')
const reply = tapped.find(m => m.type === 'server-response')
expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier
})
it('covers the whole unary dispatch table', async () => {
const client = new FixtureApiClient()
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
})
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
const client = new FixtureApiClient()
const tapped: RpcMessage[] = []
client.subscribeEnvelopes(batch => tapped.push(...batch))
const order: string[] = []
const abort = new AbortController()
for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) {
order.push(envelope.payload.type)
abort.abort()
}
expect(order[0]).toBe('open')
expect(order[1]).toBe('session/subscribed')
await vi.waitFor(() => {
expect(tapped.some(m => m.type === 'server-request')).toBe(true)
})
// Host stream side of the pair (same tap path).
const habort = new AbortController()
const hostOrder: string[] = []
const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]()
const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))])
expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent
habort.abort()
if (raced === 'idle') await hostIterator.return?.(undefined)
})
})
@@ -0,0 +1,10 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
})
})
+42
View File
@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../util/brand"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../support/invariants"
}
],
"exclude": [
"**/*.legacy.*"
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js', 'lib/types/invariant.js'])
+16
View File
@@ -0,0 +1,16 @@
# @deepseek-ai/dsh-client-i18n
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
## Model Experience
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-client-i18n",
"description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-web-react": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}
+108
View File
@@ -0,0 +1,108 @@
/**
* i18n plugin, browser half: namespace x locale dictionary registry with a
* bound translate function whose reference is stable (safe for inject
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
* Contract: api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
declare module 'cordis' {
interface Context {
i18n: I18nService
}
}
/** Fallback locale consulted after the active locale misses. */
export const FALLBACK_LOCALE = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/**
* Dictionary registry plus locale switch. Lookup chain per key: active locale
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
* the UI rather than blank).
*/
export class I18nService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the locale store at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
/** Active locale store (switching re-renders the tree; low frequency). */
get locale(): SnapshotStore<string> {
return this.localeStore
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/**
* Client plugin body: provide the i18n service with base dictionaries.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const i18n = new I18nService()
i18n.register(COMMON_NS, 'zh', zh)
i18n.register(COMMON_NS, 'en', en)
ctx.provide('i18n', i18n)
}
+11
View File
@@ -0,0 +1,11 @@
/**
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Everything else —
* I18nService, Translate, LocaleDict — lives in the client half; consumers
* import the /client subpath. Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the i18n plugin. */
export function apply(): void {}
+32
View File
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
* @module @deepseek-ai/dsh-client-i18n/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
/** Cordis companion plugin name. */
export const name = 'client-i18n-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: ns-by-locale dictionary registry with a stable
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
* mutable relation; fallback-chain resolution and locale-store behavior are
* asserted directly by this package's behavior specs.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+2
View File
@@ -0,0 +1,2 @@
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const en: Record<string, string> = {}
+2
View File
@@ -0,0 +1,2 @@
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const zh: Record<string, string> = {}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
describe('I18nService', () => {
it('translates from the active locale with zh fallback then key passthrough', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
i18n.register('ns', 'en', { hello: 'Hello' })
const t = i18n.bind('ns')
expect(i18n.locale.getSnapshot()).toBe('zh')
expect(t('hello')).toBe('你好')
i18n.locale.set('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = i18n.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
expect(t('greet')).toBe('你好,{name}!第 {n} 次')
})
it('bind returns a stable reference per namespace', () => {
const i18n = new I18nService()
expect(i18n.bind('a')).toBe(i18n.bind('a'))
expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
})
it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
const i18n = new I18nService()
const dispose = i18n.register('ns', 'zh', { k: 'v1' })
expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
dispose()
const t = i18n.bind('ns')
expect(t('k')).toBe('k')
i18n.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
})
it('locale store is subscribable (snapshot store contract)', () => {
const i18n = new I18nService()
let notified = 0
i18n.locale.subscribe(() => { notified += 1 })
i18n.locale.set('en')
expect(i18n.locale.getSnapshot()).toBe('en')
expect(notified).toBe(1)
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
expect(inject).toEqual([])
const ctx = new Context()
await ctx.plugin({ inject, apply: clientApply }).await()
const i18n = ctx.get('i18n')
expect(i18n).toBeInstanceOf(I18nService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../web-react"
},
{
"path": "../../support/invariants"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])
+18
View File
@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-client-runtime
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@deepseek-ai/dsh-client-runtime",
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./loader": {
"types": "./lib/types/client/loader/index.d.ts",
"default": "./lib/loader.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection"
],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-session": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/loader.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService, SessionsService (list store + scope tree + object layer),
* the ClientLoader interface, and the cordis Context/Events merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export { SessionManager } from './sessions/manager.ts'
export type { SessionListSnapshot } from './sessions/manager.ts'
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
export type { SessionListEntry } from './sessions/lineage.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
// concrete types live here, where their subjects live) ----
/**
* The client cordis context face: the base Context plus the service keys
* this package's declaration merge contributes (slots/sessions/loader) and
* every later plugin's merge. A plain alias — the merges land on Context
* itself inside the client program; the name marks intent at consumer seams.
*/
export type ClientContext = Context
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
* settled (result node). The fold produces both shapes; toolview components
* narrow on the discriminant fields.
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module 'cordis' {
interface Events {
/**
* A slot's definition or registration set changed.
* @mode emit
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
loader: ClientLoader
}
}
/** One __DSH_BOOT__ manifest row. */
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
/** Per-plugin load status store shape. */
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
/**
* Client bundle loader. The immediately group loads first (parallel fetch,
* apply in inject topology order); remaining plugins follow in inject
* topology. Loaded bundle export surfaces are registered back into the
* require module table. Implementation lives in the `./loader` subpath
* (shell-held machinery).
*/
export interface ClientLoader {
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
start(): void
/**
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
* @param id - plugin id (package name).
*/
load(id: string): Promise<void>
/**
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
* @param id - plugin id.
*/
unload(id: string): Promise<void>
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
settled(): Promise<void>
/**
* Read a loaded module's export surface from the module table (same
* implementation the bundle-facing require uses; unknown spec throws).
* @param spec - module specifier (package name or seeded library id).
*/
requireModule(spec: string): unknown
/** Per-plugin status store. */
readonly status: SnapshotStore<LoaderStatus>
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']
/**
* Client plugin body: mount slots + sessions, start the stream loop.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
onConnected: () => { sessions.manager.handleConnected() },
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}
@@ -0,0 +1,247 @@
/**
* ClientLoader implementation (shell-held machinery — the loader cannot load
* itself, so the web shell imports this subpath statically and mounts the
* instance as ctx.loader; the runtime package's own client bundle never
* includes it).
*
* Load chain per plugin: fetch bundle text → execute (script injection) → the
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
* handoff, id reconciled) → factory(require) with require bound to the module
* table → ctx.plugin(exports.apply) → the export surface is registered into
* the module table under the plugin id (inject topology guarantees later
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
*
* start(): the `immediately` group is fetched in parallel and executed in
* group-internal inject topology (execution is serial — the handoff slot is
* single); a full-group barrier precedes the remaining plugins, which then
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
export interface ClientPluginHandoff {
/** Plugin id (package name) — must match the manifest row being loaded. */
id: string
/**
* Closure factory: receives the DI require and returns the module's export
* surface; an `apply` export is applied as a cordis plugin.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface the loader owns (bundle side of the handoff protocol). */
interface DshWindow {
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
}
/** Options for createClientLoader (assembled by the web shell at boot). */
export interface ClientLoaderOptions {
/** Client root context: plugin applies mount under it. */
ctx: Context
/**
* Seeded module table: pure-library entities (react, react-dom, cordis,
* ui-slots, web-react, ui-primitives). The loader takes ownership and
* registers loaded bundle export surfaces alongside them.
*/
modules: Record<string, unknown>
/**
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
* same protocol shape.
*/
boot?: { plugins: BootPluginEntry[] }
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (serial half; execution synchronously performs the
* loadPlugin handoff). Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/** Per-plugin bookkeeping across the load chain. */
interface PluginRecord {
entry: BootPluginEntry
state: 'idle' | 'loading' | 'active' | 'failed'
fetch?: Promise<string>
load?: Promise<void>
}
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
/**
* Build the client bundle loader.
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
* @returns the ClientLoader the shell mounts as ctx.loader.
*/
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
const { ctx } = options
const win = globalThis as DshWindow
const boot = options.boot ?? win.__DSH_BOOT__
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
const modules = new Map<string, unknown>(Object.entries(options.modules))
const records = new Map<string, PluginRecord>()
for (const entry of boot.plugins) {
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
records.set(entry.id, { entry, state: 'idle' })
}
const status = createSnapshotStore<LoaderStatus>({})
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
status.update((draft) => { draft[id] = state })
}
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
// doLoad arms the slot before executing and reconciles the id after.
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
win.DSHClientProxy = {
loadPlugin: (handoff: ClientPluginHandoff): void => {
if (slot !== NOT_LOADED) {
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
}
slot = handoff
},
}
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
})
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
})
const requireModule = (spec: string): unknown => {
if (!modules.has(spec)) {
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
}
return modules.get(spec)
}
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
const claimStyles = (id: string): void => {
if (typeof document === 'undefined') return
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
}
/** Start (or reuse) the parallelizable fetch half. */
const prefetch = (record: PluginRecord): Promise<string> =>
(record.fetch ??= fetchBundle(record.entry.url))
async function doLoad(record: PluginRecord): Promise<void> {
const { id } = record.entry
record.state = 'loading'
publish(id, 'loading')
try {
// Dependencies must already be active (start() sequences this; direct
// load() callers get the same fail-loud check).
for (const dep of record.entry.inject) {
const depRecord = records.get(dep)
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
}
const code = await prefetch(record)
executeBundle(code, record.entry.url)
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
const handoff = slot
slot = NOT_LOADED
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
const exports = handoff.factory(requireModule)
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
// The whole export surface is the plugin: cordis object-plugin form
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
// silently drop the dependency declaration — postmortem 0001).
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
await fiber.await()
// Register under both specifier forms bundles emit: the bare package
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
// form) — the loaded surface IS the client half either way.
modules.set(id, exports)
modules.set(`${id}/client`, exports)
claimStyles(id)
record.state = 'active'
publish(id, 'active')
} catch (error) {
record.state = 'failed'
publish(id, 'failed')
throw error
}
}
const load = (id: string): Promise<void> => {
const record = records.get(id)
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
record.load ??= doLoad(record)
return record.load
}
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
const topo = (ids: string[]): string[] => {
const pool = new Set(ids)
const ordered: string[] = []
const done = new Set<string>()
const visiting = new Set<string>()
const visit = (id: string): void => {
if (done.has(id)) return
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
visiting.add(id)
const record = records.get(id)
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
for (const dep of record.entry.inject) {
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (pool.has(dep)) visit(dep)
}
visiting.delete(id)
done.add(id)
ordered.push(id)
}
for (const id of ids) visit(id)
return ordered
}
let settledPromise: Promise<void> | undefined
async function run(): Promise<void> {
const all = [...records.values()]
const early = all.filter(r => r.entry.immediately === true)
const rest = all.filter(r => r.entry.immediately !== true)
// Early group: parallel fetch (all requests in flight at once), serial
// inject-topology execution, full-group barrier before anything else.
const earlyOrder = topo(early.map(r => r.entry.id))
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
for (const id of earlyOrder) await load(id)
// Remaining plugins: one by one in inject topology.
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
}
return {
start: () => {
settledPromise ??= run()
// Failures surface through settled()/status — start() itself is fire-and-forget.
settledPromise.catch(() => {})
},
load,
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
settled: () => {
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
return settledPromise
},
requireModule,
status,
}
}
@@ -0,0 +1,165 @@
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
// Immutability contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
/**
* core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end).
* @param content - core content blocks verbatim.
* @returns UI-classified blocks in source order.
*/
export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
return content.map(toAssistantBlock)
}
/**
* Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw).
* @param block - one core content block.
* @returns the UI classification.
*/
export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}
}
/** A finalized user message. */
export interface UserMessageNode {
kind: 'user'
seq: number
content: readonly ContentBlock[]
source: unknown
}
/** A finalized (or interruption-frozen) assistant message. */
export interface AssistantMessageNode {
kind: 'assistant'
seq: number
turn: number
step: number
blocks: readonly AssistantBlock[]
usage?: unknown
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
interrupted?: true
}
/** A steering message injected mid-turn. */
export interface SteeringMessageNode {
kind: 'steering'
seq: number
turn: number
content: readonly ContentBlock[]
source: unknown
}
/** A context/system injection surfaced in the flow. */
export interface ContextMessageNode {
kind: 'context'
seq: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
seq: number
callId: string
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
call: { name: string; argsRaw: string } | null
content: readonly ContentBlock[]
isError: boolean
error?: { name: string; code: string }
meta?: unknown
/** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
callView: ToolCallView | null
/** Host-computed render intent from this tool/result's wire view; null = same default. */
resultView: ToolResultView | null
}
/** Fallback for surface events this UI version does not know. */
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
type: string
data: unknown
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ToolResultNode
| UnknownSurfaceNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
name: string
argsRaw: string
turn: number
step: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null
}
/** Approval/question placeholder cards (visible, not answerable;
* rpcId = the requested frame's envelope id, the future respond backfill key). */
export type PendingInteraction =
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {
turn: number
step: number
blocks: readonly AssistantBlock[]
}
/** History-open lifecycle of a Session window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
error: RpcError
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
pending: readonly PendingInteraction[]
running: boolean
/** Set after host/session-removed; the UI grays out and disables input. */
removed: boolean
openState: OpenState
openError: RpcError | null
hasMore: boolean
loadingOlder: boolean
promptError: PromptError | null
lastAgentError: string | null
}
@@ -0,0 +1,194 @@
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
// the degradation lives in one branch function in this file, zero scattered removal points).
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
export interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
* place a synthetic event enters the window). */
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
}
case 'steering/message':
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
case 'context/message':
return {
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
return {
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
content: event.data.content, isError: event.data.isError,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
}
}
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
export class FoldAdapter {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = []
private baseSeq = 0
private surface = new SurfaceManager(this.padded)
private nodeCache = new Map<number, ConversationNode>()
private degraded = false
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
return this.callIdx
}
/**
* Window rebuild (after open/resync/page prepend): new padded array, new
* SurfaceManager, cleared cache, rebuilt callIndex.
* @param events - the new window contents (seq-ascending).
* @param baseSeq - seq of the window head (sentinels pad below it).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = false
this.callIdx = new Map()
this.resultViews.clear()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.indexCall(event, views?.[i])
}
}
/**
* Tail append (live session/event): push into the same array (incremental
* lazy fold applies) + incremental callIndex upkeep.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
this.indexCall(event, view)
}
/**
* Current node array + degradation flag. Same revision -> same array
* reference (memo boundary); node object references always come from the per-seq cache.
* @returns the fold projection for the current window revision.
*/
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
let seqs: readonly number[]
if (this.degraded) {
seqs = this.degradedSeqs()
} else {
try {
seqs = this.surface.nodes
} catch (error) {
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
this.degraded = true
seqs = this.degradedSeqs()
}
}
const out: ConversationNode[] = []
for (const seq of seqs) {
const cached = this.nodeCache.get(seq)
if (cached !== undefined) {
out.push(cached)
continue
}
const event = this.padded[seq]
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
if (event === undefined) continue
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
this.nodeCache.set(seq, node)
out.push(node)
}
const value = { nodes: out, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
private degradedSeqs(): number[] {
const seqs: number[] = []
for (let i = this.baseSeq; i < this.padded.length; i++) {
const event = this.padded[i]
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
}
return seqs
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}
@@ -0,0 +1,63 @@
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
// degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
/** One flattened session-list row (summary + lineage indent depth). */
export interface SessionListEntry {
sessionId: SessionId
updatedAt: number
running: boolean
parentSessionId?: SessionId
cwd?: string
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
/**
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, SessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
const children = new Map<SessionId, SessionSummary[]>()
const roots: SessionSummary[] = []
for (const s of summaries) {
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
const list = children.get(s.parentSessionId) ?? []
list.push(s)
children.set(s.parentSessionId, list)
} else {
roots.push(s) // root, or an orphan whose parent is absent from summaries (degrade to root, never drop)
}
}
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: SessionSummary, depth: number): void => {
if (visited.has(s.sessionId)) {
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
return
}
visited.add(s.sessionId)
out.push({ ...s, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)
for (const kid of kids) walk(kid, depth + 1)
}
for (const root of roots) walk(root, 0)
// Cycle members (unreachable from any root): emit as roots so no entry is lost.
for (const s of summaries) {
if (!visited.has(s.sessionId)) walk(s, 0)
}
return out
}
@@ -0,0 +1,250 @@
// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionListEntry } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
state: 'idle' | 'loading' | 'error'
error: RpcError | null
}
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
/** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
* history (cannot be backfilled on open), the one frame class that must not take the
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
private listError: RpcError | null = null
private listInflight: Promise<void> | null = null
private listSnapshotCache: SessionListSnapshot
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
* object when every field matches — wire refreshes mint all-new summary objects, so identity
* must be recovered by value or every SessionListItem memo misses on every refresh (audit S5). */
private entryCache = new Map<SessionId, SessionListEntry>()
private itemsCache: readonly SessionListEntry[] = []
private readonly notifier = new Notifier(() => {
this.listSnapshotCache = this.buildListSnapshot()
})
constructor(private readonly api: IApiClient) {
this.listSnapshotCache = this.buildListSnapshot()
}
// ---- Instance management ----
/**
* Lazy build: return the existing instance or construct one (no auto-open —
* open is triggered by the container's select callback).
* @param sessionId - the session to get.
* @returns the resident instance.
*/
get(sessionId: SessionId): Session {
let session = this.sessions.get(sessionId)
if (session === undefined) {
session = new Session(sessionId, this.api)
this.sessions.set(sessionId, session)
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) session.handleRunning(summary.running)
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
const buffered = this.pendingBuffers.get(sessionId)
if (buffered !== undefined) {
this.pendingBuffers.delete(sessionId)
for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
}
}
return session
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
refreshList(): Promise<void> {
if (this.listInflight !== null) return this.listInflight
this.listState = 'loading'
this.listError = null
this.notifier.markDirty()
this.listInflight = (async () => {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
this.summaries = result.value.items
this.listState = 'idle'
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
} else {
this.listState = 'error'
this.listError = result.error
}
} catch (error) {
this.listState = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.listError = folded.ok ? null : folded.error
} finally {
this.listInflight = null
this.notifier.markDirty()
}
})()
return this.listInflight
}
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh).
* @param cwd - optional working directory for the new session.
* @returns the create result.
*/
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
this.summaries = [
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
...this.summaries,
]
this.notifier.markDirty()
}
return result
} catch (error) {
return transportError(error)
}
}
// ---- Subscription surface (for useSessionList) ----
/**
* uSES subscription entry for useSessionList.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Cached list snapshot (rebuilt lazily when dirty with no listeners).
* @returns the cached reference (stable until the next flush).
*/
getListSnapshot(): SessionListSnapshot {
this.notifier.ensureFresh()
return this.listSnapshotCache
}
// ---- ConnectionController sinks (wired by boot) ----
/**
* Mux frame entry: sessionId-bearing frames go only to instantiated sessions
* (no lazy build; non-pending frames for uninstantiated sessions drop —
* history backfills them on open).
* @param envelope - the frame with its wire rpcId.
*/
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
// everything else drops (not instantiated — history fully backfills on open).
switch (frame.type) {
case 'approval/requested':
case 'approval/resolved':
case 'question/requested':
case 'question/resolved': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
this.pendingBuffers.set(frame.sessionId, buffer)
return
}
default:
return
}
}
session.handleMuxEnvelope(envelope.rpcId, frame)
}
/**
* Host frame entry: list upkeep + per-instance running/removed/agent-error relay.
* @param envelope - the frame with its wire rpcId.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
const frame = envelope.payload
switch (frame.type) {
case 'host/session-added': {
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
this.summaries = [
{
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
},
...this.summaries,
]
this.notifier.markDirty()
}
return
}
case 'host/session-removed': {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.notifier.markDirty()
return
}
case 'host/session-status': {
this.summaries = this.summaries.map(s =>
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.notifier.markDirty()
return
}
case 'host/agent-error': {
this.sessions.get(frame.sessionId)?.handleAgentError(frame.message)
return // not reflected in the list
}
default:
return // stream/error ignored; unknown frames ignored (documented default)
}
}
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
handleConnected(): void {
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
}
private buildListSnapshot(): SessionListSnapshot {
const fresh = flattenLineage(this.summaries)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry
})
for (const id of this.entryCache.keys()) {
if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id)
}
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
return { items: this.itemsCache, state: this.listState, error: this.listError }
}
}
@@ -0,0 +1,61 @@
// Notifier: subscription + microtask-batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private scheduled = false
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
/**
* uSES subscription entry.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
/** State-change entry: mark dirty and schedule the batched flush. */
markDirty(): void {
this.dirty = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.dirty) return
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
})
}
/**
* Synchronous flush: controlled-input writes must notify in the same tick as
* onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
*/
notifyNow(): void {
this.dirty = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
}
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
ensureFresh(): void {
if (!this.dirty) return
this.dirty = false
this.rebuild()
}
}
@@ -0,0 +1,89 @@
// PartialAccumulator: assistant/chunk accumulator.
// Folds the six StreamChunk variants into AssistantBlock[] keyed by block index;
// block-level immutability (a delta only swaps that block's reference).
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
private blocks: (AssistantBlock | undefined)[] = []
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
}
/**
* Fold one chunk.
* @param chunk - the stream chunk.
* @returns whether it caused a visible change (usage/finish return false, skipping notification).
*/
push(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'block-start': {
this.blocks[chunk.index] = emptyBlock(chunk.blockType)
this.changed = true
return true
}
case 'text-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'text', text: (prev?.kind === 'text' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'reasoning-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'reasoning', text: (prev?.kind === 'reasoning' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'tool-call-delta': {
const prev = this.blocks[chunk.index]
const base = prev?.kind === 'tool-call' ? prev : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
this.blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
}
this.changed = true
return true
}
case 'block-end': {
this.blocks[chunk.index] = toAssistantBlock(chunk.block)
this.changed = true
return true
}
default:
// usage / finish / merge-extensible unknown variants: no visible block change
// (finish is immediately followed by the assistant/message that supersedes the partial).
return false
}
}
/**
* Current partial projection.
* @returns the cached snapshot (the blocks array reference only changes after a mutation).
*/
toPartial(): PartialAssistant {
if (this.changed) {
// Compact sparse indexes (out-of-order block-start) into render order.
this.snapshot = { turn: this.turn, step: this.step, blocks: this.blocks.filter((b): b is AssistantBlock => b !== undefined) }
this.changed = false
}
return this.snapshot
}
}
function emptyBlock(blockType: string): AssistantBlock {
switch (blockType) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
default: return { kind: 'other', block: null }
}
}
@@ -0,0 +1,227 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
* nobody is watching it. "Watched" is approximated as the most recently
* resolved binding id — SessionProvider re-resolves on every selection
* change (keyed remount), so a switch away always re-evaluates the deferred
* teardown; a host-side death without list removal keeps the scope (frozen
* read-only view).
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
title: string
cwd?: string
parentId?: SessionId
running: boolean
updatedAt: number
}
/** Session list store shape. */
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
readonly session: Session
readonly ctx: Context
}
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
const kScope = Symbol('dsh.client.scope')
/**
* Read the session scope tag off a context.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
export function scopeOf(ctx: Context): SessionId | undefined {
return (ctx as Context & { [kScope]?: SessionId })[kScope]
}
/** Shared no-op plugin backing each session scope fiber. */
function sessionScope(): void {}
/**
* Display title projection. The wire summary carries no title yet (P-I
* ledger): the project directory's basename stands in, then the raw id.
*/
function titleOf(cwd: string | undefined, id: SessionId): string {
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
}
return id
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
}
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.manager = new SessionManager(api)
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
* @returns the new session id.
*/
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
const result = await this.manager.create(opts.cwd)
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
return result.value.sessionId
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
return this.resolve(id)?.ctx
}
/**
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
const record = this.resolve(id)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id
this.sweepDeferred()
}
return record.binding
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* @param id - session id.
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
*/
ancestry(id: SessionId): SessionSummary[] {
const { byId } = this.list.getSnapshot()
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session: this.manager.get(id), ctx },
}
this.scopes.set(id, record)
return record
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const items = this.manager.getListSnapshot().items
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
title: titleOf(entry.cwd, entry.sessionId),
running: entry.running,
updatedAt: entry.updatedAt,
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
this.list.set({ ids, byId })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
}
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the watched id ever defers, and every
* watch move sweeps first, so the set cannot contain the id the watch just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
if (this.list.getSnapshot().byId[id] !== undefined) {
this.deferredRemovals.delete(id)
continue
}
const record = this.scopes.get(id)
this.deferredRemovals.delete(id)
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
* together, so a deferred id always still owns its record; kept so a
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
void record.fiber.dispose()
}
}
}
}
@@ -0,0 +1,527 @@
// Session: wraps every contract call that needs a sessionId + all conversation state for this
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
} from './conversation.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
export const PAGE_MESSAGES = 50
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
private views: (ToolEventView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
private openError: RpcError | null = null
private openPromise: Promise<void> | null = null
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly foldAdapter = new FoldAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
private callsRev = 0
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
private running = false
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null
private snapshotCache: ConversationSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
this.snapshotCache = this.buildSnapshot()
}
// ---- Operations ----
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - core content blocks verbatim.
* @param mode - queue appends after the current turn; steer interrupts it.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
} catch (error) {
result = transportError(error)
}
if (!result.ok) {
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
}
return result
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}
if (!result.ok) {
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
}
return result
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
if (this.openPromise !== null) return this.openPromise
const promise = this.doOpen(this.openGeneration).finally(() => {
// Identity-guarded: a superseded open must not null out the promise resync just started.
if (this.openPromise === promise) this.openPromise = null
})
this.openPromise = promise
return promise
}
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
this.hasMore = false
return
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
} finally {
this.loadingOlder = false
this.notifier.markDirty()
}
}
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
async resync(): Promise<void> {
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
this.openGeneration++
this.openPromise = null
this.openState = 'cold'
this.openError = null
this.events = []
this.views = []
this.baseSeq = 0
this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.notifier.markDirty()
await this.open()
}
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
/**
* uSES subscription entry.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Cached conversation snapshot (rebuilt lazily when dirty with no listeners).
* @returns the cached reference (stable until the next flush).
*/
getSnapshot(): ConversationSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
// ---- Manager-only entry points (@internal; never called by the UI) ----
/**
* Mux frame arrival (the dispatch switch).
* @param rpcId - the frame envelope id (the respond backfill key for requested frames).
* @param frame - the routed frame.
*/
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
switch (frame.type) {
case 'session/event': {
this.acceptLiveEvent(frame.event, frame.view)
return
}
case 'session/subscribed': {
this.subscribedLastSeq = frame.lastSeq
return // pure baseline bookkeeping, no visible change
}
case 'approval/requested': {
this.pending.set(`a:${rpcId}`, {
kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
...(frame.callId !== undefined ? { callId: frame.callId } : {}),
...(frame.reason !== undefined ? { reason: frame.reason } : {}),
})
this.pendingRev++
this.notifier.markDirty()
return
}
case 'approval/resolved': {
for (const [key, item] of this.pending) {
if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
this.pending.delete(key)
this.pendingRev++
}
}
this.notifier.markDirty()
return
}
case 'question/requested': {
this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
this.pendingRev++
this.notifier.markDirty()
return
}
case 'question/resolved': {
if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
this.notifier.markDirty()
return
}
default:
return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default)
}
}
/**
* Running-bit relay from the host stream (list entry and snapshot stay consistent).
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
if (this.running === running) return
this.running = running
this.notifier.markDirty()
}
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
handleRemoved(): void {
this.removed = true
this.notifier.markDirty()
}
/**
* host/agent-error relay: the only outlet for live failures with no turn position.
* @param message - the stringified error.
*/
handleAgentError(message: string): void {
this.lastAgentError = message
this.notifier.markDirty()
}
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
dispose(): void {}
// ---- 私有 ----
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
private async doOpen(generation: number): Promise<void> {
this.openState = 'loading'
this.openError = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
this.openState = 'error'
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
}
this.openState = 'open'
} catch (error) {
if (generation !== this.openGeneration) return
this.openState = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.openError = folded.ok ? null : folded.error
} finally {
if (generation === this.openGeneration) this.notifier.markDirty()
}
}
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
this.notifier.markDirty()
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: ToolEventView): void {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.foldAdapter.append(event, view)
this.applyEventSideEffects(event, view)
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
* its continuity assertion into the degraded view). */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
return
}
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
void this.repairGap()
return
}
this.appendLive(event, view)
this.notifier.markDirty()
}
/** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
* installWindow path. No openState transition — the UI keeps the current window (no loading
* flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */
private async repairGap(): Promise<void> {
/* v8 ignore next -- re-entry guard: acceptLiveEvent already detours to liveBuffer while stitching, so no second call reaches here. */
if (this.stitching) return
this.stitching = true
const generation = this.openGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
} finally {
this.stitching = false
}
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
this.partial.push(chunk)
return
}
case 'assistant/message': {
if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) {
this.partial = null // finalize swaps in place (same notification batch, no flicker)
}
return
}
case 'tool/call': {
this.openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step,
callView: view?.for === 'call' ? view.view : null,
})
this.callsRev++
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
return
}
case 'turn/end': {
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
// from the logged chunks. Content-free partials are dropped outright.
if (this.partial !== null && this.partial.turn === event.data.turn) {
const { blocks } = this.partial.toPartial()
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.frozenNodes.push({
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.frozenRev++
}
this.partial = null
}
let callOffset = 0
for (const [callId, call] of this.openCalls) {
if (call.turn !== event.data.turn) continue
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.frozenNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
call: { name: call.name, argsRaw: call.argsRaw },
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})
this.frozenRev++
}
return
}
default:
return
}
}
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.callsRev++
this.frozenNodes = []
this.frozenRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.applyEventSideEffects(event, this.views[i])
}
}
private windowTailSeq(): number | null {
const tail = this.events[this.events.length - 1]
return tail === undefined ? null : tail.seq
}
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
nodes = this.nodesCache.value
} else {
nodes = this.frozenNodes.length === 0
? folded
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial: this.partial?.toPartial() ?? null,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
running: this.running,
removed: this.removed,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
lastAgentError: this.lastAgentError,
}
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
* run through the caller's ctx.effect so a plugin's registrations are
* collected when its fiber unloads (cordis-native cascade).
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from './index.ts'
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
export class SlotsService extends Service {
private readonly _core = new SlotCore()
/**
* @param ctx - owning root context.
*/
constructor(ctx: Context) {
super(ctx, 'slots')
this._core.onMutate((key) => { ctx.emit('slots/changed', key) })
}
/**
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param spec - kind/scope spec.
* @returns disposer.
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
}
/**
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param component - contributed component.
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
* inject factory's binding is pinned to ClientContext.
* @returns disposer.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
// Client-context registrations have exactly one ctx shape: pin Ctx to
// ClientContext so inject factories dot services without a cast.
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
}
/**
* Snapshot entries for a key.
* @param key - SlotMap key.
* @returns registered entries (stable reference between mutations).
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
return this._core.entries(key)
}
/**
* Look up a defined spec.
* @param key - SlotMap key.
* @returns spec or undefined.
*/
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
return this._core.spec(key)
}
/**
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
* @param key - candidate slot key.
* @returns wide-typed spec or undefined.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this._core.specDynamic(key)
}
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - SlotMap key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
return this._core.subscribe(key, fn)
}
/**
* Version counter for uSES pairing.
* @param key - SlotMap key.
* @returns current version.
*/
getVersion(key: keyof SlotMap & string): number {
return this._core.getVersion(key)
}
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
get core(): SlotCore {
return this._core
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Runtime plugin, node half. The implementation lives entirely in the client
* half (src/client/ — SlotsService, SessionsService + object layer, and the
* shell-held ClientLoader under ./loader); consumers import the /client or
* /loader subpaths. The empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery). Contract:
* api-contracts v3 section 4.
*/
/** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {}
+52
View File
@@ -0,0 +1,52 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`.
* @module @deepseek-ai/dsh-client-runtime/invariant
*/
/* jscpd:ignore-start */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import type { Context } from 'cordis'
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime'
/** Cordis companion plugin name. */
export const name = 'client-runtime-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']
/**
* Owned relation: every 'slots/changed'(key) emission must observe the
* mutation already applied — SlotCore bumps the key's version synchronously
* before the service re-emits, so a zero version at dispatch time means the
* event fired without (or ahead of) its mutation.
*/
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'slots/changed') return
const key: unknown = args[0]
if (typeof key !== 'string' || key === '') {
fail("'slots/changed' dispatched without a slot key argument")
return
}
const slots = ctx.get('slots')
// Event payloads carry keys as plain strings; getVersion is statically
// keyed, so restore the SlotMap-key type after the runtime string check.
if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,64 @@
/**
* Runtime plugin browser-half apply: slots + sessions mounting over the
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
interface Bench {
ctx: Context
api: FakeApiClient
sinks: ConnectionSinks | undefined
stopped: number
}
async function mount(): Promise<Bench> {
const ctx = new Context()
const api = new FakeApiClient()
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
},
}
ctx.reflect.provide('connection', handle)
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
const sessions = bench.ctx.get('sessions')
expect(sessions !== undefined).toBe(true)
expect(bench.sinks).toBeDefined()
// Frame sinks reach the object layer: a host session-added lands in the list store.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-added', sessionId: 's-new' } as never,
})
await Promise.resolve()
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
// Dispose the whole tree: the ctx.effect teardown must call loop.stop exactly once.
await bench.ctx.fiber.dispose()
expect(bench.stopped).toBe(1)
void fiber
})
})
@@ -0,0 +1,77 @@
/**
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
* through the loader chain (execute → handoff → factory(require) → apply →
* export re-registration). Skips when the bundle is not built (lib/client.js is a
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as webReact from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { SlotsService } from '../src/client/slots.ts'
import { FakeApiClient } from './fake-api.ts'
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
afterEach(() => {
delete (globalThis as Win).DSHClientProxy
delete (globalThis as Win).window
})
function readLayoutBundle(): string | undefined {
try {
const require = createRequire(import.meta.url)
return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
} catch {
return undefined
}
}
describe('real tsdown bundle through the loader', () => {
const code = readLayoutBundle()
it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
// The bundle banner addresses window.DSHClientProxy; node has no window —
// alias it to globalThis so the loader-installed proxy is reachable.
;(globalThis as Win).window = globalThis
const ctx = new Context()
// The layout apply consumes the slots + sessions services; the real chain
// loads the runtime bundle first — stand both up directly here.
ctx.plugin(SlotsService)
await ctx.fiber.await()
new SessionsService(ctx, new FakeApiClient())
const loader = createClientLoader({
ctx,
// The real bundle externals resolved from the seeded table. React is a
// type-only import in the layout bundle today, but jsx-runtime is real.
modules: {
'react': await import('react'),
'react/jsx-runtime': await import('react/jsx-runtime'),
'@deepseek-ai/dsh-client-ui-slots': uiSlots,
'@deepseek-ai/dsh-client-web-react': webReact,
},
boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
fetchBundle: () => Promise.resolve(code as string),
// node has no DOM: evaluate the bundle body directly (same synchronous
// handoff contract as the <script> path).
executeBundle: (bundleCode) => {
// Node has no <script>: Function-evaluating the built bundle IS the
// system under test (same synchronous handoff as the browser path).
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
new Function(bundleCode)()
},
})
loader.start()
await loader.settled()
expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
expect(typeof surface.apply).toBe('function')
})
})
@@ -0,0 +1,289 @@
/**
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
* with export-surface re-registration, immediately-group barrier (parallel
* fetch / topology execution / full-group barrier), status store, settled,
* failure modes (missing handoff, unknown dep, cycle, unload stub).
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
const win = globalThis as Win
afterEach(() => {
delete win.DSHClientProxy
delete win.__DSH_BOOT__
})
interface FakeBundle {
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
}
interface Bench {
loader: ReturnType<typeof createClientLoader>
fetched: string[]
executed: string[]
fetchGate: Map<string, () => void>
}
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
function bench(
plugins: BootPluginEntry[],
bundles: Record<string, FakeBundle>,
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const ctx = new Context()
const fetched: string[] = []
const executed: string[] = []
const fetchGate = new Map<string, () => void>()
const loader = createClientLoader({
ctx,
modules: opts.modules ?? { react: { marker: 'react' } },
boot: { plugins },
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
executed.push(code)
const bundle = bundles[code]
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
if (typeof bundle.handoff === 'function') {
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
return
}
win.DSHClientProxy?.loadPlugin(bundle.handoff)
},
})
return { loader, fetched, executed, fetchGate }
}
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
handoff: require => ({
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
require,
...exports,
}),
})
describe('load chain', () => {
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
const applied: string[] = []
const b = bench(
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
{
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
'/plugins/feature/client.js': {
handoff: (require) => {
// Later loader requires the earlier one's export surface (inject topology guarantee).
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
const base = require(fakeBase) as { helper: string }
expect(base.helper).toBe('base-helper')
expect((require('react') as { marker: string }).marker).toBe('react')
return { apply: () => { applied.push('feature') } }
},
},
},
)
b.loader.start()
await b.loader.settled()
expect(applied).toEqual(['fake-base', 'feature'])
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
})
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
const b = bench(
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
{
'/plugins/a/client.js': okBundle(),
'/plugins/b/client.js': okBundle(),
'/plugins/later/client.js': okBundle(),
},
{ gated: ['/plugins/a/client.js'] },
)
b.loader.start()
await Promise.resolve()
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
expect(b.executed).toEqual([])
b.fetchGate.get('/plugins/a/client.js')?.()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
})
it('orders execution by inject topology within each group', async () => {
const b = bench(
[entry('z-ui', ['a-base']), entry('a-base')],
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
)
b.loader.start()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
})
})
describe('failure modes (fail loud)', () => {
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
expect(b.loader.status.getSnapshot().silent).toBe('failed')
})
it('rejects on manifest/handoff id mismatch', async () => {
const b = bench([entry('expected')], {
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
})
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
})
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
// Sequential benches: each loader owns the window proxy, so release it between them.
const fresh = <T>(build: () => T): T => {
delete win.DSHClientProxy
return build()
}
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
missing.loader.start()
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
const cyclic = fresh(() => bench(
[entry('p', ['q']), entry('q', ['p'])],
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
))
cyclic.loader.start()
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
applyless.loader.start()
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
})
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
const b = bench([], {})
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
// First bench installed the proxy; a second loader must refuse.
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
})
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
const b = bench(
[entry('dep', [], true), entry('needy', ['dep'])],
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
)
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
})
it('direct load() naming an unknown inject target fails loud', async () => {
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
})
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
// The fire-and-forget prefetch swallow arm must absorb the early
// rejection; the awaited load surfaces the same failure via settled().
const ctx = new Context()
delete win.DSHClientProxy
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
executeBundle: () => {},
})
loader.start()
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
})
it('unload is the P-I stub', async () => {
const b = bench([], {})
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
})
})
describe('DOM default seams (stubbed globals)', () => {
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
const origFetch = globalThis.fetch
const appended: { textContent?: string | null }[] = []
const styleTag = {
attrs: {} as Record<string, string>,
setAttribute(k: string, v: string) { this.attrs[k] = v },
}
const fakeDoc = {
createElement: () => {
const el = { textContent: null as string | null }
return el
},
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
querySelectorAll: () => [styleTag],
}
const g = globalThis as { document?: unknown; fetch: typeof fetch }
g.document = fakeDoc
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
? new Response('x', { status: 500 })
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
)
try {
delete win.DSHClientProxy
const ctx = new Context()
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
] },
// NO seams injected (keys omitted, not undefined — exactOptional):
// the DOM defaults are under test.
})
const seamHandoff: ClientPluginHandoff = {
id: 'seam-ok',
factory: () => ({ apply: () => {} }),
}
// Default executeBundle only APPENDS the script element (no execution in
// our fake DOM), so drive the handoff manually before load resolves it.
const loadOk = loader.load('seam-ok')
await Promise.resolve()
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
await loadOk
expect(appended).toHaveLength(1)
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
} finally {
g.fetch = origFetch
delete (globalThis as { document?: unknown }).document
}
})
})
describe('handoff slot protocol', () => {
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
delete win.DSHClientProxy
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
const proxy = (globalThis as Win).DSHClientProxy
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
.toThrow(/overlapping loadPlugin handoff/)
})
})
@@ -0,0 +1,23 @@
/** Assistant block classifier (moved here with sessions/conversation.ts). */
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => {
it('classifies the four block shapes', () => {
const blocks: ContentBlock[] = [
{ type: 'text', text: '正文' },
{ type: 'reasoning', text: '思考' },
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
{ type: 'image', data: 'x' } as unknown as ContentBlock,
]
expect(toAssistantBlocks(blocks)).toEqual([
{ kind: 'text', text: '正文' },
{ kind: 'reasoning', text: '思考' },
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
{ kind: 'other', block: blocks[3] },
])
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
})
})
@@ -0,0 +1,50 @@
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
/** One text content block (local helper). */
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
user: (seq: number, body: string): SessionEvent =>
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/start', data: { turn, step } }),
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }),
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
export function plainTurn(startSeq: number, turn: number, ask: string, answer: string): SessionEvent[] {
return [
ev.turnStart(startSeq, turn),
ev.user(startSeq + 1, ask),
ev.stepStart(startSeq + 2, turn),
ev.assistant(startSeq + 3, turn, answer),
ev.stepEnd(startSeq + 4, turn),
ev.turnEnd(startSeq + 5, turn),
]
}
/** Wrap raw events as view-less history entries (the wire shape history now returns). */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}
+161
View File
@@ -0,0 +1,161 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let nextRpc = 0
export function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
}
export function err<T>(error: RpcError): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
}
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
interface StreamConn<F> {
feed(item: StreamItem<F>): void
}
export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
// Parameters carry local structural annotations: the CI lint lane runs
// without built lib/, so IApiClient's indexed-access types collapse to any
// and inferred parameters would trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false
/** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
* Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
holdStreamOpen = false
private heldOpens: (() => void)[] = []
releaseStreamOpens(): void {
const held = this.heldOpens
this.heldOpens = []
for (const fire of held) fire()
}
readonly events: IApiClient['events'] = {
mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.muxConns, signal, onOpen),
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
pushMux(frame: MuxFrame, rpcId?: string): void {
for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
pushHost(frame: HostFrame, rpcId?: string): void {
for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
endStreams(): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
}
failStreams(error: unknown): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
}
get openMuxCount(): number {
return this.muxConns.length
}
callsOf(method: string): unknown[] {
return this.calls.filter(c => c.method === method).map(c => c.payload)
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
const inbox: StreamItem<F>[] = []
let wake: (() => void) | null = null
const conn: StreamConn<F> = {
feed: (item) => {
inbox.push(item)
wake?.()
},
}
registry.push(conn)
if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
else if (!this.suppressStreamOpen) onOpen?.()
try {
while (!signal.aborted) {
while (inbox.length > 0) {
const item = inbox.shift() as StreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
yield item.envelope
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
registry.splice(registry.indexOf(conn), 1)
}
}
}
@@ -0,0 +1,145 @@
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
* materialization, call-index backfill, and the degraded linear-scan branch.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
describe('FoldAdapter', () => {
it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
const adapter = new FoldAdapter()
const window = plainTurn(100, 5, '偏移问', '偏移答')
adapter.reset(window, 100)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(false)
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (cache identity)', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
expect(second.nodes[0]).toBe(first.nodes[0])
expect(second.nodes[1]).toBe(first.nodes[1])
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
adapter.reset(events, 0)
const { nodes } = adapter.nodes()
const kinds = nodes.map(n => n.kind)
expect(kinds).toContain('user')
expect(kinds).toContain('assistant')
expect(kinds).toContain('steering')
expect(kinds).toContain('context')
const result = nodes.find(n => n.kind === 'tool-result')
expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
const { nodes } = adapter.nodes()
expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes surface-eligible types it does not know as unknown nodes', () => {
const adapter = new FoldAdapter()
adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
const { nodes } = adapter.nodes()
// Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
// shapes; what matters is no throw and no misclassification into a known kind.
for (const node of nodes) expect(node.kind).toBe('unknown')
})
it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
const adapter = new FoldAdapter()
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset(window, 10)
const first = adapter.nodes()
expect(first.degraded).toBe(true)
expect(errorSpy).toHaveBeenCalled()
expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
const second = adapter.nodes()
expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
expect(adapter.callIndex.size).toBe(2)
})
it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
const adapter = new FoldAdapter()
const events = [
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
]
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
adapter.reset(events, 0, [callView, resultView] as never)
expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
})
it('attaches views on the live append path and defaults to null without views', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new FoldAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
const node = adapter.nodes().nodes[0]
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
})
})
@@ -0,0 +1,47 @@
/**
* Runtime invariant companion: the 'slots/changed' emission-order audit —
* a fired key must already carry a bumped version (emission follows the
* applied mutation), bogus payloads fail loud, foreign events pass.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RuntimeInvariant from '../src/invariant.ts'
import { SlotsService } from '../src/client/slots.ts'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(RuntimeInvariant).await()
return ctx
}
const emit = (ctx: Context, event: string, ...args: unknown[]): void => {
;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args)
}
describe('runtime slots/changed invariant', () => {
it('passes foreign events and a legitimate mutation-then-emission sequence', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
// A real define bumps the version first and re-emits through onMutate —
// the audit sees version > 0 and stays quiet.
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
})
it('fails loud on a missing key and on an emission with no applied mutation', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'slots/changed', '') }).toThrow(/without a slot key/)
expect(() => { emit(ctx, 'slots/changed', 42) }).toThrow(/without a slot key/)
await ctx.plugin(SlotsService).await()
// Hand-emitted key that never saw a mutation: version 0 → violation.
expect(() => { emit(ctx, 'slots/changed', 'never-mutated') })
.toThrow(/before any mutation bumped its version/)
})
it('stays quiet when no slots service is mounted (nothing to audit against)', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'slots/changed', 'any-key') }).not.toThrow()
})
})
@@ -0,0 +1,55 @@
/**
* flattenLineage: root ordering, DFS child expansion, orphan degradation, and
* cycle fail-soft (every entry always emitted, no infinite walk).
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
sessionId: id as SessionId, updatedAt, running: false,
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
})
describe('flattenLineage', () => {
it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
s('kid-old', 11, 'new-root'),
s('kid-new', 12, 'new-root'),
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
])
})
it('degrades an orphan (absent parent) to root level without dropping it', () => {
const out = flattenLineage([s('orphan', 20, 'ghost-parent'), s('root', 10)])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([['orphan', 0], ['root', 0]])
})
it('fails soft on a two-node cycle: all entries emitted, warn fired, no hang', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const out = flattenLineage([s('a', 20, 'b'), s('b', 10, 'a'), s('root', 30)])
expect(out.map(e => e.sessionId).sort()).toEqual(['a', 'b', 'root'])
expect(warnSpy).toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('handles a self-referencing entry as a cycle member', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const out = flattenLineage([s('self', 10, 'self')])
expect(out.map(e => e.sessionId)).toEqual(['self'])
expect(out[0]?.depth).toBe(0)
} finally {
warnSpy.mockRestore()
}
})
})
@@ -0,0 +1,223 @@
/**
* SessionManager orchestration: lazy resident instances, list lifecycle, host
* frame routing, and the pending-frame buffer for uninstantiated sessions.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
return { sessionId, updatedAt: 100, running: false, ...over }
}
describe('instances', () => {
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
const session = manager.get(S1)
expect(manager.get(S1)).toBe(session) // resident: same instance forever
expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
})
it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// Uninstantiated: approval buffers, plain session/event drops.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
// Buffer cleared: a second instantiation of another id gets nothing.
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
for (let i = 0; i < 40; i++) {
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
})
describe('list lifecycle', () => {
it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await Promise.all([first, second])
expect(api.callsOf('session.list')).toHaveLength(1)
const snapshot = manager.getListSnapshot()
expect(snapshot.state).toBe('idle')
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
})
it('keeps the error in the list snapshot on failure', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
})
it('merges create into the list immediately without waiting for a refresh', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
const manager = new SessionManager(api)
const result = await manager.create()
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
})
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1)
const session = manager.get(S1)
manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } })
expect(session.getSnapshot().running).toBe(true)
expect(manager.getListSnapshot().items[0]?.running).toBe(true)
manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } })
expect(session.getSnapshot().lastAgentError).toBe('炸了')
manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
expect(session.getSnapshot().removed).toBe(true)
expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
})
})
describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.reject(new Error('list wire down'))
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
})
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const session = manager.get(S1)
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
await manager.refreshList()
expect(session.getSnapshot().running).toBe(true)
})
it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
await manager.create('/tmp/w')
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
await manager.create('/tmp/w') // same id returned: no duplicate row
expect(manager.getListSnapshot().items).toHaveLength(1)
api.onCreate = () => Promise.reject(new Error('create wire down'))
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
// Business error passes through untouched.
api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
expect(await manager.create()).toMatchObject({ ok: false })
})
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
let notified = 0
const unsubscribe = manager.subscribe(() => { notified++ })
await manager.refreshList()
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
const session = manager.get(S1)
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }])
// status flip for an unknown session only touches summaries (no crash).
manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } })
})
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
const before = manager.getListSnapshot()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
const after = manager.getListSnapshot()
expect(after.items).not.toBe(before.items)
const beforeS1 = before.items.find(e => e.sessionId === S1)
const afterS1 = after.items.find(e => e.sessionId === S1)
expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
// Same-order same-entries snapshot reuses the items array.
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } })
expect(manager.getListSnapshot().items).toBe(after.items)
})
it('carries parentSessionId from host/session-added into the lineage row', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
})
})
describe('connected generation', () => {
it('refreshes the list and resyncs only opened instances', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)
await openedSession.open()
manager.get(S2) // instantiated but never opened
const historyCallsBefore = api.callsOf('session.history').length
manager.handleConnected()
await vi.waitFor(() => {
expect(api.callsOf('session.list').length).toBe(1)
// Only the opened instance repulls history; the cold one stays silent.
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
})
})
})
@@ -0,0 +1,10 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
})
})
@@ -0,0 +1,75 @@
/**
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
* laziness, synchronous notifyNow, and unsubscribe.
*/
import { describe, expect, it } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
describe('Notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markDirty()
notifier.markDirty()
notifier.markDirty()
expect(order).toEqual([]) // nothing until the microtask boundary
await microtask()
expect(order).toEqual(['rebuild', 'notify'])
})
it('skips rebuild with zero listeners and ensureFresh rebuilds lazily exactly once', async () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.markDirty()
await microtask()
expect(rebuilds).toBe(0) // lazy: kept dirty
notifier.ensureFresh()
expect(rebuilds).toBe(1)
notifier.ensureFresh()
expect(rebuilds).toBe(1) // clean: no second rebuild
})
it('notifyNow runs listeners synchronously (controlled-input contract)', () => {
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.notifyNow()
expect(order).toEqual(['rebuild', 'notify']) // before returning, no microtask needed
})
it('notifyNow with zero listeners stays lazy like markDirty', () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.notifyNow()
expect(rebuilds).toBe(0)
notifier.ensureFresh()
expect(rebuilds).toBe(1)
})
it('a scheduled flush after notifyNow already flushed is a no-op', async () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.subscribe(() => undefined)
notifier.markDirty() // schedules the microtask flush
notifier.notifyNow() // flushes synchronously, clears dirty
await microtask() // the scheduled flush finds dirty=false
expect(rebuilds).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)
const unsubscribe = notifier.subscribe(() => { calls++ })
notifier.notifyNow()
expect(calls).toBe(1)
unsubscribe()
notifier.markDirty()
await microtask()
notifier.notifyNow()
expect(calls).toBe(1)
})
})
@@ -0,0 +1,91 @@
/**
* PartialAccumulator: six-variant chunk folding, sparse-index compaction, and
* the block/snapshot reference discipline (a delta swaps only that block).
*/
import { describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client'
import { PartialAccumulator } from '../src/client/sessions/partial.ts'
const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk
describe('PartialAccumulator', () => {
it('builds empty blocks per block-start type, unknown type falls to other', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'text' }))
acc.push(chunk({ type: 'block-start', index: 1, blockType: 'reasoning' }))
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'tool-call' }))
acc.push(chunk({ type: 'block-start', index: 3, blockType: 'no-such' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'text', text: '' },
{ kind: 'reasoning', text: '' },
{ kind: 'tool-call', callId: '', name: '', argsRaw: '' },
{ kind: 'other', block: null },
])
})
it('accumulates text deltas, starting from empty when prev is missing or another kind', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '无 start ' })) // prev missing
acc.push(chunk({ type: 'text-delta', index: 0, text: '也累积' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '无 start 也累积' }])
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '换型重起' })) // prev is text → restart
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '换型重起' }])
})
it('accumulates reasoning deltas on the reasoning lane', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '思' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '考' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c2-late', name: 'echo', argumentsDelta: ':1}' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{"a":1}' },
])
})
it('replaces the accumulated block wholesale on block-end', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '中间态' }))
acc.push(chunk({ type: 'block-end', index: 0, block: { type: 'text', text: '定稿全文' } }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '定稿全文' }])
})
it('returns false (no notification) for usage/finish/unknown variants and keeps blocks', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'x' }))
const before = acc.toPartial()
expect(acc.push(chunk({ type: 'usage', usage: {} }))).toBe(false)
expect(acc.push(chunk({ type: 'finish', reason: 'stop' }))).toBe(false)
expect(acc.push(chunk({ type: 'future-variant' }))).toBe(false)
expect(acc.toPartial()).toBe(before) // unchanged: same snapshot reference
})
it('compacts sparse indexes into a dense render-order array', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'text' }))
acc.push(chunk({ type: 'text-delta', index: 2, text: '先到的高位' }))
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
const { blocks } = acc.toPartial()
expect(blocks).toHaveLength(2) // no undefined holes
expect(blocks[0]).toEqual({ kind: 'reasoning', text: '' })
expect(blocks[1]).toEqual({ kind: 'text', text: '先到的高位' })
})
it('keeps the snapshot reference stable without changes and swaps it once per mutation', () => {
const acc = new PartialAccumulator(3, 1)
const first = acc.toPartial()
expect(first).toMatchObject({ turn: 3, step: 1, blocks: [] })
expect(acc.toPartial()).toBe(first)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'a' }))
const second = acc.toPartial()
expect(second).not.toBe(first)
expect(acc.toPartial()).toBe(second)
})
})
@@ -0,0 +1,625 @@
/**
* Session orchestration: drive the object through contract calls and injected
* frames (open → prompt → stream → finalize → cancel → resync) and assert the
* ConversationSnapshot it settles into. Reference stability is asserted with
* toBe/not.toBe — it is the React.memo/uSES contract, equal-value output is not
* enough.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
const SID = 'fk-s1' as SessionId
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
}
function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('open', () => {
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
const page = plainTurn(10, 3, '问', '答')
api.onHistory = () => histResponse(page, true)
expect(session.getSnapshot().openState).toBe('cold')
const opening = session.open()
expect(session.getSnapshot().openState).toBe('loading')
await opening
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
const { api, session } = makeSession()
await Promise.all([session.open(), session.open()])
await session.open()
expect(api.callsOf('session.history')).toHaveLength(1)
})
it('lands an error result in openState=error with the RpcError kept', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
await session.open()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('error')
expect(snapshot.openError?.code).toBe('session-not-found')
})
it('folds a transport throw into openState=error / internal', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.reject(new Error('socket died'))
await session.open()
expect(session.getSnapshot().openState).toBe('error')
expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' })
})
it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
const { api, session } = makeSession()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
expect(seqs).toEqual([11, 13, 16])
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
const { api, session } = makeSession()
api.onHistory = () => histResponse(events)
await session.open()
return { api, session }
}
it('drops replayed frames at or below the window tail', async () => {
const { session } = await opened()
const before = session.getSnapshot()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(3, '重放') })
await Promise.resolve()
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.user(7, '流式问'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '半截'))
let snapshot = session.getSnapshot()
expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] })
feed(ev.chunkText(10, 1, '回复'))
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }])
feed(ev.assistant(11, 1, '半截回复'))
feed(ev.turnEnd(12, 1))
snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const last = snapshot.nodes.at(-1)
expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] })
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.user(7, '要被打断的'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '说到一半'))
feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const frozen = snapshot.nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
// Ordered inside the flow: after the user message (seq 7), before any later turn.
expect((frozen as { seq: number }).seq).toBeGreaterThan(7)
})
it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}'))
expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }])
feed(ev.toolResult(8, 1, 'c1', 'ECHO'))
expect(session.getSnapshot().runningCalls).toEqual([])
// Second call never resolves: turn/end freezes it as an error card.
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
feed(ev.turnEnd(10, 1, 'cancelled'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls).toEqual([])
expect(snapshot.nodes.at(-1)).toMatchObject({
kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' },
})
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
api.onHistory = () => histResponse(repaired)
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
await Promise.resolve()
const seqs = session.getSnapshot().nodes.map(n => n.seq)
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
})
})
describe('paging', () => {
it('prepends an older page and keeps seq continuity', async () => {
const older = plainTurn(0, 0, '旧问', '旧答')
const newer = plainTurn(6, 1, '新问', '新答')
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(newer, true)
: histResponse(older, false)
await session.open()
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(api.callsOf('session.history')).toMatchObject([{}, { beforeSeq: 6 }].map(p => ({ sessionId: SID, ...p })))
expect(snapshot.hasMore).toBe(false)
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(10, 1, '新', '页'), true)
: histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.open()
const nodesBefore = session.getSnapshot().nodes
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(snapshot.nodes).toEqual(nodesBefore)
expect(snapshot.hasMore).toBe(false)
} finally {
errorSpy.mockRestore()
}
})
it('ignores loadOlder while one is in flight (single request)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
})
})
describe('prompt and cancel errors', () => {
it('sends content through session.prompt with the mode passed through', async () => {
const { api, session } = makeSession()
const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(result.ok).toBe(true)
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
})
it('business failure lands in promptError with op=send', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
})
it('lands cancel failures in promptError with op=stop', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.reject(new Error('cancel transport down'))
const result = await session.cancel()
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
})
})
describe('pending interactions', () => {
it('adds approval/question on requested and removes them on resolved', async () => {
const { session } = makeSession()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
session.handleMuxEnvelope('rq' as never, { type: 'question/requested', sessionId: SID, questions: [] })
expect(session.getSnapshot().pending.map(p => p.kind).sort()).toEqual(['approval', 'question'])
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap1' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
})
describe('remaining branches', () => {
it('prompt transport throw folds to internal promptError', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } })
})
it('cancel business error also lands op=stop promptError', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } }))
await session.cancel()
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } })
})
it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
const { api, session } = makeSession()
await session.loadOlder() // cold: no-op, zero calls
expect(api.calls).toEqual([])
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.open()
// err result: window unchanged
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
await session.loadOlder()
expect(session.getSnapshot().nodes).toHaveLength(2)
expect(session.getSnapshot().hasMore).toBe(true)
// empty page: hasMore adopts the response
api.onHistory = () => histResponse([], false)
await session.loadOlder()
expect(session.getSnapshot().hasMore).toBe(false)
// hasMore false now: further loadOlder is a guard no-op
const calls = api.calls.length
await session.loadOlder()
expect(api.calls.length).toBe(calls)
// throw path: fail-soft with console.error
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.resync()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.resync()
api.onHistory = () => Promise.reject(new Error('page wire down'))
await session.loadOlder()
expect(errorSpy).toHaveBeenCalled()
expect(session.getSnapshot().loadingOlder).toBe(false)
} finally {
errorSpy.mockRestore()
}
})
it('subscribe delivers snapshot-change notifications and unsubscribes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
let notified = 0
const unsubscribe = session.subscribe(() => { notified++ })
await session.open()
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
session.handleRunning(true) // any snapshot mutation; the listener must stay silent
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
const { api, session } = makeSession()
const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
let call = 0
api.onHistory = () => {
call++
return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full)
}
// Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once.
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
await session.open()
expect(call).toBe(2)
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('a failed second stitch pull keeps the first window and still opens', async () => {
const { api, session } = makeSession()
let call = 0
api.onHistory = () => {
call++
return call === 1
? histResponse(plainTurn(0, 0, 'a', 'b'))
: Promise.resolve(err({ code: 'internal', message: 'stitch pull down', details: {} }))
}
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
await session.open()
expect(call).toBe(2)
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open') // stitch-pull failure is not an open failure
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3]) // first window kept
})
it('approval frame with callId/reason keeps the optional fields; duplicate resolved is a no-op', () => {
const { session } = makeSession()
session.handleMuxEnvelope('ra' as never, {
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
})
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
expect(session.getSnapshot().pending).toEqual([])
})
it('ignores unknown mux frame types and repeated running flips (documented defaults)', () => {
const { session } = makeSession()
const before = session.getSnapshot()
session.handleMuxEnvelope('rz' as never, { type: 'future/frame' } as never)
session.handleRunning(false) // already false: dedup branch
expect(session.getSnapshot()).toBe(before)
session.handleRemoved()
expect(session.getSnapshot().removed).toBe(true)
})
it('drops live events while cold/error (no window upkeep)', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '冷态帧') })
expect(session.getSnapshot().nodes).toEqual([])
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
await session.open()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '错态帧') })
expect(session.getSnapshot().nodes).toEqual([])
})
it('repairGap failure logs and clears stitching; concurrent gaps coalesce into one repair', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let repairs = 0
api.onHistory = () => {
repairs++
return gate.promise
}
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞一') })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(10, '洞二') }) // stitching: detours, no second repair
expect(repairs).toBe(1)
gate.reject(new Error('repair wire down'))
await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() })
// Window unchanged; a later successful repull still lands the buffered frames.
expect(session.getSnapshot().nodes).toHaveLength(2)
} finally {
errorSpy.mockRestore()
}
})
it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
feed(ev.turnEnd(8, 1, 'cancelled'))
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
})
it('turn/end sweeps only same-turn open calls; other turns keep running', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
feed(ev.turnEnd(9, 1, 'cancelled'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
})
it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
const resynced = session.resync()
stale.reject(new Error('stale wire'))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error
})
it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
})
it('drops a stale stitch pull (second doOpen fetch) superseded mid-flight by resync', async () => {
const { api, session } = makeSession()
const secondPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let call = 0
api.onHistory = () => {
call++
if (call === 1) return histResponse(plainTurn(0, 0, 'a', 'b')) // first page: tail 5
if (call === 2) return secondPull.promise // gap-stitch pull: held
return histResponse(plainTurn(6, 1, 'c', 'd'))
}
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
const opening = session.open() // triggers the second pull, which parks
await vi.waitFor(() => { expect(call).toBe(2) })
const resynced = session.resync()
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
})
it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => repairPull.promise
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
})
it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const result = await session.cancel()
expect(result.ok).toBe(true)
expect(session.getSnapshot().promptError).toBeNull()
const callsBefore = session.getSnapshot().runningCalls
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') })
expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference
})
it('freezes a tool-call-only partial (visible through the non-text arm)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
feed(ev.turnEnd(8, 1, 'cancelled'))
const frozen = session.getSnapshot().nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
})
it('dispose is a reserved no-op on resident instances', () => {
const { session } = makeSession()
expect(() => { session.dispose() }).not.toThrow()
})
it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => {
const { api, session } = makeSession()
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
api.onHistory = () => Promise.resolve(ok({
events: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' },
})
// Live path: the frame's view slot reaches runningCalls, then the result node.
session.handleMuxEnvelope('rv1' as never, {
type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'),
view: { for: 'call', view: { card: 'generic', title: '直播卡' } },
} as never)
expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }])
session.handleMuxEnvelope('rv2' as never, {
type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'),
view: { for: 'result', view: { card: 'generic', title: '直播果' } },
} as never)
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' },
})
})
})
describe('resync', () => {
it('rebuilds the window and clears pending; cold instances no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')])
await session.resync()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames
expect(snapshot.nodes).toHaveLength(4)
const cold = makeSession()
await cold.session.resync()
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const firstOpen = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late
await firstOpen
await resynced
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
})
describe('reference stability (the memo contract)', () => {
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const before = session.getSnapshot()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') })
const after = session.getSnapshot()
expect(after).not.toBe(before) // top-level swap on change
expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity
expect(after.nodes[1]).toBe(before.nodes[1])
expect(after.nodes).toHaveLength(3)
// No change → same snapshot reference.
expect(session.getSnapshot()).toBe(after)
})
it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
const before = session.getSnapshot()
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '与工具无关的流式'))
const after = session.getSnapshot()
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.runningCalls).not.toBe(after.runningCalls)
expect(resolved.pending).toBe(after.pending)
})
})
@@ -0,0 +1,193 @@
/**
* SessionsService: list store projection (manager → {ids, byId} with derived
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
* teardown with watch deferral), binding identity, ancestry walk, create.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
interface Bench {
ctx: Context
api: FakeApiClient
svc: SessionsService
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const svc = new SessionsService(ctx, api)
return { ctx, api, svc }
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
})),
}) as never)
await b.svc.manager.refreshList()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
const b = bench()
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
})
describe('scope tree', () => {
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.scope(sid('unknown'))).toBeUndefined()
const scoped = b.svc.scope(sid('s1'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1')) // s1 is watched
b.svc.scope(sid('s2')) // s2 scoped but not watched
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while watched: deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
const b = bench()
await feedList(b, [{ id: 's1', running: true }])
const scoped = b.svc.scope(sid('s1'))
await feedList(b, [{ id: 's1', running: false }])
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
it('cancels a deferred teardown when the id reappears in the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
await feedList(b, []) // removed while watched → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
const b = bench()
await feedList(b, [
{ id: 'root', cwd: '/w/app' },
{ id: 'mid', parentId: 'root' },
{ id: 'leaf', parentId: 'mid' },
{ id: 'orphan', parentId: 'ghost' },
])
expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
})
})
describe('create', () => {
it('returns the new id on ok and throws a coded error on failure', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
b.api.onCreate = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
} as never)
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
})
})
describe('coverage tails (branch duals)', () => {
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
const { byId } = b.svc.list.getSnapshot()
expect(byId[sid('no-base')]?.title).toBe('no-base')
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
})
it('binding for an unknown session returns undefined without moving the watch', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
await feedList(b, []) // deferred removal of the watched id
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
expect(b.svc.binding(sid('s1'))).toBeDefined()
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.binding(sid('a'))
b.svc.binding(sid('b')) // watch: b; both scoped
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
// set containing b (torn) — and the watched-continue branch fires when the
// deferral set still holds the current watch target.
await feedList(b, [{ id: 'c' }])
b.svc.binding(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force-add via removed
// list state (scope teardown raced) — sweep must tolerate the missing record.
await feedList(b, [])
b.svc.binding(sid('c')) // c now watched+removed → deferred
await feedList(b, [{ id: 'd' }])
b.svc.binding(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})
})

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