mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows
Conflict: the generated event-producer-consumer doc — regenerated over merged sources. Also strips the trailing whitespace / extra EOF blank line that master's vitest.config.ts and client api.ts arrived with (the pre-commit whitespace hook rejects any commit touching them otherwise).
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 0d4686a5a233785ca4832ef068a118b484a872fe
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: dcc9213c6b3a088b8b8bce2a442c5232ed5b7d0b
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 43ba5708d1216c37a7ad7e2904df7d2a6baf016d
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b33ff870d745584d2988bb6a7eb1a31e56ec3da
|
||||
|
||||
@@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; CI static, pre-push, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;CI 静态检查、pre-push 与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
### 构建管线与产物
|
||||
|
||||
|
||||
@@ -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/web,vite 应用) | 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' }`——载体层回执,**不是** RpcMessage(response 不再有 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[] }` | 已持久化 session,updatedAt 倒序;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-extensible:client 对未知 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 审计。**现状**:契约与帧类型已 shipped,host 侧 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`(观测)。
|
||||
|
||||
### IApiClient:caller 视图
|
||||
|
||||
与 `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.0(dsh-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` union,register 同 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 import,grep 可断言):
|
||||
|
||||
```
|
||||
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=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 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.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each):
|
||||
1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm.
|
||||
2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of the fully-generated `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` and their `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected.
|
||||
|
||||
Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references.
|
||||
Both run via a shared `doc-sync` package.json script that contributors invoke for relevant documentation changes and CI invokes exhaustively. The [fast local Git hooks](2026-07-22-fast-local-git-hooks.md) decision keeps this surface-selected work out of commit and push hooks.
|
||||
|
||||
**Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the docs/AGENTS.md "one physical line per paragraph" writing rule. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates.
|
||||
|
||||
@@ -24,7 +24,7 @@ Both run via a shared `doc-sync` package.json script that the lefthook pre-push
|
||||
|
||||
## Consequences
|
||||
|
||||
- Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle.
|
||||
- Doc drift in the checkable classes fails `doc-sync` and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle.
|
||||
- Making doc snippets compile costs a few stub imports/`declare`s; the `ignore-check` ratio must stay low or the gate is theater (the ratio guard enforces this).
|
||||
- The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review.
|
||||
- API reports remain available to revisit if the packages are ever published externally.
|
||||
|
||||
@@ -2,24 +2,26 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path.
|
||||
|
||||
## Problem
|
||||
|
||||
This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review.
|
||||
|
||||
## Decision
|
||||
|
||||
Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts:
|
||||
Every mechanically checkable AGENTS.md promise gets a command that exits non-zero. CI invokes the exhaustive set, while Git hooks reserve their latency budget for cheap local defects:
|
||||
|
||||
- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary.
|
||||
- ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded.
|
||||
- jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations.
|
||||
- Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
|
||||
- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations.
|
||||
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths.
|
||||
- lefthook pre-commit fixes staged lint, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Conventions survive agent turnover; violations fail fast and locally.
|
||||
- Conventions survive agent turnover; cheap commit/push defects fail locally and exhaustive violations fail in CI.
|
||||
- The gates themselves are code to maintain; config changes are reviewed like any change.
|
||||
- 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see [the mutation-testing proposal](../../proposed/testing/2026-06-11-mutation-testing.md)).
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr
|
||||
- Check a target only when it is a **relative path**. Skip scheme-qualified URLs (`https:`, `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path` — no stable base in a checkout), and pure in-page anchors (`#section`). Strip any `#fragment`/`?query`, resolve the path against the linking file's directory, and assert it exists on disk.
|
||||
- Report and never rewrite; exit non-zero on the first broken link found.
|
||||
|
||||
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into the `doc-sync` script that the lefthook pre-push hook and CI both run, so a broken link fails locally before a push — consistent with [mechanical quality gates](2026-06-11-quality-gates.md).
|
||||
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into `doc-sync`, so relevant documentation changes and CI exercise the same broken-link check.
|
||||
|
||||
This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped).
|
||||
|
||||
@@ -26,6 +26,6 @@ This gate checks *existence*, not anchor validity: a link to a real file with a
|
||||
|
||||
## Consequences
|
||||
|
||||
- Renames and moves that orphan a cross-link now fail the pre-push hook and CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle.
|
||||
- Renames and moves that orphan a cross-link fail `doc-sync` and CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle.
|
||||
- One more fast tsx script in the `doc-sync` chain; no new dependency (the mdast/GFM stack is already in devDependencies for `verify-md-wrap`).
|
||||
- The convention this enforces — cross-reference docs by machine-checkable relative link, never by bare prose or a number — is documented in [docs/AGENTS.md](../../../../docs/AGENTS.md) so authors know the gate exists and why.
|
||||
|
||||
@@ -43,4 +43,4 @@ Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don'
|
||||
- Every Agent Note sits under a class folder. A reader can browse one folder to see all simplifications or all testing decisions within a lifecycle.
|
||||
- Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`).
|
||||
- Adding a class is a deliberate act: amend the `const` in `scripts/agent-note-tree.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in.
|
||||
- Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see.
|
||||
- Source-comment doc references are gated too — a moved or renamed doc that a `.ts` comment cites fails `verify-doc-refs` in `doc-sync` and CI, closing a drift class `verify-md-links` structurally could not see.
|
||||
|
||||
@@ -32,7 +32,7 @@ The durability requirement was specific: the doc shows the **literal** current t
|
||||
- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches.
|
||||
- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
|
||||
- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot.
|
||||
- Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates.
|
||||
- Wired into `doc-sync`, so relevant documentation changes run it locally and CI runs it with the other documentation checks.
|
||||
|
||||
### Maintenance is the author's job, with a gate backstop
|
||||
|
||||
@@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de
|
||||
|
||||
## Consequences
|
||||
|
||||
- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here.
|
||||
- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in `doc-sync` and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here.
|
||||
- The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering.
|
||||
- The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment.
|
||||
- Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist.
|
||||
|
||||
@@ -33,7 +33,7 @@ This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright.
|
||||
- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in `doc-sync` and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright.
|
||||
- Event and service-method contracts have a single home — the JSDoc at the declaration. The catalog repeats that original JSDoc inside its generated signature block and uses its description portion as entry prose, so thin source documentation yields a thin catalog entry.
|
||||
- The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator.
|
||||
- `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead.
|
||||
|
||||
@@ -8,7 +8,7 @@ The repository had no single reference for the names, descriptions, and JSON Sch
|
||||
|
||||
## Decision
|
||||
|
||||
Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## <package>` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate.
|
||||
Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## <package>` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so relevant documentation changes and CI exercise the same freshness check.
|
||||
|
||||
### Why boot, not parse (the crux)
|
||||
|
||||
@@ -47,7 +47,7 @@ Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck`
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright.
|
||||
- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in `doc-sync` and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright.
|
||||
- Tool description prose has a single home — the `defineTool` `description` at the source — and the generated entry is only as good as it, the same forcing function the cordis catalog applies to event JSDoc.
|
||||
- The generator imports and executes workspace packages (the first repo script to do so; the others only read text). It runs under `tsx` via the root `tsconfig` `paths` map, the same unbuilt-source path the demos and tests use, so it needs no build step.
|
||||
- A new capability seam behind a future tool means a new manifest recipe entry (which seams to mount). This is the deliberate hand-written cost called out above; it changes only when a tool package is added.
|
||||
|
||||
@@ -10,7 +10,7 @@ The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-ch
|
||||
|
||||
## Decision
|
||||
|
||||
Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth).
|
||||
Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, so relevant documentation changes and CI exercise the same gate without separate wiring.
|
||||
|
||||
The contract:
|
||||
|
||||
@@ -32,7 +32,7 @@ Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` dr
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails `doc-sync` and CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically.
|
||||
- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result.
|
||||
- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate.
|
||||
|
||||
@@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
|
||||
- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in `doc-sync` and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
|
||||
- Event prose has a single home, the JSDoc at the declaration; the catalog preserves that JSDoc and any nested field comments without flattening or paraphrasing them.
|
||||
- The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler.
|
||||
- The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change.
|
||||
|
||||
@@ -36,7 +36,7 @@ Three exemption families keep the gate from demanding boilerplate, in the spirit
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync`, which pre-push and CI already run. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync` and CI. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where `@param` must name them.
|
||||
- Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements.
|
||||
- The gate builds a `ts.Program` (~6s) — the one doc gate that pays for type resolution; acceptable inside `doc-sync`, which already compiles doc snippets.
|
||||
|
||||
@@ -32,7 +32,7 @@ The package README `## Config` sections stay. The overlap is accepted deliberate
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in pre-push and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright.
|
||||
- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in `doc-sync` and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright.
|
||||
- Config prose now has a forcing function at the declaration: writing a new config field means writing its JSDoc, which becomes the catalog entry verbatim.
|
||||
- The generator hard-errors on shapes it cannot walk statically — an aliased package-local config import, a schema built by anything other than `object`/`intersect` composition, an unlisted global type name. Introducing such a shape includes teaching the generator (or the shape stays out of the repo), which is the point: the catalog stays the whole truth.
|
||||
- `gen-cordis-catalog.ts` exports its JSDoc/pointer helpers and `LINK_MAP` for reuse, so the two catalogs cross-link types identically and a link-map addition serves both.
|
||||
|
||||
@@ -2,39 +2,30 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
|
||||
|
||||
## Problem
|
||||
|
||||
The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent.
|
||||
|
||||
Flattening those members directly into `lefthook.yml` solves the local hook only. CI has the same scheduling problem, and duplicating a long leaf list in YAML gives future script changes two places to drift.
|
||||
|
||||
`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state.
|
||||
Aggregate jobs such as documentation synchronization hide long sequential chains whose members are read-only and independent. Duplicating their leaf inventory in workflow YAML gives future script changes multiple places to drift, while running package publication checks serially makes one gate consume time proportional to the package count.
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses.
|
||||
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI and `doc-sync`. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound.
|
||||
|
||||
The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including Agent Note classification and Agent Note format, while the runner schedules independent checks with four active top-level workers by default; `DSH_GATE_CONCURRENCY` overrides that bound.
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
The build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain the scheduler mirrors, while `doc-sync` has since moved its member list into the scheduler itself ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)).
|
||||
The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep aggregate `hygiene` and `doc-sync` jobs in the hook** - simpler config, but it leaves most of the pre-push wall clock inside serial command chains that lefthook cannot see or schedule.
|
||||
- **Declare one lefthook job per leaf gate** - exposes parallelism through lefthook's native job model, but it makes the hook file carry a long member list that CI cannot reuse.
|
||||
- **Require developers to build before pushing** - avoids one hook gate, but it makes `publint` fail in a clean worktree and turns the final local checkpoint into a convention instead of a runnable check.
|
||||
- **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about.
|
||||
- **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added.
|
||||
- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs.
|
||||
- **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup.
|
||||
- **Declare one CI job per leaf gate** — exposes maximum workflow parallelism but repeats checkout, setup, and install overhead and duplicates the scheduler inventory in YAML.
|
||||
- **Background subcommands inside shell scripts** — parallelizes work but loses per-gate timing, deterministic failure grouping, and straightforward signal handling.
|
||||
- **Declare one `publint` job per package** — exposes maximum package parallelism but creates a hand-maintained package inventory that drifts when packages change.
|
||||
- **Run `publint` with unbounded concurrency** — minimizes elapsed time on small repositories only by gambling with process count, memory pressure, package tarball creation, and readable logs.
|
||||
|
||||
## Consequences
|
||||
|
||||
The hook's critical path becomes the slowest real gate instead of the sum of hidden gate chains. Lefthook reports one `full check` job, and the runner reports per-gate timing inside that job, so a slow local checkpoint still points at the gate that dominates the run.
|
||||
Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. The cost is a custom scheduler with an explicit mode inventory.
|
||||
|
||||
The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path.
|
||||
|
||||
`publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
|
||||
`publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
|
||||
|
||||
@@ -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 devDeps(dev-only),首个 spec `web-ui/tests/utils.spec.tsx`(utils 纯函数 + 组件 RTL render + hook uSES 探针);环境用 per-file `// @vitest-environment jsdom` pragma,node env 的其他包零影响。
|
||||
- 排除是**显式注释的裁决**不是静默豁免;解除路径=删 exclude 行 + 补 justified 排除或补测。
|
||||
|
||||
## 车道地图
|
||||
|
||||
| 场景 | 命令 | 内容 | 何时跑 |
|
||||
|---|---|---|---|
|
||||
| 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 |
|
||||
| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 |
|
||||
| 门禁 | `pnpm run test:coverage` | 全仓 gate(host 侧 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)要么重走前置×N;PASS/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,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-doc-sync-through-gate-scheduler.md: b79df2dd7d3515cb0434ac672f7f87c3271d900b
|
||||
2026-07-21-doc-sync-through-gate-scheduler.zh.md: 9395244e3c7700166ad87c49219073210c66bc7e
|
||||
2026-07-21-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2
|
||||
2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef
|
||||
|
||||
@@ -6,13 +6,13 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI and pre-push never enforced that catalog's freshness.
|
||||
`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI never enforced that catalog's freshness.
|
||||
|
||||
## Decision
|
||||
|
||||
`doc-sync` in `package.json` now delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — the same way `check:pre-push` and the `check:ci:*` scripts already do ([parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The new `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set; the chain that could drift from it is gone. Like `pre-push`, the mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides.
|
||||
`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides.
|
||||
|
||||
The drift this consolidation surfaced is fixed in the same change: `docSyncLeafGates` gains the missing `verify-cordis-api` leaf, so CI and pre-push now gate the generated runtime API catalog alongside the other generated docs.
|
||||
`docSyncLeafGates` includes `verify-cordis-api`, so relevant local documentation checks and CI gate the generated runtime API catalog alongside the other generated docs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 和 pre-push 从未把关该目录的新鲜度。
|
||||
`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 从未把关该目录的新鲜度。
|
||||
|
||||
## 决策
|
||||
|
||||
`package.json` 中的 `doc-sync` 现在委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与 `check:pre-push` 和各 `check:ci:*` 脚本的做法一致([并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。新增的 `doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源;那条可能与之漂移的链不复存在。与 `pre-push` 一样,该模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。
|
||||
`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。
|
||||
|
||||
这次整合暴露出的漂移在同一变更中修复:`docSyncLeafGates` 补上缺失的 `verify-cordis-api` 叶子,CI 和 pre-push 从此与其他生成文档一起把关生成的运行时 API 目录。
|
||||
`docSyncLeafGates` 包含 `verify-cordis-api`,因此相关的本地文档检查与 CI 会同其他生成文档一起把关生成的运行时 API 目录。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-fast-local-git-hooks.md: bab47c6479f1a2c01cbfa7152b1d610917fb6175
|
||||
2026-07-22-fast-local-git-hooks.zh.md: 7b279b1a9ad86e09ed5cf7d2470cb61ff17e09b7
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Fast local Git hooks
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-fast-local-git-hooks.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again.
|
||||
|
||||
Fast hooks still need to reject cheap, high-confidence defects before work leaves the machine. Staged formatting, whitespace errors, missing vendored-source metadata, and repository type errors fit that boundary; unit suites, snapshots, documentation checks, builds, and package hygiene vary with the changed surface and do not.
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode.
|
||||
|
||||
Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The `check:pre-push` package script and `pre-push` scheduler mode do not exist; [scripts/run-gates.ts](../../../../scripts/run-gates.ts) continues to own CI and `doc-sync` scheduling.
|
||||
|
||||
Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence.
|
||||
|
||||
## Supersedes
|
||||
|
||||
This decision supersedes the local-hook portion of [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) and the hook/CI symmetry in [Mechanical quality gates over prose guidelines](2026-06-11-quality-gates.md). Their CI scheduler, package-gate, and mechanical-enforcement decisions remain in force.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the full pre-push suite and optimize its scheduler** — preserves the earliest exhaustive signal but still repeats agent-selected evidence and CI, while unrelated failures continue blocking publication.
|
||||
- **Remove pre-push entirely** — makes pushes cheapest but loses the fast cross-file guarantee that TypeScript provides after several commits.
|
||||
- **Keep typecheck in pre-commit** — catches type errors earlier but charges every intermediate commit instead of one push; staged lint already covers the commit-local syntax and style boundary.
|
||||
- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the existing auto-fix workflow; Lefthook's `stage_fixed` owns re-staging so the command does not duplicate `git add`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state.
|
||||
|
||||
Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 快速本地 Git 钩子
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-fast-local-git-hooks.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。
|
||||
|
||||
快速钩子仍需在工作离开本机之前拦下检查成本低且把握高的缺陷。暂存文件格式问题、空白错误、vendor 源码元数据缺失与仓库类型错误符合这条边界;单元测试套件、快照、文档检查、构建与包(package)的 `hygiene` 检查则随改动范围而异,不符合这条边界。
|
||||
|
||||
## 决策
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。
|
||||
|
||||
两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。`check:pre-push` 包脚本与调度器的 `pre-push` 模式不存在;[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 继续负责 CI 和 `doc-sync` 调度。
|
||||
|
||||
agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。
|
||||
|
||||
## 取代关系
|
||||
|
||||
本决策取代[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中涉及本地钩子的部分,以及[以机械质量门禁代替文字规范](2026-06-11-quality-gates.md)中关于钩子与 CI 对称性的部分。上述记录中关于 CI 调度器、包门禁与机械化强制执行的决策继续有效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。
|
||||
- **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。
|
||||
- **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。
|
||||
- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留现有的自动修复工作流;Lefthook 的 `stage_fixed` 负责重新暂存,因此命令无需重复执行 `git add`。
|
||||
|
||||
## 结果
|
||||
|
||||
普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。
|
||||
|
||||
从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-cross-platform-test-fixtures.md: 83af904db5d004366021d4ba6bead656ff813dae
|
||||
2026-07-22-cross-platform-test-fixtures.zh.md: 3570c393f8d2fc3344aa43ff0eb8291500d07e1c
|
||||
2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a
|
||||
2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-22-cross-platform-test-fixtures.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and numeric file descriptor `0` is not the sole owner of Node's pipe-backed child stdin. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture.
|
||||
The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and child-pipe closure or event-loop scheduling does not settle at the same point on every host. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture.
|
||||
|
||||
Treating fixture syntax as product behavior either reports false regressions or encourages production normalization that erases native path semantics.
|
||||
|
||||
@@ -14,18 +14,20 @@ Treating fixture syntax as product behavior either reports false regressions or
|
||||
|
||||
Tests of platform-neutral behavior construct absolute paths and `file:` URIs with the host's `node:path` and `node:url` APIs, then assert native absolute output or stable workspace-relative output as the contract requires. Invalid-URI fixtures use encodings rejected by `fileURLToPath()` on every supported platform.
|
||||
|
||||
Subprocess fixtures that require the parent write side to fail close both the CRT descriptor and the libuv handle owning child stdin. This pins the connection failure contract across POSIX descriptor-backed and Windows pipe-backed processes while keeping the child alive long enough to distinguish pipe failure from process exit.
|
||||
Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles.
|
||||
|
||||
Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection.
|
||||
Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient.
|
||||
|
||||
Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Normalize all paths and URIs to POSIX strings.** This would make assertions uniform but would change correct Windows behavior: external paths are native absolute paths, UNC file URIs are valid, and configured homes resolve through the host path rules.
|
||||
|
||||
**Run POSIX fixtures through a compatibility shell on Windows.** A compatibility environment would test different filesystem and process semantics from the native Node runtime exercised by the product.
|
||||
**Manipulate child-pipe internals until a write fails.** CRT descriptors and libuv handles have different ownership across hosts and Node versions, so this would test undocumented fixture machinery instead of the connection's write-failure contract.
|
||||
|
||||
**Skip whole files or packages on Windows.** Broad exclusions would hide supported behavior. Only the individual fixture whose state cannot exist on Windows is excluded; the surrounding contract remains covered.
|
||||
|
||||
## Consequences
|
||||
|
||||
Portable fixtures are slightly more verbose because expected paths derive from shared native constants. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Pipe-failure fixtures depend on Node's test-runtime handle shape, but that dependency stays inside the scripted child and proves the real parent-side stream behavior rather than mocking it.
|
||||
Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;在 Node 中,编号为 `0` 的文件描述符也不是子进程管道型 stdin 的唯一持有者。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。
|
||||
单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;子进程管道关闭或事件循环调度在不同宿主上的稳定时点也不一致。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。
|
||||
|
||||
把 fixture 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。
|
||||
|
||||
@@ -14,18 +14,20 @@ Status: implemented
|
||||
|
||||
测试平台无关行为时,使用宿主的 `node:path` 和 `node:url` API 构造绝对路径与 `file:` URI,再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。
|
||||
|
||||
需要使父进程写端失败的子进程 fixture 会同时关闭 CRT 文件描述符和持有子进程 stdin 的 libuv 句柄。这种方式在以 POSIX 文件描述符为后端的进程和以 Windows 管道为后端的进程上固定了连接失败契约,同时让子进程存活足够长的时间,以区分管道故障与进程退出。
|
||||
传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。
|
||||
|
||||
对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。
|
||||
语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。
|
||||
|
||||
对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为:外部路径是原生绝对路径,UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。
|
||||
|
||||
**在 Windows 上通过兼容性 shell 运行 POSIX fixture。**这种兼容环境测试的文件系统与进程语义不同于产品实际使用的原生 Node 运行时。
|
||||
**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败契约。
|
||||
|
||||
**在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture;相关契约仍保持覆盖。
|
||||
|
||||
## 后果
|
||||
|
||||
可移植 fixture 略显冗长,因为预期路径需要从共享的原生常量派生。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。管道故障 fixture 依赖 Node 测试运行时的句柄形态,但这种依赖仅存在于脚本化的子进程内;因此,这类 fixture 验证的是真实的父进程侧流行为,而不是对它进行 mock。
|
||||
可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,dispose 的调用方仍能观察到该失败。
|
||||
|
||||
@@ -24,7 +24,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
|
||||
3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry.
|
||||
4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md).
|
||||
5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)).
|
||||
6. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect.
|
||||
6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect.
|
||||
|
||||
## Manual checks
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Diff the sibling branch against `origin/master`, not against the current PR bran
|
||||
|
||||
## Validation And PR Hygiene
|
||||
|
||||
For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Before pushing, expect the pre-push hook to run module graph freshness, unit tests, snapshots, doc-sync, and hygiene.
|
||||
For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Select any other evidence from the outgoing diff; the pre-push hook contributes typecheck only.
|
||||
|
||||
When opening or updating a PR, summarize:
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: dsh-pre-push-checks
|
||||
description: Use before pushing, force-pushing, marking ready for review, claiming checks pass, or bypassing a local hook on a deepseek-harness branch, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes.
|
||||
description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite.
|
||||
---
|
||||
|
||||
# DSH Pre-Push Checks
|
||||
|
||||
Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, and built-bin smoke.
|
||||
Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix.
|
||||
|
||||
## First Steps
|
||||
## Inspect the outgoing change
|
||||
|
||||
1. Confirm the checkout and branch.
|
||||
|
||||
@@ -16,88 +16,80 @@ git status --short --branch
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
|
||||
2. Inspect the outgoing diff.
|
||||
2. Inspect the diff against its actual base.
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
git diff --name-only origin/$(git branch --show-current)...HEAD
|
||||
```
|
||||
|
||||
If the branch has no upstream or the command is not meaningful for the stack shape, use `git diff --name-only origin/master...HEAD` or the PR base branch.
|
||||
If the branch has no upstream or that range is not meaningful for the stack, compare with the PR base branch. After merging a changed base, reassess which behavior the combined diff can affect and rerun only checks invalidated by the merge.
|
||||
|
||||
3. If the branch was just merged with `master`, or the user says master changed, run the gates after resolving the merge and before pushing or marking ready. Do not present a conflict-resolution commit as ready with only typecheck/lint evidence.
|
||||
## Select relevant evidence
|
||||
|
||||
## Required Baseline
|
||||
There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches.
|
||||
|
||||
Run these before every non-trivial push:
|
||||
- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it.
|
||||
- **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it.
|
||||
- **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output.
|
||||
- **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke.
|
||||
- **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets.
|
||||
|
||||
Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook.
|
||||
|
||||
### Focus unit coverage on the affected source
|
||||
|
||||
Test selection and coverage selection are separate. A Vitest file filter chooses which tests run, while the repository configuration otherwise measures every `packages/*/*/src/**/*.ts` file. When unit coverage is relevant, name both the owning tests and the source files or package whose coverage those tests must prove:
|
||||
|
||||
```sh
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run test:coverage
|
||||
pnpm exec vitest run packages/<group>/<package>/tests/<behavior>.spec.ts \
|
||||
--coverage \
|
||||
--coverage.include='packages/<group>/<package>/src/**/*.ts'
|
||||
```
|
||||
|
||||
Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI.
|
||||
Use an exact source file when the behavior is truly confined to one module. Repeat `--coverage.include` for multiple affected files or packages, and pass every owning test file needed to exercise that scope. The configured per-file 100% thresholds still apply inside the selected source scope.
|
||||
|
||||
## Add Gates By Touched Surface
|
||||
|
||||
Run `pnpm run doc-sync` and `pnpm run verify-module-graph` when the diff touches Markdown docs, package manifests, package imports/exports, generated catalogs, Agent Notes, architecture docs, translation pairs, Mermaid diagrams, or comments that cite docs/packages.
|
||||
|
||||
Run `pnpm run build` and `pnpm run hygiene` when the diff touches any package `package.json`, dependency graph, public exports, build config, declaration surface, bundled runtime path, or code that will be consumed from built `lib/`.
|
||||
|
||||
Run snapshot tests when the diff changes ACP/editor-facing transcript behavior: ACP bridge updates, agent-loop observable output, tool call/result presentation, session log rendering, stdout/stderr protocol output, or snapshot fixtures.
|
||||
When the owning tests are unclear, use Vitest's dependency graph to discover a candidate set, then inspect the selected tests before treating the run as evidence:
|
||||
|
||||
```sh
|
||||
pnpm run test:snapshot
|
||||
pnpm exec vitest related packages/<group>/<package>/src/<changed>.ts \
|
||||
--run \
|
||||
--coverage \
|
||||
--coverage.include='packages/<group>/<package>/src/<changed>.ts'
|
||||
```
|
||||
|
||||
Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change.
|
||||
`vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change.
|
||||
|
||||
```sh
|
||||
DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts
|
||||
```
|
||||
## Full local rehearsal
|
||||
|
||||
Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets.
|
||||
Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate.
|
||||
|
||||
```sh
|
||||
pnpm run test:e2e
|
||||
```
|
||||
## Handle failures
|
||||
|
||||
Run a targeted test first for the changed package, but never use targeted tests as the only push evidence unless the change is test-only and cannot affect shared behavior.
|
||||
|
||||
## Full Local CI Approximation
|
||||
|
||||
Use this before high-risk pushes, after large merges, before asking for review on a major PR, or when prior pushes have caused CI churn. The authoritative command list is the root [AGENTS.md § Run the CI gates locally before marking a PR ready](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready); run that block rather than copying a local variant into this skill. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior.
|
||||
|
||||
## Handling Failures
|
||||
|
||||
If a gate fails, stop and fix or explain the blocker. Do not push and hope CI differs.
|
||||
If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs.
|
||||
|
||||
If a failure looks environment-specific, prove it:
|
||||
|
||||
- Record the exact command, failing test, and platform-specific mismatch.
|
||||
- Confirm the relevant non-platform gates pass.
|
||||
- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate.
|
||||
- Bypass a local hook only when the user explicitly asks to push or agrees, and state exactly which hook failed and why it is not expected to fail on CI.
|
||||
- Confirm the relevant non-platform evidence.
|
||||
- Prefer fixing cross-platform nondeterminism when the check is required.
|
||||
- Bypass a local hook only when the user explicitly asks or agrees, and report exactly what failed and why CI is expected to differ.
|
||||
|
||||
Known pattern to watch for: Linux CI and macOS local behavior can differ for shell utilities such as `sed -i`. Treat this as evidence to investigate, not as automatic permission to bypass.
|
||||
## Push procedure
|
||||
|
||||
## Push Procedure
|
||||
|
||||
1. Local commits may happen before the full gate set, but do not push, mark ready, or claim checks pass until the relevant gates pass or any blocker is explicitly documented.
|
||||
2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it.
|
||||
3. Push normally first so the pre-push hook can run.
|
||||
4. If a local hook is bypassed after user approval, use the narrow bypass and say so in the final response.
|
||||
5. After push, verify the remote ref matches local HEAD.
|
||||
1. Run the selected relevant checks once.
|
||||
2. Commit normally and inspect any files changed by the pre-commit fixer before continuing.
|
||||
3. Push normally so the incremental typecheck hook runs.
|
||||
4. Verify the remote ref matches local `HEAD`.
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD origin/$(git branch --show-current)
|
||||
```
|
||||
|
||||
For GitHub PRs, check CI after push:
|
||||
For GitHub PRs, inspect remote CI after the push:
|
||||
|
||||
```sh
|
||||
gh pr checks
|
||||
```
|
||||
|
||||
If checks are pending, say pending. If checks fail, inspect logs before claiming the push is good.
|
||||
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "DSH Pre-Push Checks"
|
||||
short_description: "Run the right DeepSeek Harness gates before push"
|
||||
short_description: "Run the relevant DeepSeek Harness checks before push"
|
||||
default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -48,7 +48,7 @@ Package groups: [packages/README.md](packages/README.md).
|
||||
```sh
|
||||
pnpm install # pnpm workspaces, node ^22.19 || >=24
|
||||
pnpm run test # vitest unit tests
|
||||
pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src
|
||||
pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src
|
||||
pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
|
||||
pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t <name>
|
||||
pnpm run test:snapshot:record # re-record expected outputs (needs key)
|
||||
@@ -69,26 +69,13 @@ pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
|
||||
|
||||
When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test.
|
||||
|
||||
### Run the CI gates locally before marking a PR ready
|
||||
### Run relevant checks locally
|
||||
|
||||
Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`:
|
||||
Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run.
|
||||
|
||||
```sh
|
||||
set -euo pipefail
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run duplication
|
||||
pnpm run test:coverage
|
||||
pnpm run test:snapshot
|
||||
pnpm run doc-sync
|
||||
pnpm run website:build
|
||||
pnpm run verify-module-graph
|
||||
pnpm run build
|
||||
pnpm run hygiene
|
||||
DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
|
||||
```
|
||||
|
||||
`test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run.
|
||||
- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior.
|
||||
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
|
||||
- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
|
||||
|
||||
## Secrets / .env
|
||||
|
||||
@@ -115,12 +102,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
|
||||
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
|
||||
- **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)).
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
||||
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
|
||||
- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
|
||||
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
|
||||
- Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it.
|
||||
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
|
||||
|
||||
## Defensive patterns
|
||||
|
||||
|
||||
23
apps/cli/package.json
Normal file
23
apps/cli/package.json
Normal 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
apps/cli/src/bin.ts
Normal file
22
apps/cli/src/bin.ts
Normal 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
apps/cli/src/headless.ts
Normal file
104
apps/cli/src/headless.ts
Normal 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
apps/cli/src/web.ts
Normal file
89
apps/cli/src/web.ts
Normal 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
apps/cli/tsconfig.json
Normal file
18
apps/cli/tsconfig.json
Normal 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
apps/web/index.html
Normal file
12
apps/web/index.html
Normal 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
apps/web/package.json
Normal file
37
apps/web/package.json
Normal 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
apps/web/src/main.ts
Normal file
10
apps/web/src/main.ts
Normal 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
apps/web/tests/smoke-fixture.e2e.ts
Normal file
147
apps/web/tests/smoke-fixture.e2e.ts
Normal 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
apps/web/tests/smoke-real.e2e.ts
Normal file
235
apps/web/tests/smoke-real.e2e.ts
Normal 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
apps/web/tests/support.ts
Normal file
47
apps/web/tests/support.ts
Normal 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
apps/web/tsconfig.json
Normal file
22
apps/web/tsconfig.json
Normal 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
apps/web/vite.config.ts
Normal file
26
apps/web/vite.config.ts
Normal 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') },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -1583,7 +1583,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`
|
||||
|
||||
@@ -1749,6 +1749,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))
|
||||
@@ -1785,8 +1793,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))
|
||||
|
||||
@@ -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/*`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1482,7 +1482,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:82`](../../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`
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
development.md: f0db7fbcb4a9df98e83d6c1edd5610e5cc4dd517
|
||||
development.zh.md: 62e16479a49d5548e1fbd773dabca5bd741a24fe
|
||||
development.md: 10406cebae1bf83fff663903b1478c9acb8476a1
|
||||
development.zh.md: 50051ffd631518b37c3ad96f5fd3830cf6893ec9
|
||||
|
||||
@@ -35,13 +35,13 @@ pnpm run typecheck
|
||||
|
||||
That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings.
|
||||
|
||||
If you are preparing to push from a fresh clone or worktree, also build once:
|
||||
If a relevant local check consumes built package output, build once first:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs.
|
||||
`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
## Git hooks
|
||||
|
||||
lefthook is configured in `lefthook.yml` as an early local checkpoint before review:
|
||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||
|
||||
- `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard.
|
||||
- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs runtime-closure verification, unit tests, duplication detection, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently.
|
||||
- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-push` runs only the incremental repository typecheck.
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26.
|
||||
The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.
|
||||
|
||||
## CI gates
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ pnpm run typecheck
|
||||
|
||||
首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。
|
||||
|
||||
如果准备从新克隆或新 worktree 推送,还需要构建一次:
|
||||
如果相关的本地检查需要使用构建后的包产物,请先构建一次:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件。
|
||||
`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
## Git 钩子
|
||||
|
||||
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点:
|
||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行 runtime-closure 校验、单元测试、重复代码检查、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene` 与 `pnpm run doc-sync` 的各成员门禁。
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 只运行仓库增量类型检查。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
|
||||
这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。
|
||||
这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。
|
||||
|
||||
## CI 门禁
|
||||
|
||||
|
||||
@@ -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), [`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) |
|
||||
| `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) |
|
||||
| `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:70`](../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:80`](../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:92`](../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:70`](../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:80`](../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:92`](../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:102`](../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), [`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), `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.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: bd5d8c08a4c474a13342b6b60800cfe0d31e110b
|
||||
README.zh.md: a53ab8d9d6053b39def34505038504fefc80a3f9
|
||||
README.md: c4ddf44ad2497b4ff371918356ab1ec0698c7049
|
||||
README.zh.md: 4a31af4fdee4db2d0362cf9117a6eef4fea32393
|
||||
|
||||
@@ -21,7 +21,7 @@ This repo's documentation is read by people and agents both inside and outside t
|
||||
|
||||
## The gate: verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically:
|
||||
`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:
|
||||
|
||||
1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.
|
||||
2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 门禁:verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约:
|
||||
`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:
|
||||
|
||||
1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
|
||||
2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
|
||||
|
||||
@@ -126,6 +126,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"]
|
||||
@@ -144,6 +158,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"]
|
||||
@@ -182,8 +201,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
|
||||
@@ -666,8 +700,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) |
|
||||
|
||||
@@ -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
docs/web-styling.md
Normal file
107
docs/web-styling.md
Normal 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`、字号 14px;hover 底 `--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`;内部上下两段=textarea(16px/24px,min 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-request(unary 出站) | `--accent` / `--accent-soft` |
|
||||
| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` |
|
||||
| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` |
|
||||
| `⇞` | client-response(SSE 侧回应) | `--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)
|
||||
@@ -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'],
|
||||
|
||||
536
knip.json
536
knip.json
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
lefthook.yml
16
lefthook.yml
@@ -1,25 +1,23 @@
|
||||
# Git hooks (lefthook). Hooks call the same package.json scripts CI runs —
|
||||
# one source of truth; the hook is just an earlier, faster checkpoint.
|
||||
# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full
|
||||
# repository-wide gate matrix.
|
||||
# Install: `pnpm exec lefthook install` (runs automatically via postinstall).
|
||||
|
||||
pre-commit:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: lint (staged)
|
||||
glob: '*.{ts,mts,cts,mjs}'
|
||||
exclude:
|
||||
- 'vendor/*/src/**'
|
||||
run: node_modules/.bin/eslint --fix {staged_files} && git add {staged_files}
|
||||
run: node_modules/.bin/eslint --fix {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
- name: typecheck
|
||||
glob: '*.ts'
|
||||
run: pnpm run typecheck
|
||||
- name: whitespace (staged)
|
||||
run: git diff --cached --check
|
||||
|
||||
- name: vendor manifest guard
|
||||
run: scripts/check-vendor-manifest.sh
|
||||
|
||||
pre-push:
|
||||
jobs:
|
||||
- name: full check
|
||||
run: pnpm run check:pre-push
|
||||
- name: typecheck
|
||||
run: node_modules/.bin/tsc -b tsconfig.json --pretty false
|
||||
|
||||
12
package.json
12
package.json
@@ -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",
|
||||
@@ -32,7 +36,6 @@
|
||||
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
|
||||
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
|
||||
"check:node-compat": "tsx scripts/run-gates.ts node-compat",
|
||||
"check:pre-push": "tsx scripts/run-gates.ts pre-push",
|
||||
"knip": "knip --treat-config-hints-as-errors",
|
||||
"publint": "tsx scripts/publint-all.ts",
|
||||
"doc-typecheck": "tsx scripts/doc-typecheck.ts",
|
||||
@@ -60,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",
|
||||
@@ -85,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",
|
||||
@@ -103,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
packages/client/AGENTS.md
Normal file
71
packages/client/AGENTS.md
Normal 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
packages/client/connection/README.md
Normal file
16
packages/client/connection/README.md
Normal 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
packages/client/connection/package.json
Normal file
53
packages/client/connection/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
packages/client/connection/src/client/api.ts
Normal file
46
packages/client/connection/src/client/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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: {} },
|
||||
}
|
||||
}
|
||||
190
packages/client/connection/src/client/connection.ts
Normal file
190
packages/client/connection/src/client/connection.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
560
packages/client/connection/src/client/fixture.ts
Normal file
560
packages/client/connection/src/client/fixture.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
76
packages/client/connection/src/client/index.ts
Normal file
76
packages/client/connection/src/client/index.ts
Normal file
@@ -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)
|
||||
}
|
||||
12
packages/client/connection/src/client/web-api-client.ts
Normal file
12
packages/client/connection/src/client/web-api-client.ts
Normal file
@@ -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
packages/client/connection/src/index.ts
Normal file
10
packages/client/connection/src/index.ts
Normal 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 {}
|
||||
32
packages/client/connection/src/invariant.ts
Normal file
32
packages/client/connection/src/invariant.ts
Normal file
@@ -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 */
|
||||
21
packages/client/connection/tests/api-helpers.spec.ts
Normal file
21
packages/client/connection/tests/api-helpers.spec.ts
Normal file
@@ -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 })
|
||||
})
|
||||
})
|
||||
65
packages/client/connection/tests/client-apply.spec.ts
Normal file
65
packages/client/connection/tests/client-apply.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
238
packages/client/connection/tests/connection.spec.ts
Normal file
238
packages/client/connection/tests/connection.spec.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
160
packages/client/connection/tests/fake-api.ts
Normal file
160
packages/client/connection/tests/fake-api.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
337
packages/client/connection/tests/fixture.spec.ts
Normal file
337
packages/client/connection/tests/fixture.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
10
packages/client/connection/tests/node-half.spec.ts
Normal file
10
packages/client/connection/tests/node-half.spec.ts
Normal file
@@ -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
packages/client/connection/tsconfig.json
Normal file
42
packages/client/connection/tsconfig.json
Normal 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.*"
|
||||
]
|
||||
}
|
||||
3
packages/client/connection/tsdown.config.ts
Normal file
3
packages/client/connection/tsdown.config.ts
Normal file
@@ -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
packages/client/i18n/README.md
Normal file
16
packages/client/i18n/README.md
Normal 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
packages/client/i18n/package.json
Normal file
54
packages/client/i18n/package.json
Normal 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
packages/client/i18n/src/client/index.ts
Normal file
108
packages/client/i18n/src/client/index.ts
Normal 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
packages/client/i18n/src/index.ts
Normal file
11
packages/client/i18n/src/index.ts
Normal 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
packages/client/i18n/src/invariant.ts
Normal file
32
packages/client/i18n/src/invariant.ts
Normal 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 */
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user