Merge PR #500 into CI optimization

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

71
packages/client/AGENTS.md Normal file
View File

@@ -0,0 +1,71 @@
# AGENTS.md — Web client stack
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
## Layering red lines
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
1. **Data object layer** (`web-runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
Non-negotiables across the layers:
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (`web-ui/src`)
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
Two-level feature directories, one contributor per directory — physical conflict avoidance:
```
web-ui/src/
shell/ # AppShell + the three slot registries + builtins
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
components/ # shared leaves (MessageText, JsonBlock, …)
hooks/ utils/ style/ # cross-cutting; not feature-owned
```
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
## Styling
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
## Testing and coverage
The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
## Before you push: the local check ladder
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
## New component checklist
1. Claim the slot (see the directory regime above): one feature, one directory.
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.

View File

@@ -0,0 +1,16 @@
# @deepseek-ai/dsh-client-connection
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.

View File

@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-client-connection",
"description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View 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: {} },
}
}

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

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

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

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

View File

@@ -0,0 +1,10 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
*/
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../util/brand"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../support/invariants"
}
],
"exclude": [
"**/*.legacy.*"
]
}

View 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'])

View File

@@ -0,0 +1,16 @@
# @deepseek-ai/dsh-client-i18n
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
## Model Experience
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-client-i18n",
"description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-web-react": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,108 @@
/**
* i18n plugin, browser half: namespace x locale dictionary registry with a
* bound translate function whose reference is stable (safe for inject
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
* Contract: api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
declare module 'cordis' {
interface Context {
i18n: I18nService
}
}
/** Fallback locale consulted after the active locale misses. */
export const FALLBACK_LOCALE = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/**
* Dictionary registry plus locale switch. Lookup chain per key: active locale
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
* the UI rather than blank).
*/
export class I18nService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the locale store at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
/** Active locale store (switching re-renders the tree; low frequency). */
get locale(): SnapshotStore<string> {
return this.localeStore
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/**
* Client plugin body: provide the i18n service with base dictionaries.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const i18n = new I18nService()
i18n.register(COMMON_NS, 'zh', zh)
i18n.register(COMMON_NS, 'en', en)
ctx.provide('i18n', i18n)
}

View File

@@ -0,0 +1,11 @@
/**
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Everything else —
* I18nService, Translate, LocaleDict — lives in the client half; consumers
* import the /client subpath. Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the i18n plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
* @module @deepseek-ai/dsh-client-i18n/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
/** Cordis companion plugin name. */
export const name = 'client-i18n-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: ns-by-locale dictionary registry with a stable
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
* mutable relation; fallback-chain resolution and locale-store behavior are
* asserted directly by this package's behavior specs.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,2 @@
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const en: Record<string, string> = {}

View File

@@ -0,0 +1,2 @@
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const zh: Record<string, string> = {}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
describe('I18nService', () => {
it('translates from the active locale with zh fallback then key passthrough', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
i18n.register('ns', 'en', { hello: 'Hello' })
const t = i18n.bind('ns')
expect(i18n.locale.getSnapshot()).toBe('zh')
expect(t('hello')).toBe('你好')
i18n.locale.set('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = i18n.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
expect(t('greet')).toBe('你好,{name}!第 {n} 次')
})
it('bind returns a stable reference per namespace', () => {
const i18n = new I18nService()
expect(i18n.bind('a')).toBe(i18n.bind('a'))
expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
})
it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
const i18n = new I18nService()
const dispose = i18n.register('ns', 'zh', { k: 'v1' })
expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
dispose()
const t = i18n.bind('ns')
expect(t('k')).toBe('k')
i18n.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
})
it('locale store is subscribable (snapshot store contract)', () => {
const i18n = new I18nService()
let notified = 0
i18n.locale.subscribe(() => { notified += 1 })
i18n.locale.set('en')
expect(i18n.locale.getSnapshot()).toBe('en')
expect(notified).toBe(1)
})
})

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
expect(inject).toEqual([])
const ctx = new Context()
await ctx.plugin({ inject, apply: clientApply }).await()
const i18n = ctx.get('i18n')
expect(i18n).toBeInstanceOf(I18nService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../web-react"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-client-runtime
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.

View File

@@ -0,0 +1,63 @@
{
"name": "@deepseek-ai/dsh-client-runtime",
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./loader": {
"types": "./lib/types/client/loader/index.d.ts",
"default": "./lib/loader.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection"
],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-session": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/loader.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,128 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService, SessionsService (list store + scope tree + object layer),
* the ClientLoader interface, and the cordis Context/Events merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export { SessionManager } from './sessions/manager.ts'
export type { SessionListSnapshot } from './sessions/manager.ts'
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
export type { SessionListEntry } from './sessions/lineage.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
// concrete types live here, where their subjects live) ----
/**
* The client cordis context face: the base Context plus the service keys
* this package's declaration merge contributes (slots/sessions/loader) and
* every later plugin's merge. A plain alias — the merges land on Context
* itself inside the client program; the name marks intent at consumer seams.
*/
export type ClientContext = Context
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
* settled (result node). The fold produces both shapes; toolview components
* narrow on the discriminant fields.
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module 'cordis' {
interface Events {
/**
* A slot's definition or registration set changed.
* @mode emit
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
loader: ClientLoader
}
}
/** One __DSH_BOOT__ manifest row. */
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
/** Per-plugin load status store shape. */
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
/**
* Client bundle loader. The immediately group loads first (parallel fetch,
* apply in inject topology order); remaining plugins follow in inject
* topology. Loaded bundle export surfaces are registered back into the
* require module table. Implementation lives in the `./loader` subpath
* (shell-held machinery).
*/
export interface ClientLoader {
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
start(): void
/**
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
* @param id - plugin id (package name).
*/
load(id: string): Promise<void>
/**
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
* @param id - plugin id.
*/
unload(id: string): Promise<void>
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
settled(): Promise<void>
/**
* Read a loaded module's export surface from the module table (same
* implementation the bundle-facing require uses; unknown spec throws).
* @param spec - module specifier (package name or seeded library id).
*/
requireModule(spec: string): unknown
/** Per-plugin status store. */
readonly status: SnapshotStore<LoaderStatus>
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']
/**
* Client plugin body: mount slots + sessions, start the stream loop.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
onConnected: () => { sessions.manager.handleConnected() },
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}

View File

@@ -0,0 +1,247 @@
/**
* ClientLoader implementation (shell-held machinery — the loader cannot load
* itself, so the web shell imports this subpath statically and mounts the
* instance as ctx.loader; the runtime package's own client bundle never
* includes it).
*
* Load chain per plugin: fetch bundle text → execute (script injection) → the
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
* handoff, id reconciled) → factory(require) with require bound to the module
* table → ctx.plugin(exports.apply) → the export surface is registered into
* the module table under the plugin id (inject topology guarantees later
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
*
* start(): the `immediately` group is fetched in parallel and executed in
* group-internal inject topology (execution is serial — the handoff slot is
* single); a full-group barrier precedes the remaining plugins, which then
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
export interface ClientPluginHandoff {
/** Plugin id (package name) — must match the manifest row being loaded. */
id: string
/**
* Closure factory: receives the DI require and returns the module's export
* surface; an `apply` export is applied as a cordis plugin.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface the loader owns (bundle side of the handoff protocol). */
interface DshWindow {
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
}
/** Options for createClientLoader (assembled by the web shell at boot). */
export interface ClientLoaderOptions {
/** Client root context: plugin applies mount under it. */
ctx: Context
/**
* Seeded module table: pure-library entities (react, react-dom, cordis,
* ui-slots, web-react, ui-primitives). The loader takes ownership and
* registers loaded bundle export surfaces alongside them.
*/
modules: Record<string, unknown>
/**
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
* same protocol shape.
*/
boot?: { plugins: BootPluginEntry[] }
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (serial half; execution synchronously performs the
* loadPlugin handoff). Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/** Per-plugin bookkeeping across the load chain. */
interface PluginRecord {
entry: BootPluginEntry
state: 'idle' | 'loading' | 'active' | 'failed'
fetch?: Promise<string>
load?: Promise<void>
}
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
/**
* Build the client bundle loader.
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
* @returns the ClientLoader the shell mounts as ctx.loader.
*/
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
const { ctx } = options
const win = globalThis as DshWindow
const boot = options.boot ?? win.__DSH_BOOT__
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
const modules = new Map<string, unknown>(Object.entries(options.modules))
const records = new Map<string, PluginRecord>()
for (const entry of boot.plugins) {
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
records.set(entry.id, { entry, state: 'idle' })
}
const status = createSnapshotStore<LoaderStatus>({})
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
status.update((draft) => { draft[id] = state })
}
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
// doLoad arms the slot before executing and reconciles the id after.
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
win.DSHClientProxy = {
loadPlugin: (handoff: ClientPluginHandoff): void => {
if (slot !== NOT_LOADED) {
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
}
slot = handoff
},
}
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
})
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
})
const requireModule = (spec: string): unknown => {
if (!modules.has(spec)) {
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
}
return modules.get(spec)
}
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
const claimStyles = (id: string): void => {
if (typeof document === 'undefined') return
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
}
/** Start (or reuse) the parallelizable fetch half. */
const prefetch = (record: PluginRecord): Promise<string> =>
(record.fetch ??= fetchBundle(record.entry.url))
async function doLoad(record: PluginRecord): Promise<void> {
const { id } = record.entry
record.state = 'loading'
publish(id, 'loading')
try {
// Dependencies must already be active (start() sequences this; direct
// load() callers get the same fail-loud check).
for (const dep of record.entry.inject) {
const depRecord = records.get(dep)
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
}
const code = await prefetch(record)
executeBundle(code, record.entry.url)
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
const handoff = slot
slot = NOT_LOADED
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
const exports = handoff.factory(requireModule)
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
// The whole export surface is the plugin: cordis object-plugin form
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
// silently drop the dependency declaration — postmortem 0001).
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
await fiber.await()
// Register under both specifier forms bundles emit: the bare package
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
// form) — the loaded surface IS the client half either way.
modules.set(id, exports)
modules.set(`${id}/client`, exports)
claimStyles(id)
record.state = 'active'
publish(id, 'active')
} catch (error) {
record.state = 'failed'
publish(id, 'failed')
throw error
}
}
const load = (id: string): Promise<void> => {
const record = records.get(id)
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
record.load ??= doLoad(record)
return record.load
}
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
const topo = (ids: string[]): string[] => {
const pool = new Set(ids)
const ordered: string[] = []
const done = new Set<string>()
const visiting = new Set<string>()
const visit = (id: string): void => {
if (done.has(id)) return
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
visiting.add(id)
const record = records.get(id)
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
for (const dep of record.entry.inject) {
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (pool.has(dep)) visit(dep)
}
visiting.delete(id)
done.add(id)
ordered.push(id)
}
for (const id of ids) visit(id)
return ordered
}
let settledPromise: Promise<void> | undefined
async function run(): Promise<void> {
const all = [...records.values()]
const early = all.filter(r => r.entry.immediately === true)
const rest = all.filter(r => r.entry.immediately !== true)
// Early group: parallel fetch (all requests in flight at once), serial
// inject-topology execution, full-group barrier before anything else.
const earlyOrder = topo(early.map(r => r.entry.id))
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
for (const id of earlyOrder) await load(id)
// Remaining plugins: one by one in inject topology.
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
}
return {
start: () => {
settledPromise ??= run()
// Failures surface through settled()/status — start() itself is fire-and-forget.
settledPromise.catch(() => {})
},
load,
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
settled: () => {
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
return settledPromise
},
requireModule,
status,
}
}

View File

@@ -0,0 +1,165 @@
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
// Immutability contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
/**
* core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end).
* @param content - core content blocks verbatim.
* @returns UI-classified blocks in source order.
*/
export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
return content.map(toAssistantBlock)
}
/**
* Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw).
* @param block - one core content block.
* @returns the UI classification.
*/
export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}
}
/** A finalized user message. */
export interface UserMessageNode {
kind: 'user'
seq: number
content: readonly ContentBlock[]
source: unknown
}
/** A finalized (or interruption-frozen) assistant message. */
export interface AssistantMessageNode {
kind: 'assistant'
seq: number
turn: number
step: number
blocks: readonly AssistantBlock[]
usage?: unknown
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
interrupted?: true
}
/** A steering message injected mid-turn. */
export interface SteeringMessageNode {
kind: 'steering'
seq: number
turn: number
content: readonly ContentBlock[]
source: unknown
}
/** A context/system injection surfaced in the flow. */
export interface ContextMessageNode {
kind: 'context'
seq: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
seq: number
callId: string
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
call: { name: string; argsRaw: string } | null
content: readonly ContentBlock[]
isError: boolean
error?: { name: string; code: string }
meta?: unknown
/** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
callView: ToolCallView | null
/** Host-computed render intent from this tool/result's wire view; null = same default. */
resultView: ToolResultView | null
}
/** Fallback for surface events this UI version does not know. */
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
type: string
data: unknown
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ToolResultNode
| UnknownSurfaceNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
name: string
argsRaw: string
turn: number
step: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null
}
/** Approval/question placeholder cards (visible, not answerable;
* rpcId = the requested frame's envelope id, the future respond backfill key). */
export type PendingInteraction =
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {
turn: number
step: number
blocks: readonly AssistantBlock[]
}
/** History-open lifecycle of a Session window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
error: RpcError
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
pending: readonly PendingInteraction[]
running: boolean
/** Set after host/session-removed; the UI grays out and disables input. */
removed: boolean
openState: OpenState
openError: RpcError | null
hasMore: boolean
loadingOlder: boolean
promptError: PromptError | null
lastAgentError: string | null
}

View File

@@ -0,0 +1,194 @@
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
// the degradation lives in one branch function in this file, zero scattered removal points).
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
export interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
* place a synthetic event enters the window). */
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
}
case 'steering/message':
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
case 'context/message':
return {
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
return {
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
content: event.data.content, isError: event.data.isError,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
}
}
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
export class FoldAdapter {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = []
private baseSeq = 0
private surface = new SurfaceManager(this.padded)
private nodeCache = new Map<number, ConversationNode>()
private degraded = false
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
return this.callIdx
}
/**
* Window rebuild (after open/resync/page prepend): new padded array, new
* SurfaceManager, cleared cache, rebuilt callIndex.
* @param events - the new window contents (seq-ascending).
* @param baseSeq - seq of the window head (sentinels pad below it).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = false
this.callIdx = new Map()
this.resultViews.clear()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.indexCall(event, views?.[i])
}
}
/**
* Tail append (live session/event): push into the same array (incremental
* lazy fold applies) + incremental callIndex upkeep.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
this.indexCall(event, view)
}
/**
* Current node array + degradation flag. Same revision -> same array
* reference (memo boundary); node object references always come from the per-seq cache.
* @returns the fold projection for the current window revision.
*/
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
let seqs: readonly number[]
if (this.degraded) {
seqs = this.degradedSeqs()
} else {
try {
seqs = this.surface.nodes
} catch (error) {
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
this.degraded = true
seqs = this.degradedSeqs()
}
}
const out: ConversationNode[] = []
for (const seq of seqs) {
const cached = this.nodeCache.get(seq)
if (cached !== undefined) {
out.push(cached)
continue
}
const event = this.padded[seq]
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
if (event === undefined) continue
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
this.nodeCache.set(seq, node)
out.push(node)
}
const value = { nodes: out, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
private degradedSeqs(): number[] {
const seqs: number[] = []
for (let i = this.baseSeq; i < this.padded.length; i++) {
const event = this.padded[i]
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
}
return seqs
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}

View File

@@ -0,0 +1,63 @@
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
// degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
/** One flattened session-list row (summary + lineage indent depth). */
export interface SessionListEntry {
sessionId: SessionId
updatedAt: number
running: boolean
parentSessionId?: SessionId
cwd?: string
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
/**
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, SessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
const children = new Map<SessionId, SessionSummary[]>()
const roots: SessionSummary[] = []
for (const s of summaries) {
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
const list = children.get(s.parentSessionId) ?? []
list.push(s)
children.set(s.parentSessionId, list)
} else {
roots.push(s) // root, or an orphan whose parent is absent from summaries (degrade to root, never drop)
}
}
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: SessionSummary, depth: number): void => {
if (visited.has(s.sessionId)) {
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
return
}
visited.add(s.sessionId)
out.push({ ...s, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)
for (const kid of kids) walk(kid, depth + 1)
}
for (const root of roots) walk(root, 0)
// Cycle members (unreachable from any root): emit as roots so no entry is lost.
for (const s of summaries) {
if (!visited.has(s.sessionId)) walk(s, 0)
}
return out
}

View File

@@ -0,0 +1,250 @@
// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionListEntry } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
state: 'idle' | 'loading' | 'error'
error: RpcError | null
}
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
/** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
* history (cannot be backfilled on open), the one frame class that must not take the
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
private listError: RpcError | null = null
private listInflight: Promise<void> | null = null
private listSnapshotCache: SessionListSnapshot
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
* object when every field matches — wire refreshes mint all-new summary objects, so identity
* must be recovered by value or every SessionListItem memo misses on every refresh (audit S5). */
private entryCache = new Map<SessionId, SessionListEntry>()
private itemsCache: readonly SessionListEntry[] = []
private readonly notifier = new Notifier(() => {
this.listSnapshotCache = this.buildListSnapshot()
})
constructor(private readonly api: IApiClient) {
this.listSnapshotCache = this.buildListSnapshot()
}
// ---- Instance management ----
/**
* Lazy build: return the existing instance or construct one (no auto-open —
* open is triggered by the container's select callback).
* @param sessionId - the session to get.
* @returns the resident instance.
*/
get(sessionId: SessionId): Session {
let session = this.sessions.get(sessionId)
if (session === undefined) {
session = new Session(sessionId, this.api)
this.sessions.set(sessionId, session)
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) session.handleRunning(summary.running)
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
const buffered = this.pendingBuffers.get(sessionId)
if (buffered !== undefined) {
this.pendingBuffers.delete(sessionId)
for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
}
}
return session
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
refreshList(): Promise<void> {
if (this.listInflight !== null) return this.listInflight
this.listState = 'loading'
this.listError = null
this.notifier.markDirty()
this.listInflight = (async () => {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
this.summaries = result.value.items
this.listState = 'idle'
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
} else {
this.listState = 'error'
this.listError = result.error
}
} catch (error) {
this.listState = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.listError = folded.ok ? null : folded.error
} finally {
this.listInflight = null
this.notifier.markDirty()
}
})()
return this.listInflight
}
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh).
* @param cwd - optional working directory for the new session.
* @returns the create result.
*/
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
this.summaries = [
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
...this.summaries,
]
this.notifier.markDirty()
}
return result
} catch (error) {
return transportError(error)
}
}
// ---- Subscription surface (for useSessionList) ----
/**
* uSES subscription entry for useSessionList.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Cached list snapshot (rebuilt lazily when dirty with no listeners).
* @returns the cached reference (stable until the next flush).
*/
getListSnapshot(): SessionListSnapshot {
this.notifier.ensureFresh()
return this.listSnapshotCache
}
// ---- ConnectionController sinks (wired by boot) ----
/**
* Mux frame entry: sessionId-bearing frames go only to instantiated sessions
* (no lazy build; non-pending frames for uninstantiated sessions drop —
* history backfills them on open).
* @param envelope - the frame with its wire rpcId.
*/
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
// everything else drops (not instantiated — history fully backfills on open).
switch (frame.type) {
case 'approval/requested':
case 'approval/resolved':
case 'question/requested':
case 'question/resolved': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
this.pendingBuffers.set(frame.sessionId, buffer)
return
}
default:
return
}
}
session.handleMuxEnvelope(envelope.rpcId, frame)
}
/**
* Host frame entry: list upkeep + per-instance running/removed/agent-error relay.
* @param envelope - the frame with its wire rpcId.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
const frame = envelope.payload
switch (frame.type) {
case 'host/session-added': {
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
this.summaries = [
{
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
},
...this.summaries,
]
this.notifier.markDirty()
}
return
}
case 'host/session-removed': {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.notifier.markDirty()
return
}
case 'host/session-status': {
this.summaries = this.summaries.map(s =>
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.notifier.markDirty()
return
}
case 'host/agent-error': {
this.sessions.get(frame.sessionId)?.handleAgentError(frame.message)
return // not reflected in the list
}
default:
return // stream/error ignored; unknown frames ignored (documented default)
}
}
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
handleConnected(): void {
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
}
private buildListSnapshot(): SessionListSnapshot {
const fresh = flattenLineage(this.summaries)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry
})
for (const id of this.entryCache.keys()) {
if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id)
}
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
return { items: this.itemsCache, state: this.listState, error: this.listError }
}
}

View File

@@ -0,0 +1,61 @@
// Notifier: subscription + microtask-batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private scheduled = false
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
/**
* uSES subscription entry.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
/** State-change entry: mark dirty and schedule the batched flush. */
markDirty(): void {
this.dirty = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.dirty) return
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
})
}
/**
* Synchronous flush: controlled-input writes must notify in the same tick as
* onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
*/
notifyNow(): void {
this.dirty = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
}
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
ensureFresh(): void {
if (!this.dirty) return
this.dirty = false
this.rebuild()
}
}

View File

@@ -0,0 +1,89 @@
// PartialAccumulator: assistant/chunk accumulator.
// Folds the six StreamChunk variants into AssistantBlock[] keyed by block index;
// block-level immutability (a delta only swaps that block's reference).
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
private blocks: (AssistantBlock | undefined)[] = []
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
}
/**
* Fold one chunk.
* @param chunk - the stream chunk.
* @returns whether it caused a visible change (usage/finish return false, skipping notification).
*/
push(chunk: StreamChunk): boolean {
switch (chunk.type) {
case 'block-start': {
this.blocks[chunk.index] = emptyBlock(chunk.blockType)
this.changed = true
return true
}
case 'text-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'text', text: (prev?.kind === 'text' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'reasoning-delta': {
const prev = this.blocks[chunk.index]
this.blocks[chunk.index] = { kind: 'reasoning', text: (prev?.kind === 'reasoning' ? prev.text : '') + chunk.text }
this.changed = true
return true
}
case 'tool-call-delta': {
const prev = this.blocks[chunk.index]
const base = prev?.kind === 'tool-call' ? prev : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
this.blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
}
this.changed = true
return true
}
case 'block-end': {
this.blocks[chunk.index] = toAssistantBlock(chunk.block)
this.changed = true
return true
}
default:
// usage / finish / merge-extensible unknown variants: no visible block change
// (finish is immediately followed by the assistant/message that supersedes the partial).
return false
}
}
/**
* Current partial projection.
* @returns the cached snapshot (the blocks array reference only changes after a mutation).
*/
toPartial(): PartialAssistant {
if (this.changed) {
// Compact sparse indexes (out-of-order block-start) into render order.
this.snapshot = { turn: this.turn, step: this.step, blocks: this.blocks.filter((b): b is AssistantBlock => b !== undefined) }
this.changed = false
}
return this.snapshot
}
}
function emptyBlock(blockType: string): AssistantBlock {
switch (blockType) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
default: return { kind: 'other', block: null }
}
}

View File

@@ -0,0 +1,227 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
* nobody is watching it. "Watched" is approximated as the most recently
* resolved binding id — SessionProvider re-resolves on every selection
* change (keyed remount), so a switch away always re-evaluates the deferred
* teardown; a host-side death without list removal keeps the scope (frozen
* read-only view).
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
title: string
cwd?: string
parentId?: SessionId
running: boolean
updatedAt: number
}
/** Session list store shape. */
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
readonly session: Session
readonly ctx: Context
}
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
const kScope = Symbol('dsh.client.scope')
/**
* Read the session scope tag off a context.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
export function scopeOf(ctx: Context): SessionId | undefined {
return (ctx as Context & { [kScope]?: SessionId })[kScope]
}
/** Shared no-op plugin backing each session scope fiber. */
function sessionScope(): void {}
/**
* Display title projection. The wire summary carries no title yet (P-I
* ledger): the project directory's basename stands in, then the raw id.
*/
function titleOf(cwd: string | undefined, id: SessionId): string {
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
}
return id
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
}
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.manager = new SessionManager(api)
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
* @returns the new session id.
*/
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
const result = await this.manager.create(opts.cwd)
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
return result.value.sessionId
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
return this.resolve(id)?.ctx
}
/**
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
const record = this.resolve(id)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id
this.sweepDeferred()
}
return record.binding
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* @param id - session id.
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
*/
ancestry(id: SessionId): SessionSummary[] {
const { byId } = this.list.getSnapshot()
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session: this.manager.get(id), ctx },
}
this.scopes.set(id, record)
return record
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const items = this.manager.getListSnapshot().items
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
title: titleOf(entry.cwd, entry.sessionId),
running: entry.running,
updatedAt: entry.updatedAt,
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
this.list.set({ ids, byId })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
}
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the watched id ever defers, and every
* watch move sweeps first, so the set cannot contain the id the watch just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
if (this.list.getSnapshot().byId[id] !== undefined) {
this.deferredRemovals.delete(id)
continue
}
const record = this.scopes.get(id)
this.deferredRemovals.delete(id)
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
* together, so a deferred id always still owns its record; kept so a
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
void record.fiber.dispose()
}
}
}
}

View File

@@ -0,0 +1,527 @@
// Session: wraps every contract call that needs a sessionId + all conversation state for this
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
} from './conversation.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
export const PAGE_MESSAGES = 50
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
private views: (ToolEventView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
private openError: RpcError | null = null
private openPromise: Promise<void> | null = null
/** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly foldAdapter = new FoldAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
private callsRev = 0
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
private running = false
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null
private snapshotCache: ConversationSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
this.snapshotCache = this.buildSnapshot()
}
// ---- Operations ----
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - core content blocks verbatim.
* @param mode - queue appends after the current turn; steer interrupts it.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
} catch (error) {
result = transportError(error)
}
if (!result.ok) {
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
}
return result
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}
if (!result.ok) {
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
}
return result
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
if (this.openPromise !== null) return this.openPromise
const promise = this.doOpen(this.openGeneration).finally(() => {
// Identity-guarded: a superseded open must not null out the promise resync just started.
if (this.openPromise === promise) this.openPromise = null
})
this.openPromise = promise
return promise
}
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
this.hasMore = false
return
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
} finally {
this.loadingOlder = false
this.notifier.markDirty()
}
}
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
async resync(): Promise<void> {
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
this.openGeneration++
this.openPromise = null
this.openState = 'cold'
this.openError = null
this.events = []
this.views = []
this.baseSeq = 0
this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.notifier.markDirty()
await this.open()
}
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
/**
* uSES subscription entry.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Cached conversation snapshot (rebuilt lazily when dirty with no listeners).
* @returns the cached reference (stable until the next flush).
*/
getSnapshot(): ConversationSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
// ---- Manager-only entry points (@internal; never called by the UI) ----
/**
* Mux frame arrival (the dispatch switch).
* @param rpcId - the frame envelope id (the respond backfill key for requested frames).
* @param frame - the routed frame.
*/
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
switch (frame.type) {
case 'session/event': {
this.acceptLiveEvent(frame.event, frame.view)
return
}
case 'session/subscribed': {
this.subscribedLastSeq = frame.lastSeq
return // pure baseline bookkeeping, no visible change
}
case 'approval/requested': {
this.pending.set(`a:${rpcId}`, {
kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
...(frame.callId !== undefined ? { callId: frame.callId } : {}),
...(frame.reason !== undefined ? { reason: frame.reason } : {}),
})
this.pendingRev++
this.notifier.markDirty()
return
}
case 'approval/resolved': {
for (const [key, item] of this.pending) {
if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
this.pending.delete(key)
this.pendingRev++
}
}
this.notifier.markDirty()
return
}
case 'question/requested': {
this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
this.pendingRev++
this.notifier.markDirty()
return
}
case 'question/resolved': {
if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
this.notifier.markDirty()
return
}
default:
return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default)
}
}
/**
* Running-bit relay from the host stream (list entry and snapshot stay consistent).
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
if (this.running === running) return
this.running = running
this.notifier.markDirty()
}
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
handleRemoved(): void {
this.removed = true
this.notifier.markDirty()
}
/**
* host/agent-error relay: the only outlet for live failures with no turn position.
* @param message - the stringified error.
*/
handleAgentError(message: string): void {
this.lastAgentError = message
this.notifier.markDirty()
}
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
dispose(): void {}
// ---- 私有 ----
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
private async doOpen(generation: number): Promise<void> {
this.openState = 'loading'
this.openError = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
this.openState = 'error'
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
}
this.openState = 'open'
} catch (error) {
if (generation !== this.openGeneration) return
this.openState = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.openError = folded.ok ? null : folded.error
} finally {
if (generation === this.openGeneration) this.notifier.markDirty()
}
}
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
this.notifier.markDirty()
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: ToolEventView): void {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.foldAdapter.append(event, view)
this.applyEventSideEffects(event, view)
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
* its continuity assertion into the degraded view). */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
return
}
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
void this.repairGap()
return
}
this.appendLive(event, view)
this.notifier.markDirty()
}
/** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
* installWindow path. No openState transition — the UI keeps the current window (no loading
* flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */
private async repairGap(): Promise<void> {
/* v8 ignore next -- re-entry guard: acceptLiveEvent already detours to liveBuffer while stitching, so no second call reaches here. */
if (this.stitching) return
this.stitching = true
const generation = this.openGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
} finally {
this.stitching = false
}
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
this.partial.push(chunk)
return
}
case 'assistant/message': {
if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) {
this.partial = null // finalize swaps in place (same notification batch, no flicker)
}
return
}
case 'tool/call': {
this.openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step,
callView: view?.for === 'call' ? view.view : null,
})
this.callsRev++
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
return
}
case 'turn/end': {
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
// from the logged chunks. Content-free partials are dropped outright.
if (this.partial !== null && this.partial.turn === event.data.turn) {
const { blocks } = this.partial.toPartial()
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.frozenNodes.push({
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.frozenRev++
}
this.partial = null
}
let callOffset = 0
for (const [callId, call] of this.openCalls) {
if (call.turn !== event.data.turn) continue
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.frozenNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
call: { name: call.name, argsRaw: call.argsRaw },
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})
this.frozenRev++
}
return
}
default:
return
}
}
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.callsRev++
this.frozenNodes = []
this.frozenRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.applyEventSideEffects(event, this.views[i])
}
}
private windowTailSeq(): number | null {
const tail = this.events[this.events.length - 1]
return tail === undefined ? null : tail.seq
}
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
nodes = this.nodesCache.value
} else {
nodes = this.frozenNodes.length === 0
? folded
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial: this.partial?.toPartial() ?? null,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
running: this.running,
removed: this.removed,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
lastAgentError: this.lastAgentError,
}
}
}

View File

@@ -0,0 +1,107 @@
/**
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
* run through the caller's ctx.effect so a plugin's registrations are
* collected when its fiber unloads (cordis-native cascade).
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from './index.ts'
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
export class SlotsService extends Service {
private readonly _core = new SlotCore()
/**
* @param ctx - owning root context.
*/
constructor(ctx: Context) {
super(ctx, 'slots')
this._core.onMutate((key) => { ctx.emit('slots/changed', key) })
}
/**
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param spec - kind/scope spec.
* @returns disposer.
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
}
/**
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param component - contributed component.
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
* inject factory's binding is pinned to ClientContext.
* @returns disposer.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
// Client-context registrations have exactly one ctx shape: pin Ctx to
// ClientContext so inject factories dot services without a cast.
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
}
/**
* Snapshot entries for a key.
* @param key - SlotMap key.
* @returns registered entries (stable reference between mutations).
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
return this._core.entries(key)
}
/**
* Look up a defined spec.
* @param key - SlotMap key.
* @returns spec or undefined.
*/
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
return this._core.spec(key)
}
/**
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
* @param key - candidate slot key.
* @returns wide-typed spec or undefined.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this._core.specDynamic(key)
}
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - SlotMap key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
return this._core.subscribe(key, fn)
}
/**
* Version counter for uSES pairing.
* @param key - SlotMap key.
* @returns current version.
*/
getVersion(key: keyof SlotMap & string): number {
return this._core.getVersion(key)
}
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
get core(): SlotCore {
return this._core
}
}

View File

@@ -0,0 +1,11 @@
/**
* Runtime plugin, node half. The implementation lives entirely in the client
* half (src/client/ — SlotsService, SessionsService + object layer, and the
* shell-held ClientLoader under ./loader); consumers import the /client or
* /loader subpaths. The empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery). Contract:
* api-contracts v3 section 4.
*/
/** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {}

View File

@@ -0,0 +1,52 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`.
* @module @deepseek-ai/dsh-client-runtime/invariant
*/
/* jscpd:ignore-start */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import type { Context } from 'cordis'
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime'
/** Cordis companion plugin name. */
export const name = 'client-runtime-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']
/**
* Owned relation: every 'slots/changed'(key) emission must observe the
* mutation already applied — SlotCore bumps the key's version synchronously
* before the service re-emits, so a zero version at dispatch time means the
* event fired without (or ahead of) its mutation.
*/
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'slots/changed') return
const key: unknown = args[0]
if (typeof key !== 'string' || key === '') {
fail("'slots/changed' dispatched without a slot key argument")
return
}
const slots = ctx.get('slots')
// Event payloads carry keys as plain strings; getVersion is statically
// keyed, so restore the SlotMap-key type after the runtime string check.
if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,64 @@
/**
* Runtime plugin browser-half apply: slots + sessions mounting over the
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
interface Bench {
ctx: Context
api: FakeApiClient
sinks: ConnectionSinks | undefined
stopped: number
}
async function mount(): Promise<Bench> {
const ctx = new Context()
const api = new FakeApiClient()
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
},
}
ctx.reflect.provide('connection', handle)
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
const sessions = bench.ctx.get('sessions')
expect(sessions !== undefined).toBe(true)
expect(bench.sinks).toBeDefined()
// Frame sinks reach the object layer: a host session-added lands in the list store.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-added', sessionId: 's-new' } as never,
})
await Promise.resolve()
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
// Dispose the whole tree: the ctx.effect teardown must call loop.stop exactly once.
await bench.ctx.fiber.dispose()
expect(bench.stopped).toBe(1)
void fiber
})
})

View File

@@ -0,0 +1,77 @@
/**
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
* through the loader chain (execute → handoff → factory(require) → apply →
* export re-registration). Skips when the bundle is not built (lib/client.js is a
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as webReact from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { SlotsService } from '../src/client/slots.ts'
import { FakeApiClient } from './fake-api.ts'
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
afterEach(() => {
delete (globalThis as Win).DSHClientProxy
delete (globalThis as Win).window
})
function readLayoutBundle(): string | undefined {
try {
const require = createRequire(import.meta.url)
return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
} catch {
return undefined
}
}
describe('real tsdown bundle through the loader', () => {
const code = readLayoutBundle()
it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
// The bundle banner addresses window.DSHClientProxy; node has no window —
// alias it to globalThis so the loader-installed proxy is reachable.
;(globalThis as Win).window = globalThis
const ctx = new Context()
// The layout apply consumes the slots + sessions services; the real chain
// loads the runtime bundle first — stand both up directly here.
ctx.plugin(SlotsService)
await ctx.fiber.await()
new SessionsService(ctx, new FakeApiClient())
const loader = createClientLoader({
ctx,
// The real bundle externals resolved from the seeded table. React is a
// type-only import in the layout bundle today, but jsx-runtime is real.
modules: {
'react': await import('react'),
'react/jsx-runtime': await import('react/jsx-runtime'),
'@deepseek-ai/dsh-client-ui-slots': uiSlots,
'@deepseek-ai/dsh-client-web-react': webReact,
},
boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
fetchBundle: () => Promise.resolve(code as string),
// node has no DOM: evaluate the bundle body directly (same synchronous
// handoff contract as the <script> path).
executeBundle: (bundleCode) => {
// Node has no <script>: Function-evaluating the built bundle IS the
// system under test (same synchronous handoff as the browser path).
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
new Function(bundleCode)()
},
})
loader.start()
await loader.settled()
expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
expect(typeof surface.apply).toBe('function')
})
})

View File

@@ -0,0 +1,289 @@
/**
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
* with export-surface re-registration, immediately-group barrier (parallel
* fetch / topology execution / full-group barrier), status store, settled,
* failure modes (missing handoff, unknown dep, cycle, unload stub).
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
const win = globalThis as Win
afterEach(() => {
delete win.DSHClientProxy
delete win.__DSH_BOOT__
})
interface FakeBundle {
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
}
interface Bench {
loader: ReturnType<typeof createClientLoader>
fetched: string[]
executed: string[]
fetchGate: Map<string, () => void>
}
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
function bench(
plugins: BootPluginEntry[],
bundles: Record<string, FakeBundle>,
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const ctx = new Context()
const fetched: string[] = []
const executed: string[] = []
const fetchGate = new Map<string, () => void>()
const loader = createClientLoader({
ctx,
modules: opts.modules ?? { react: { marker: 'react' } },
boot: { plugins },
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
executed.push(code)
const bundle = bundles[code]
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
if (typeof bundle.handoff === 'function') {
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
return
}
win.DSHClientProxy?.loadPlugin(bundle.handoff)
},
})
return { loader, fetched, executed, fetchGate }
}
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
handoff: require => ({
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
require,
...exports,
}),
})
describe('load chain', () => {
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
const applied: string[] = []
const b = bench(
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
{
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
'/plugins/feature/client.js': {
handoff: (require) => {
// Later loader requires the earlier one's export surface (inject topology guarantee).
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
const base = require(fakeBase) as { helper: string }
expect(base.helper).toBe('base-helper')
expect((require('react') as { marker: string }).marker).toBe('react')
return { apply: () => { applied.push('feature') } }
},
},
},
)
b.loader.start()
await b.loader.settled()
expect(applied).toEqual(['fake-base', 'feature'])
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
})
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
const b = bench(
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
{
'/plugins/a/client.js': okBundle(),
'/plugins/b/client.js': okBundle(),
'/plugins/later/client.js': okBundle(),
},
{ gated: ['/plugins/a/client.js'] },
)
b.loader.start()
await Promise.resolve()
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
expect(b.executed).toEqual([])
b.fetchGate.get('/plugins/a/client.js')?.()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
})
it('orders execution by inject topology within each group', async () => {
const b = bench(
[entry('z-ui', ['a-base']), entry('a-base')],
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
)
b.loader.start()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
})
})
describe('failure modes (fail loud)', () => {
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
expect(b.loader.status.getSnapshot().silent).toBe('failed')
})
it('rejects on manifest/handoff id mismatch', async () => {
const b = bench([entry('expected')], {
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
})
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
})
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
// Sequential benches: each loader owns the window proxy, so release it between them.
const fresh = <T>(build: () => T): T => {
delete win.DSHClientProxy
return build()
}
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
missing.loader.start()
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
const cyclic = fresh(() => bench(
[entry('p', ['q']), entry('q', ['p'])],
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
))
cyclic.loader.start()
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
applyless.loader.start()
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
})
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
const b = bench([], {})
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
// First bench installed the proxy; a second loader must refuse.
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
})
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
const b = bench(
[entry('dep', [], true), entry('needy', ['dep'])],
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
)
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
})
it('direct load() naming an unknown inject target fails loud', async () => {
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
})
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
// The fire-and-forget prefetch swallow arm must absorb the early
// rejection; the awaited load surfaces the same failure via settled().
const ctx = new Context()
delete win.DSHClientProxy
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
executeBundle: () => {},
})
loader.start()
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
})
it('unload is the P-I stub', async () => {
const b = bench([], {})
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
})
})
describe('DOM default seams (stubbed globals)', () => {
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
const origFetch = globalThis.fetch
const appended: { textContent?: string | null }[] = []
const styleTag = {
attrs: {} as Record<string, string>,
setAttribute(k: string, v: string) { this.attrs[k] = v },
}
const fakeDoc = {
createElement: () => {
const el = { textContent: null as string | null }
return el
},
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
querySelectorAll: () => [styleTag],
}
const g = globalThis as { document?: unknown; fetch: typeof fetch }
g.document = fakeDoc
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
? new Response('x', { status: 500 })
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
)
try {
delete win.DSHClientProxy
const ctx = new Context()
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
] },
// NO seams injected (keys omitted, not undefined — exactOptional):
// the DOM defaults are under test.
})
const seamHandoff: ClientPluginHandoff = {
id: 'seam-ok',
factory: () => ({ apply: () => {} }),
}
// Default executeBundle only APPENDS the script element (no execution in
// our fake DOM), so drive the handoff manually before load resolves it.
const loadOk = loader.load('seam-ok')
await Promise.resolve()
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
await loadOk
expect(appended).toHaveLength(1)
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
} finally {
g.fetch = origFetch
delete (globalThis as { document?: unknown }).document
}
})
})
describe('handoff slot protocol', () => {
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
delete win.DSHClientProxy
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
const proxy = (globalThis as Win).DSHClientProxy
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
.toThrow(/overlapping loadPlugin handoff/)
})
})

View File

@@ -0,0 +1,23 @@
/** Assistant block classifier (moved here with sessions/conversation.ts). */
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => {
it('classifies the four block shapes', () => {
const blocks: ContentBlock[] = [
{ type: 'text', text: '正文' },
{ type: 'reasoning', text: '思考' },
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
{ type: 'image', data: 'x' } as unknown as ContentBlock,
]
expect(toAssistantBlocks(blocks)).toEqual([
{ kind: 'text', text: '正文' },
{ kind: 'reasoning', text: '思考' },
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
{ kind: 'other', block: blocks[3] },
])
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
})
})

View File

@@ -0,0 +1,50 @@
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
/** One text content block (local helper). */
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
user: (seq: number, body: string): SessionEvent =>
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/start', data: { turn, step } }),
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }),
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
export function plainTurn(startSeq: number, turn: number, ask: string, answer: string): SessionEvent[] {
return [
ev.turnStart(startSeq, turn),
ev.user(startSeq + 1, ask),
ev.stepStart(startSeq + 2, turn),
ev.assistant(startSeq + 3, turn, answer),
ev.stepEnd(startSeq + 4, turn),
ev.turnEnd(startSeq + 5, turn),
]
}
/** Wrap raw events as view-less history entries (the wire shape history now returns). */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}

View File

@@ -0,0 +1,161 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let nextRpc = 0
export function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
}
export function err<T>(error: RpcError): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
}
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
interface StreamConn<F> {
feed(item: StreamItem<F>): void
}
export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
// Parameters carry local structural annotations: the CI lint lane runs
// without built lib/, so IApiClient's indexed-access types collapse to any
// and inferred parameters would trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false
/** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
* Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
holdStreamOpen = false
private heldOpens: (() => void)[] = []
releaseStreamOpens(): void {
const held = this.heldOpens
this.heldOpens = []
for (const fire of held) fire()
}
readonly events: IApiClient['events'] = {
mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.muxConns, signal, onOpen),
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
pushMux(frame: MuxFrame, rpcId?: string): void {
for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
pushHost(frame: HostFrame, rpcId?: string): void {
for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
endStreams(): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
}
failStreams(error: unknown): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
}
get openMuxCount(): number {
return this.muxConns.length
}
callsOf(method: string): unknown[] {
return this.calls.filter(c => c.method === method).map(c => c.payload)
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
const inbox: StreamItem<F>[] = []
let wake: (() => void) | null = null
const conn: StreamConn<F> = {
feed: (item) => {
inbox.push(item)
wake?.()
},
}
registry.push(conn)
if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
else if (!this.suppressStreamOpen) onOpen?.()
try {
while (!signal.aborted) {
while (inbox.length > 0) {
const item = inbox.shift() as StreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
yield item.envelope
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
registry.splice(registry.indexOf(conn), 1)
}
}
}

View File

@@ -0,0 +1,145 @@
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
* materialization, call-index backfill, and the degraded linear-scan branch.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
describe('FoldAdapter', () => {
it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
const adapter = new FoldAdapter()
const window = plainTurn(100, 5, '偏移问', '偏移答')
adapter.reset(window, 100)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(false)
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (cache identity)', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
expect(second.nodes[0]).toBe(first.nodes[0])
expect(second.nodes[1]).toBe(first.nodes[1])
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
adapter.reset(events, 0)
const { nodes } = adapter.nodes()
const kinds = nodes.map(n => n.kind)
expect(kinds).toContain('user')
expect(kinds).toContain('assistant')
expect(kinds).toContain('steering')
expect(kinds).toContain('context')
const result = nodes.find(n => n.kind === 'tool-result')
expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
const { nodes } = adapter.nodes()
expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes surface-eligible types it does not know as unknown nodes', () => {
const adapter = new FoldAdapter()
adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
const { nodes } = adapter.nodes()
// Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
// shapes; what matters is no throw and no misclassification into a known kind.
for (const node of nodes) expect(node.kind).toBe('unknown')
})
it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
const adapter = new FoldAdapter()
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset(window, 10)
const first = adapter.nodes()
expect(first.degraded).toBe(true)
expect(errorSpy).toHaveBeenCalled()
expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
const second = adapter.nodes()
expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
expect(adapter.callIndex.size).toBe(2)
})
it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
const adapter = new FoldAdapter()
const events = [
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
]
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
adapter.reset(events, 0, [callView, resultView] as never)
expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
})
it('attaches views on the live append path and defaults to null without views', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new FoldAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
const node = adapter.nodes().nodes[0]
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
})
})

View File

@@ -0,0 +1,47 @@
/**
* Runtime invariant companion: the 'slots/changed' emission-order audit —
* a fired key must already carry a bumped version (emission follows the
* applied mutation), bogus payloads fail loud, foreign events pass.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RuntimeInvariant from '../src/invariant.ts'
import { SlotsService } from '../src/client/slots.ts'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(RuntimeInvariant).await()
return ctx
}
const emit = (ctx: Context, event: string, ...args: unknown[]): void => {
;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args)
}
describe('runtime slots/changed invariant', () => {
it('passes foreign events and a legitimate mutation-then-emission sequence', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
// A real define bumps the version first and re-emits through onMutate —
// the audit sees version > 0 and stays quiet.
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
})
it('fails loud on a missing key and on an emission with no applied mutation', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'slots/changed', '') }).toThrow(/without a slot key/)
expect(() => { emit(ctx, 'slots/changed', 42) }).toThrow(/without a slot key/)
await ctx.plugin(SlotsService).await()
// Hand-emitted key that never saw a mutation: version 0 → violation.
expect(() => { emit(ctx, 'slots/changed', 'never-mutated') })
.toThrow(/before any mutation bumped its version/)
})
it('stays quiet when no slots service is mounted (nothing to audit against)', async () => {
const ctx = await setup()
expect(() => { emit(ctx, 'slots/changed', 'any-key') }).not.toThrow()
})
})

View File

@@ -0,0 +1,55 @@
/**
* flattenLineage: root ordering, DFS child expansion, orphan degradation, and
* cycle fail-soft (every entry always emitted, no infinite walk).
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
sessionId: id as SessionId, updatedAt, running: false,
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
})
describe('flattenLineage', () => {
it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
s('kid-old', 11, 'new-root'),
s('kid-new', 12, 'new-root'),
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
])
})
it('degrades an orphan (absent parent) to root level without dropping it', () => {
const out = flattenLineage([s('orphan', 20, 'ghost-parent'), s('root', 10)])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([['orphan', 0], ['root', 0]])
})
it('fails soft on a two-node cycle: all entries emitted, warn fired, no hang', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const out = flattenLineage([s('a', 20, 'b'), s('b', 10, 'a'), s('root', 30)])
expect(out.map(e => e.sessionId).sort()).toEqual(['a', 'b', 'root'])
expect(warnSpy).toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('handles a self-referencing entry as a cycle member', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const out = flattenLineage([s('self', 10, 'self')])
expect(out.map(e => e.sessionId)).toEqual(['self'])
expect(out[0]?.depth).toBe(0)
} finally {
warnSpy.mockRestore()
}
})
})

View File

@@ -0,0 +1,223 @@
/**
* SessionManager orchestration: lazy resident instances, list lifecycle, host
* frame routing, and the pending-frame buffer for uninstantiated sessions.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
return { sessionId, updatedAt: 100, running: false, ...over }
}
describe('instances', () => {
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
const session = manager.get(S1)
expect(manager.get(S1)).toBe(session) // resident: same instance forever
expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
})
it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// Uninstantiated: approval buffers, plain session/event drops.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
// Buffer cleared: a second instantiation of another id gets nothing.
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
for (let i = 0; i < 40; i++) {
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
})
describe('list lifecycle', () => {
it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api)
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await Promise.all([first, second])
expect(api.callsOf('session.list')).toHaveLength(1)
const snapshot = manager.getListSnapshot()
expect(snapshot.state).toBe('idle')
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
})
it('keeps the error in the list snapshot on failure', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
})
it('merges create into the list immediately without waiting for a refresh', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
const manager = new SessionManager(api)
const result = await manager.create()
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
})
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1)
const session = manager.get(S1)
manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } })
expect(session.getSnapshot().running).toBe(true)
expect(manager.getListSnapshot().items[0]?.running).toBe(true)
manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } })
expect(session.getSnapshot().lastAgentError).toBe('炸了')
manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
expect(session.getSnapshot().removed).toBe(true)
expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
})
})
describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.reject(new Error('list wire down'))
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
})
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const session = manager.get(S1)
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
await manager.refreshList()
expect(session.getSnapshot().running).toBe(true)
})
it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
await manager.create('/tmp/w')
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
await manager.create('/tmp/w') // same id returned: no duplicate row
expect(manager.getListSnapshot().items).toHaveLength(1)
api.onCreate = () => Promise.reject(new Error('create wire down'))
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
// Business error passes through untouched.
api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
expect(await manager.create()).toMatchObject({ ok: false })
})
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
let notified = 0
const unsubscribe = manager.subscribe(() => { notified++ })
await manager.refreshList()
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
const session = manager.get(S1)
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }])
// status flip for an unknown session only touches summaries (no crash).
manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } })
})
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
const before = manager.getListSnapshot()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
const after = manager.getListSnapshot()
expect(after.items).not.toBe(before.items)
const beforeS1 = before.items.find(e => e.sessionId === S1)
const afterS1 = after.items.find(e => e.sessionId === S1)
expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
// Same-order same-entries snapshot reuses the items array.
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } })
expect(manager.getListSnapshot().items).toBe(after.items)
})
it('carries parentSessionId from host/session-added into the lineage row', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
})
})
describe('connected generation', () => {
it('refreshes the list and resyncs only opened instances', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)
await openedSession.open()
manager.get(S2) // instantiated but never opened
const historyCallsBefore = api.callsOf('session.history').length
manager.handleConnected()
await vi.waitFor(() => {
expect(api.callsOf('session.list').length).toBe(1)
// Only the opened instance repulls history; the cold one stays silent.
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
})
})
})

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

View File

@@ -0,0 +1,75 @@
/**
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
* laziness, synchronous notifyNow, and unsubscribe.
*/
import { describe, expect, it } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
describe('Notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markDirty()
notifier.markDirty()
notifier.markDirty()
expect(order).toEqual([]) // nothing until the microtask boundary
await microtask()
expect(order).toEqual(['rebuild', 'notify'])
})
it('skips rebuild with zero listeners and ensureFresh rebuilds lazily exactly once', async () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.markDirty()
await microtask()
expect(rebuilds).toBe(0) // lazy: kept dirty
notifier.ensureFresh()
expect(rebuilds).toBe(1)
notifier.ensureFresh()
expect(rebuilds).toBe(1) // clean: no second rebuild
})
it('notifyNow runs listeners synchronously (controlled-input contract)', () => {
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.notifyNow()
expect(order).toEqual(['rebuild', 'notify']) // before returning, no microtask needed
})
it('notifyNow with zero listeners stays lazy like markDirty', () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.notifyNow()
expect(rebuilds).toBe(0)
notifier.ensureFresh()
expect(rebuilds).toBe(1)
})
it('a scheduled flush after notifyNow already flushed is a no-op', async () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.subscribe(() => undefined)
notifier.markDirty() // schedules the microtask flush
notifier.notifyNow() // flushes synchronously, clears dirty
await microtask() // the scheduled flush finds dirty=false
expect(rebuilds).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)
const unsubscribe = notifier.subscribe(() => { calls++ })
notifier.notifyNow()
expect(calls).toBe(1)
unsubscribe()
notifier.markDirty()
await microtask()
notifier.notifyNow()
expect(calls).toBe(1)
})
})

View File

@@ -0,0 +1,91 @@
/**
* PartialAccumulator: six-variant chunk folding, sparse-index compaction, and
* the block/snapshot reference discipline (a delta swaps only that block).
*/
import { describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client'
import { PartialAccumulator } from '../src/client/sessions/partial.ts'
const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk
describe('PartialAccumulator', () => {
it('builds empty blocks per block-start type, unknown type falls to other', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'text' }))
acc.push(chunk({ type: 'block-start', index: 1, blockType: 'reasoning' }))
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'tool-call' }))
acc.push(chunk({ type: 'block-start', index: 3, blockType: 'no-such' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'text', text: '' },
{ kind: 'reasoning', text: '' },
{ kind: 'tool-call', callId: '', name: '', argsRaw: '' },
{ kind: 'other', block: null },
])
})
it('accumulates text deltas, starting from empty when prev is missing or another kind', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '无 start ' })) // prev missing
acc.push(chunk({ type: 'text-delta', index: 0, text: '也累积' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '无 start 也累积' }])
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '换型重起' })) // prev is text → restart
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '换型重起' }])
})
it('accumulates reasoning deltas on the reasoning lane', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '思' }))
acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '考' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c2-late', name: 'echo', argumentsDelta: ':1}' }))
expect(acc.toPartial().blocks).toEqual([
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{"a":1}' },
])
})
it('replaces the accumulated block wholesale on block-end', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: '中间态' }))
acc.push(chunk({ type: 'block-end', index: 0, block: { type: 'text', text: '定稿全文' } }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '定稿全文' }])
})
it('returns false (no notification) for usage/finish/unknown variants and keeps blocks', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'x' }))
const before = acc.toPartial()
expect(acc.push(chunk({ type: 'usage', usage: {} }))).toBe(false)
expect(acc.push(chunk({ type: 'finish', reason: 'stop' }))).toBe(false)
expect(acc.push(chunk({ type: 'future-variant' }))).toBe(false)
expect(acc.toPartial()).toBe(before) // unchanged: same snapshot reference
})
it('compacts sparse indexes into a dense render-order array', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'block-start', index: 2, blockType: 'text' }))
acc.push(chunk({ type: 'text-delta', index: 2, text: '先到的高位' }))
acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
const { blocks } = acc.toPartial()
expect(blocks).toHaveLength(2) // no undefined holes
expect(blocks[0]).toEqual({ kind: 'reasoning', text: '' })
expect(blocks[1]).toEqual({ kind: 'text', text: '先到的高位' })
})
it('keeps the snapshot reference stable without changes and swaps it once per mutation', () => {
const acc = new PartialAccumulator(3, 1)
const first = acc.toPartial()
expect(first).toMatchObject({ turn: 3, step: 1, blocks: [] })
expect(acc.toPartial()).toBe(first)
acc.push(chunk({ type: 'text-delta', index: 0, text: 'a' }))
const second = acc.toPartial()
expect(second).not.toBe(first)
expect(acc.toPartial()).toBe(second)
})
})

View File

@@ -0,0 +1,625 @@
/**
* Session orchestration: drive the object through contract calls and injected
* frames (open → prompt → stream → finalize → cancel → resync) and assert the
* ConversationSnapshot it settles into. Reference stability is asserted with
* toBe/not.toBe — it is the React.memo/uSES contract, equal-value output is not
* enough.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
const SID = 'fk-s1' as SessionId
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
}
function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('open', () => {
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
const page = plainTurn(10, 3, '问', '答')
api.onHistory = () => histResponse(page, true)
expect(session.getSnapshot().openState).toBe('cold')
const opening = session.open()
expect(session.getSnapshot().openState).toBe('loading')
await opening
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
const { api, session } = makeSession()
await Promise.all([session.open(), session.open()])
await session.open()
expect(api.callsOf('session.history')).toHaveLength(1)
})
it('lands an error result in openState=error with the RpcError kept', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
await session.open()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('error')
expect(snapshot.openError?.code).toBe('session-not-found')
})
it('folds a transport throw into openState=error / internal', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.reject(new Error('socket died'))
await session.open()
expect(session.getSnapshot().openState).toBe('error')
expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' })
})
it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
const { api, session } = makeSession()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
expect(seqs).toEqual([11, 13, 16])
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
const { api, session } = makeSession()
api.onHistory = () => histResponse(events)
await session.open()
return { api, session }
}
it('drops replayed frames at or below the window tail', async () => {
const { session } = await opened()
const before = session.getSnapshot()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(3, '重放') })
await Promise.resolve()
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.user(7, '流式问'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '半截'))
let snapshot = session.getSnapshot()
expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] })
feed(ev.chunkText(10, 1, '回复'))
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }])
feed(ev.assistant(11, 1, '半截回复'))
feed(ev.turnEnd(12, 1))
snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const last = snapshot.nodes.at(-1)
expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] })
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.user(7, '要被打断的'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '说到一半'))
feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const frozen = snapshot.nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
// Ordered inside the flow: after the user message (seq 7), before any later turn.
expect((frozen as { seq: number }).seq).toBeGreaterThan(7)
})
it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}'))
expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }])
feed(ev.toolResult(8, 1, 'c1', 'ECHO'))
expect(session.getSnapshot().runningCalls).toEqual([])
// Second call never resolves: turn/end freezes it as an error card.
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
feed(ev.turnEnd(10, 1, 'cancelled'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls).toEqual([])
expect(snapshot.nodes.at(-1)).toMatchObject({
kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' },
})
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
api.onHistory = () => histResponse(repaired)
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
await Promise.resolve()
const seqs = session.getSnapshot().nodes.map(n => n.seq)
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
})
})
describe('paging', () => {
it('prepends an older page and keeps seq continuity', async () => {
const older = plainTurn(0, 0, '旧问', '旧答')
const newer = plainTurn(6, 1, '新问', '新答')
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(newer, true)
: histResponse(older, false)
await session.open()
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(api.callsOf('session.history')).toMatchObject([{}, { beforeSeq: 6 }].map(p => ({ sessionId: SID, ...p })))
expect(snapshot.hasMore).toBe(false)
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(10, 1, '新', '页'), true)
: histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.open()
const nodesBefore = session.getSnapshot().nodes
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(snapshot.nodes).toEqual(nodesBefore)
expect(snapshot.hasMore).toBe(false)
} finally {
errorSpy.mockRestore()
}
})
it('ignores loadOlder while one is in flight (single request)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
})
})
describe('prompt and cancel errors', () => {
it('sends content through session.prompt with the mode passed through', async () => {
const { api, session } = makeSession()
const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(result.ok).toBe(true)
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
})
it('business failure lands in promptError with op=send', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
})
it('lands cancel failures in promptError with op=stop', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.reject(new Error('cancel transport down'))
const result = await session.cancel()
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
})
})
describe('pending interactions', () => {
it('adds approval/question on requested and removes them on resolved', async () => {
const { session } = makeSession()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
session.handleMuxEnvelope('rq' as never, { type: 'question/requested', sessionId: SID, questions: [] })
expect(session.getSnapshot().pending.map(p => p.kind).sort()).toEqual(['approval', 'question'])
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap1' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
})
describe('remaining branches', () => {
it('prompt transport throw folds to internal promptError', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } })
})
it('cancel business error also lands op=stop promptError', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } }))
await session.cancel()
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } })
})
it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
const { api, session } = makeSession()
await session.loadOlder() // cold: no-op, zero calls
expect(api.calls).toEqual([])
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.open()
// err result: window unchanged
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
await session.loadOlder()
expect(session.getSnapshot().nodes).toHaveLength(2)
expect(session.getSnapshot().hasMore).toBe(true)
// empty page: hasMore adopts the response
api.onHistory = () => histResponse([], false)
await session.loadOlder()
expect(session.getSnapshot().hasMore).toBe(false)
// hasMore false now: further loadOlder is a guard no-op
const calls = api.calls.length
await session.loadOlder()
expect(api.calls.length).toBe(calls)
// throw path: fail-soft with console.error
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.resync()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.resync()
api.onHistory = () => Promise.reject(new Error('page wire down'))
await session.loadOlder()
expect(errorSpy).toHaveBeenCalled()
expect(session.getSnapshot().loadingOlder).toBe(false)
} finally {
errorSpy.mockRestore()
}
})
it('subscribe delivers snapshot-change notifications and unsubscribes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
let notified = 0
const unsubscribe = session.subscribe(() => { notified++ })
await session.open()
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
session.handleRunning(true) // any snapshot mutation; the listener must stay silent
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
const { api, session } = makeSession()
const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
let call = 0
api.onHistory = () => {
call++
return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full)
}
// Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once.
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
await session.open()
expect(call).toBe(2)
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('a failed second stitch pull keeps the first window and still opens', async () => {
const { api, session } = makeSession()
let call = 0
api.onHistory = () => {
call++
return call === 1
? histResponse(plainTurn(0, 0, 'a', 'b'))
: Promise.resolve(err({ code: 'internal', message: 'stitch pull down', details: {} }))
}
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
await session.open()
expect(call).toBe(2)
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open') // stitch-pull failure is not an open failure
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3]) // first window kept
})
it('approval frame with callId/reason keeps the optional fields; duplicate resolved is a no-op', () => {
const { session } = makeSession()
session.handleMuxEnvelope('ra' as never, {
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
})
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
expect(session.getSnapshot().pending).toEqual([])
})
it('ignores unknown mux frame types and repeated running flips (documented defaults)', () => {
const { session } = makeSession()
const before = session.getSnapshot()
session.handleMuxEnvelope('rz' as never, { type: 'future/frame' } as never)
session.handleRunning(false) // already false: dedup branch
expect(session.getSnapshot()).toBe(before)
session.handleRemoved()
expect(session.getSnapshot().removed).toBe(true)
})
it('drops live events while cold/error (no window upkeep)', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '冷态帧') })
expect(session.getSnapshot().nodes).toEqual([])
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
await session.open()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '错态帧') })
expect(session.getSnapshot().nodes).toEqual([])
})
it('repairGap failure logs and clears stitching; concurrent gaps coalesce into one repair', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let repairs = 0
api.onHistory = () => {
repairs++
return gate.promise
}
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞一') })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(10, '洞二') }) // stitching: detours, no second repair
expect(repairs).toBe(1)
gate.reject(new Error('repair wire down'))
await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() })
// Window unchanged; a later successful repull still lands the buffered frames.
expect(session.getSnapshot().nodes).toHaveLength(2)
} finally {
errorSpy.mockRestore()
}
})
it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
feed(ev.turnEnd(8, 1, 'cancelled'))
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
})
it('turn/end sweeps only same-turn open calls; other turns keep running', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
feed(ev.turnEnd(9, 1, 'cancelled'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
})
it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
const resynced = session.resync()
stale.reject(new Error('stale wire'))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error
})
it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
})
it('drops a stale stitch pull (second doOpen fetch) superseded mid-flight by resync', async () => {
const { api, session } = makeSession()
const secondPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let call = 0
api.onHistory = () => {
call++
if (call === 1) return histResponse(plainTurn(0, 0, 'a', 'b')) // first page: tail 5
if (call === 2) return secondPull.promise // gap-stitch pull: held
return histResponse(plainTurn(6, 1, 'c', 'd'))
}
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
const opening = session.open() // triggers the second pull, which parks
await vi.waitFor(() => { expect(call).toBe(2) })
const resynced = session.resync()
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
})
it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => repairPull.promise
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
})
it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const result = await session.cancel()
expect(result.ok).toBe(true)
expect(session.getSnapshot().promptError).toBeNull()
const callsBefore = session.getSnapshot().runningCalls
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') })
expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference
})
it('freezes a tool-call-only partial (visible through the non-text arm)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
feed(ev.turnEnd(8, 1, 'cancelled'))
const frozen = session.getSnapshot().nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
})
it('dispose is a reserved no-op on resident instances', () => {
const { session } = makeSession()
expect(() => { session.dispose() }).not.toThrow()
})
it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => {
const { api, session } = makeSession()
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
api.onHistory = () => Promise.resolve(ok({
events: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' },
})
// Live path: the frame's view slot reaches runningCalls, then the result node.
session.handleMuxEnvelope('rv1' as never, {
type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'),
view: { for: 'call', view: { card: 'generic', title: '直播卡' } },
} as never)
expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }])
session.handleMuxEnvelope('rv2' as never, {
type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'),
view: { for: 'result', view: { card: 'generic', title: '直播果' } },
} as never)
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' },
})
})
})
describe('resync', () => {
it('rebuilds the window and clears pending; cold instances no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')])
await session.resync()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames
expect(snapshot.nodes).toHaveLength(4)
const cold = makeSession()
await cold.session.resync()
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const firstOpen = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late
await firstOpen
await resynced
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
})
describe('reference stability (the memo contract)', () => {
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const before = session.getSnapshot()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') })
const after = session.getSnapshot()
expect(after).not.toBe(before) // top-level swap on change
expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity
expect(after.nodes[1]).toBe(before.nodes[1])
expect(after.nodes).toHaveLength(3)
// No change → same snapshot reference.
expect(session.getSnapshot()).toBe(after)
})
it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
const before = session.getSnapshot()
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '与工具无关的流式'))
const after = session.getSnapshot()
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.runningCalls).not.toBe(after.runningCalls)
expect(resolved.pending).toBe(after.pending)
})
})

View File

@@ -0,0 +1,193 @@
/**
* SessionsService: list store projection (manager → {ids, byId} with derived
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
* teardown with watch deferral), binding identity, ancestry walk, create.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
interface Bench {
ctx: Context
api: FakeApiClient
svc: SessionsService
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const svc = new SessionsService(ctx, api)
return { ctx, api, svc }
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
})),
}) as never)
await b.svc.manager.refreshList()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
const b = bench()
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
})
describe('scope tree', () => {
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.scope(sid('unknown'))).toBeUndefined()
const scoped = b.svc.scope(sid('s1'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1')) // s1 is watched
b.svc.scope(sid('s2')) // s2 scoped but not watched
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while watched: deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
const b = bench()
await feedList(b, [{ id: 's1', running: true }])
const scoped = b.svc.scope(sid('s1'))
await feedList(b, [{ id: 's1', running: false }])
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
it('cancels a deferred teardown when the id reappears in the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
await feedList(b, []) // removed while watched → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
const b = bench()
await feedList(b, [
{ id: 'root', cwd: '/w/app' },
{ id: 'mid', parentId: 'root' },
{ id: 'leaf', parentId: 'mid' },
{ id: 'orphan', parentId: 'ghost' },
])
expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
})
})
describe('create', () => {
it('returns the new id on ok and throws a coded error on failure', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
b.api.onCreate = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
} as never)
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
})
})
describe('coverage tails (branch duals)', () => {
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
const { byId } = b.svc.list.getSnapshot()
expect(byId[sid('no-base')]?.title).toBe('no-base')
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
})
it('binding for an unknown session returns undefined without moving the watch', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
await feedList(b, []) // deferred removal of the watched id
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
expect(b.svc.binding(sid('s1'))).toBeDefined()
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.binding(sid('a'))
b.svc.binding(sid('b')) // watch: b; both scoped
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
// set containing b (torn) — and the watched-continue branch fires when the
// deferral set still holds the current watch target.
await feedList(b, [{ id: 'c' }])
b.svc.binding(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force-add via removed
// list state (scope teardown raced) — sweep must tolerate the missing record.
await feedList(b, [])
b.svc.binding(sid('c')) // c now watched+removed → deferred
await feedList(b, [{ id: 'd' }])
b.svc.binding(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})
})

View File

@@ -0,0 +1,80 @@
/**
* SlotsService: cordis Service wrapper semantics — core delegation, the
* 'slots/changed' event bridge, and fiber-scoped registration disposal.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import { SlotsService } from '../src/client/slots.ts'
// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
't-single': { kind: 'single'; scope: 'root'; props: object }
't-list': { kind: 'list'; scope: 'root'; props: object }
}
}
const C: FC<object> = () => null
async function boot(): Promise<Context> {
const ctx = new Context()
ctx.plugin(SlotsService)
await ctx.fiber.await()
return ctx
}
describe('SlotsService', () => {
it('proxies define/register/entries/spec/getVersion to the core', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
const v0 = ctx.slots.getVersion('t-single')
ctx.slots.register('t-single', C)
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
expect(ctx.slots.core.spec('t-single')).toBeDefined()
})
it("re-emits every mutation as 'slots/changed' with the key", async () => {
const ctx = await boot()
const seen: string[] = []
ctx.on('slots/changed', (key) => { seen.push(key) })
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
ctx.slots.register('t-list', C, { id: 'a' })
expect(seen).toEqual(['t-list', 't-list'])
})
it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({
name: 'occupant',
inject: ['slots'],
apply: (pluginCtx: Context) => {
pluginCtx.slots.register('t-single', C)
},
})
await fiber.await()
expect(ctx.slots.entries('t-single')).toHaveLength(1)
await fiber.dispose()
expect(ctx.slots.entries('t-single')).toHaveLength(0)
// The slot definition (registered from root) survives; a new occupant may register.
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
})
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
const ctx = await boot()
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
let notified = 0
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
ctx.slots.register('t-list', C, { id: 'row' })
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
expect(notified).toBeGreaterThan(0)
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
unsubscribe()
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
}
],
"exclude": [
"**/*.legacy.*"
]
}

View File

@@ -0,0 +1,23 @@
import type { UserConfig } from 'tsdown'
import { clientBundle } from '../tsdown.client.ts'
/**
* Standard dual-entry shape plus the loader lib half: exports["./loader"]
* promises lib/loader.js (the web shell statically imports the machinery —
* a loader cannot load itself), and the shared preset only emits
* lib/{index,invariant}.js, so the extra config supplies it.
*/
const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
const loaderLib: UserConfig = {
entry: { loader: 'lib/types/client/loader/index.js' },
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}
export default [...configs, loaderLib]

View File

@@ -0,0 +1,152 @@
/**
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory})
* and resolves externals through the injected require (loader module table —
* cordis DI entities, no globals, no import map). CSS Modules are compiled by
* lightningcss inside the bundle: importing `x.module.css` yields the
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
* tag at factory execution (the loader removes plugin-owned tags on unload).
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
/**
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
* (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
* ending in `.css`, so the virtual id must not.
*/
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
/**
* Wire/type layers a client bundle may inline: browser-safe contract surfaces
* with no runtime identity to share (no Symbol/instanceof/singleton state).
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
export const CLIENT_EXTERNALS = [
'react',
'react-dom',
'react/jsx-runtime',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-web-react/store',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-connection/client',
'@deepseek-ai/dsh-client-runtime/client',
'@deepseek-ai/dsh-client-ui-layout/client',
'@deepseek-ai/dsh-client-ui-conversation/client',
'@deepseek-ai/dsh-client-ui-theme/client',
'@deepseek-ai/dsh-client-i18n/client',
]
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
* the root workspace shape, so the lib half must be restated here — dropping
* it leaves the package without lib/index.js and the host Loader cannot
* import its node half.
* @param id - plugin id (package name), stamped into the loadPlugin handoff
* and onto the injected style tags.
* @param libEntry - node-half entries, spelled at the call site so the
* package-invariants gate can see `lib/types/invariant.js` in each package's
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
*/
export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] {
return [{
entry: [...libEntry],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}, {
entry: { client: 'src/client/index.ts' },
// Browser bundle lands next to the node half (single lib/ artifact dir;
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
// off — a default clean would wipe the node-half output emitted above.
outDir: 'lib',
format: 'cjs',
platform: 'browser',
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
dts: false,
clean: false,
external: CLIENT_EXTERNALS,
// tsdown auto-externalizes package dependencies; anything NOT in the
// loader module table must inline instead (wire/type layers, zod, clsx —
// every non-shared dep). A require() the table cannot answer is a
// guaranteed runtime throw, so the rule is the table list itself: no
// opinion for table entries (external above wins), bundle everything else.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate: a bare-name import of a module-table package would
// slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
// second copy of that package — duplicate runtime identity (a second
// scope Symbol was tonight's white-screen root cause). Resolve-time is
// the earliest, most precise interception: rewrite bare table names to
// their /client form (the loader registers both specifiers), and reject
// any other @deepseek-ai/* leak that is not an inline-safe wire layer.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // external wins
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
return { id: `${source}/client`, external: true }
}
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
+ 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
)
},
}, {
name: 'dsh-css-modules-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.module.css')) return null
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
// One <style data-plugin> per module file; idempotent under re-evaluation.
return [
`const css = ${JSON.stringify(code.toString())};`,
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
` const tag = document.createElement('style');`,
` tag.dataset.plugin = ${JSON.stringify(id)};`,
` tag.dataset.pluginCss = tagId;`,
` tag.textContent = css;`,
` document.head.appendChild(tag);`,
`}`,
`export default ${JSON.stringify(classMap)};`,
].join('\n')
},
}],
outputOptions: {
entryFileNames: 'client.js',
banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
}]
}

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-client-ui-conversation
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
## Model Experience
None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.

View File

@@ -0,0 +1,63 @@
{
"name": "@deepseek-ai/dsh-client-ui-conversation",
"description": "Conversation domain: skeleton (header/tabs/composer), chat view, ctx.toolviews registry, minimal details panel",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,185 @@
/**
* Client plugin body: provide the conversation service and toolview registry,
* register the conversation/details slot occupants and the no-session empty
* state, and mount the chat view with its samples. Assembly only — components
* receive everything through inject factories; nothing here renders directly.
*/
import { createElement, Fragment, type ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import { scopedSlots, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type {
SessionId, SessionListState, SessionsService, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
import { childSessionScope, registerChat } from './chat/register.ts'
import { registerBashSamples } from './toolviews/bash-sample.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions', 'i18n']
/** Resolve a service via ctx.get, failing loud. Property access is reserved
* for contexts whose fiber declares the inject (scope fibers do not). */
// T is the caller-named cast target; inlining `as T` per call site would scatter the budgeted cast.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
function need<T>(ctx: Context, name: string): T {
const value = ctx.get(name) as T | undefined
if (value === undefined) throw new Error(`ui-conversation: ${name} service unavailable`)
return value
}
/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */
const cwdsCache = new WeakMap<SessionListState, readonly string[]>()
function cwdsOf(state: SessionListState): readonly string[] {
let cached = cwdsCache.get(state)
if (cached === undefined) {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
cached = [...seen]
cwdsCache.set(state, cached)
}
return cached
}
/**
* Client plugin body.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const sessions = need<SessionsService>(ctx, 'sessions')
const layout = need<LayoutService>(ctx, 'layout')
const i18n = need<I18nService>(ctx, 'i18n')
const slots = need<SlotsService>(ctx, 'slots')
const conversation = new ConversationService(ctx)
const toolviews = new ToolViewRegistry()
ctx.provide('toolviews', toolviews)
const t = i18n.bind('conversation')
// Chat view + StatsLine footer; bash samples assembled here (apply is the
// only cross-domain point — chat consumes the resolver face, samples come
// from the toolviews domain). registerView inside registerChat is already
// effect-scoped; the raw sample registrations need the effect wrapper to
// ride the fiber cascade.
ctx.effect(
() => registerChat({ conversation, toolviews, t }),
'ui-conversation: chat view')
ctx.effect(
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
'ui-conversation: bash toolview samples')
// ConvViewProps.slots is ScopedSlots<never>: a real outlet with an empty
// whitelist (uncallable by type, correct runtime shape for future grants).
const emptySlots = scopedSlots<never>(slots.core)
/** conversation slot: skeleton surface assembled once per (entry x session). */
const conversationInject = (b: SessionBinding): ConversationInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const id = b.sessionId as SessionId
const useSession = b.session.useSelector as UseSession
const selectionStore = scoped.selection
const draftsStore = scoped.drafts
const session = sessions.manager.get(id)
// Watch-driven history pull: assembling the surface IS the watch signal
// (once per entry x session; open() is idempotent and self-recovers).
void session.open()
const viewProps: Omit<ConvViewProps, 'slots'> = {
sessionId: id,
useSession,
useSelection: selectionStore.useSelector,
actions: {
openDetails: (target: SelectionTarget) => { scoped.openDetails(target) },
loadOlder: () => { void session.loadOlder() },
},
}
const injected: ConversationInjected = {
useAncestry: () => sessions.list.useSelector(
() => sessions.ancestry(id),
(a, b) => shallowEqual(a, b)),
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
// layout's viewFor value type is its own looser ViewId; the registry is
// the runtime validator (unknown ids fall back to the first view).
useActiveView: () => layout.current.useSelector(s => s.viewFor[id]) as ViewId | undefined,
composer: {
useDraft: () => draftsStore.useSelector(s => s),
setDraft: (text) => { draftsStore.set(text) },
send: (mode) => {
const text = draftsStore.getSnapshot().trim()
if (text === '') return
// Optimistic clear with failure restore (choreography lives with the
// sender; the business failure also lands in snapshot.promptError).
draftsStore.set('')
void scoped.send(text, mode).catch(() => {
if (draftsStore.getSnapshot() === '') draftsStore.set(text)
})
},
stop: () => {
scoped.cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
},
actions: {
openView: (view: ViewId) => { layout.openView(id, view) },
open: (target: SessionId) => { layout.open(target) },
},
renderView: (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'header', sessionId: id, useSession }))
}
children.push(createElement(entry.component, { key: 'view', ...viewProps, slots: emptySlots }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'footer', sessionId: id, useSession }))
}
return createElement(Fragment, null, ...children)
},
}
return injected
}
/** details slot: minimal selection-driven panel. */
const detailsInject = (b: SessionBinding): DetailsInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const injected: DetailsInjected = {
useSelection: scoped.selection.useSelector,
actions: { closeDetails: () => { layout.closeDetails() } },
}
return injected
}
/** conversation.empty root slot: the NEW SESSION hero. */
const emptyInject = (): EmptyStateInjected => {
const useCwds: SnapshotSelectorHook<readonly string[]> = (sel, eq) =>
sessions.list.useSelector(s => sel(cwdsOf(s)), eq)
const injected: EmptyStateInjected = {
useCwds,
actions: { startSession: opts => conversation.startSession(opts) },
}
return injected
}
slots.register('conversation', ConversationRoot, { inject: conversationInject })
slots.register('details', DetailsPanel, { inject: detailsInject })
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
}

View File

@@ -0,0 +1,33 @@
/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
.root {
display: flex;
flex-direction: column;
gap: 16px;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-primary);
}
.pulse {
display: inline-block;
width: 8px;
height: 14px;
background: var(--dsw-alias-state-business-primary);
animation: pulse 1s infinite ease-in-out;
}
@keyframes pulse {
50% { opacity: 0.2; }
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;
padding: 0 6px;
border-radius: 6px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 18px;
}

View File

@@ -0,0 +1,56 @@
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows via the toolview outlet (figma step-summary
// flow). Shared by finalized nodes and the streaming partial (pulse marker).
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
interrupted?: boolean | undefined
}
function firstLine(text: string): string {
const nl = text.indexOf('\n')
return nl === -1 ? text : text.slice(0, nl)
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
<ToolRow
variant="think"
icon={<IconThinkOutline14 />}
title="Think"
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
/>
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
const last = blocks.length - 1
return (
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{streaming && <span className={css.pulse} />}
{interrupted && <span className={css.stopped}></span>}
</div>
)
})

View File

@@ -0,0 +1,101 @@
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma);
tool rows inside a group gap 10. Input padding cap rides the skeleton. */
.root {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
}
.scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 24px;
}
/* Message column: 736px fixed width, centered on the same axis as the
input box; the scroller itself stays full-bleed. */
.column {
max-width: 736px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.toolGroup {
display: flex;
flex-direction: column;
gap: 10px;
}
.callRow {
border-radius: 6px;
}
/* Selection linkage: the selected call row wears the blue outline.
button-info-fill flips 500→400 with the theme, hitting the darker-blue
dark-mode spec exactly (business-primary stays 500 on both). */
.callRow[data-selected] {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 1px;
}
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.openError {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.older {
display: flex;
justify-content: center;
}
.older button {
border: none;
border-radius: 14px;
padding: 4px 12px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-interactive-bg-hover-solid);
cursor: pointer;
}
.older button:disabled {
cursor: default;
opacity: 0.6;
}
/* Back-to-bottom: 34px circular icon button at the column's right edge. */
.toBottom {
position: absolute;
right: max(24px, calc((100% - 736px) / 2));
bottom: 16px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 100px;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-button-floating-fill);
box-shadow: var(--dsw-shadow-lv2);
cursor: pointer;
}
.toBottom:hover {
background: var(--dsw-alias-button-floating-hover);
}

View File

@@ -0,0 +1,293 @@
// ChatView: the default conversation view — message flow with user bubbles,
// assistant narration, tool summary rows grouped into step runs, pending
// cards, paging and bottom-follow. Created via factory so plugin deps
// (toolviews registry, i18n) arrive by closure, never by import.
//
// Render economics (architecture RFC performance model): the list parent
// subscribes to snapshot segments that do NOT change per streaming chunk
// (nodes/runningCalls/pending keep their references across chunk batches), so
// during a token storm only StreamingTail re-renders; history rows hold via
// memo on cache-stable node slices. Selection changes re-render the parent
// map but only rows whose own selected bit flipped.
import {
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
import css from './ChatView.module.css'
/** Plugin-supplied closure deps (assembled in registerChat, apply world). */
export interface ChatViewDeps {
toolviews: ToolViewResolver
t: Translate
}
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
/** web-react's UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** One tool call row (result or running): builds the bound ToolViewProps. */
const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, callId, toolName, block, seq, onOpenDetails, selected }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
/** Surface seq for finalized results; the call's turn for running calls. */
seq: number
onOpenDetails: OpenDetails
selected: boolean
}) {
const viewProps = useMemo<ToolViewProps>(() => ({
callId, toolName, block, useSession,
actions: { openDetails: () => onOpenDetails({ turnSeq: seq, callId, toolName }) },
t,
}), [callId, toolName, block, useSession, seq, onOpenDetails, t])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName={toolName} viewProps={viewProps} />
</div>
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, results, onOpenDetails, selectedCallId }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
selectedCallId: string | undefined
}) {
return (
<div className={css.toolGroup}>
{results.map((node) => (
<CallRow
key={node.callId}
registry={registry}
sessionId={sessionId}
useSession={useSession}
t={t}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
seq={node.seq}
onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId}
/>
))}
</div>
)
})
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
useSession: UseConversation
onGrow: () => void
}) {
const partial = useSession((s) => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/**
* Build the chat view component over plugin deps.
* @param deps - toolview registry and bound translator.
* @returns the ConvViewProps component registered as the chat view.
*/
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useSelection((sel) => sel?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlder = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
actions.loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
results={item.results}
onOpenDetails={actions.openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlder}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={actions.openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
</div>
</div>
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
}
}

View File

@@ -0,0 +1,36 @@
// GenericToolCard: the registry-miss fallback toolview — classifies the tool
// into one of the five figma row variants and renders the summary row. Also
// the shared base the bash sample builds on: any ToolViewProps consumer.
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolViewProps } from '../contract/toolview.ts'
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table). */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
think: <IconThinkOutline14 />,
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
others: <IconSparkle16 />,
}
export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
return (
<ToolRow
variant={model.variant}
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}
body={model.body}
state={model.state}
onOpenDetails={actions.openDetails}
/>
)
}

View File

@@ -0,0 +1,15 @@
// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading
// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector
// data, so this is a hand-authored three-star approximation). Lives here
// rather than ui-primitives until the exact glyph is exported and adopted
// into the ic_ds_* family.
export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
</svg>
)
}

View File

@@ -0,0 +1,34 @@
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
(#EDF3FE light / dark pair rides the token sheet). */
/* Block spacing is the flow column's gap alone — no extra padding here. */
.userRow {
display: flex;
justify-content: flex-end;
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);
background: var(--dsw-specific-bubble);
border-radius: 22px;
/* 44px single-line bubble: 24 line + 10 vertical padding each side. */
padding: 10px 16px;
font-size: 16px;
line-height: 24px;
color: var(--dsw-alias-label-primary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
padding: 1px 6px;
border-radius: 6px;
background: var(--dsw-alias-state-warn-primary);
color: var(--dsw-alias-label-primary-foreground);
font-size: 11px;
}
.contextRow {
padding: 2px 0;
}

View File

@@ -0,0 +1,56 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned),
// steering (badged bubble), context injection and unknown-surface JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
import { memo } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
const texts: string[] = []
const rest: unknown[] = []
for (const block of content) {
const b = block as { type?: string; text?: string }
if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
else rest.push(block)
}
return { text: texts.join(''), rest }
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}></span>}
<MessageText text={text} />
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
</div>
)
}
case 'context':
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
</div>
)
default:
return (
<div className={css.contextRow}>
<JsonBlock label={`未知 surface 事件:${node.type}`} payload={node.data} />
</div>
)
}
})

View File

@@ -0,0 +1,31 @@
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
.card {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 10px;
background: var(--dsw-alias-state-warn-tertiary);
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.mono {
font-family: var(--ds-font-family-code);
}
.reason {
margin-top: 4px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hint {
margin-top: 6px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,31 @@
// PendingCard: approval/question placeholder card (visible, not answerable —
// the composer-takeover approval panel is a P-II item; wire pending semantics
// already exist so the flow must show them).
import { memo } from 'react'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PendingCard.module.css'
export interface PendingCardProps {
item: PendingInteraction
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}><span className={css.mono}>{item.toolName}</span></div>
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
</>
) : (
<>
<div className={css.title}>{item.questions.length} </div>
<JsonBlock label="问题内容" payload={item.questions} />
</>
)}
<div className={css.hint}>web </div>
</div>
)
})

View File

@@ -0,0 +1,13 @@
/* Session stats row: 12/20 tertiary text under the flow, aligned to the
736px message column axis. */
.root {
max-width: 736px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px 24px 8px;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,68 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
// tokens · 45.2s · 5 turns · 32 steps"), mounted as the chat view's
// chrome.footer — the first chrome-attachment consumer. Duration has no data
// source in P-I (ledger). Subscribes to `nodes` only: chunk batches never swap
// that reference, so the row renders zero times during streaming (the RFC
// performance model's acceptance row).
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ChromeProps } from '../contract/views.ts'
import css from './StatsLine.module.css'
interface UsageTotals {
turns: number
steps: number
tokens: number
cacheHitPct: number | null
}
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
interface UsageLike {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
}
/**
* Fold assistant nodes into display totals.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
const turns = new Set<number>()
let steps = 0
let tokens = 0
let input = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
tokens,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
}
export const StatsLine = memo(function StatsLine({ useSession }: ChromeProps) {
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
parts.push(`${stats.turns} turns`)
parts.push(`${stats.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>
})

View File

@@ -0,0 +1,88 @@
/* Tool summary row (figma 122:9479): 24px single line —
[16 leading] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
.root {
display: flex;
flex-direction: column;
}
.row {
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.row[data-clickable] {
cursor: pointer;
border-radius: 6px;
}
.row[data-clickable]:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-tertiary);
}
/* The others-variant sparkle glyph is one gray step darker than the icon
family in the source design. */
.root[data-variant='others'] .leading {
color: var(--dsw-alias-label-secondary);
}
button.leading {
cursor: pointer;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
padding: 4px 0 4px 22px;
font-size: 14px;
line-height: 24px;
white-space: pre-wrap;
word-break: break-word;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,75 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import css from './ToolRow.module.css'
export interface ToolRowProps {
variant: ToolRowVariant
/** Leading 16px tool icon, shown while collapsed and not running/failed. */
icon: ReactNode
title: string
summary: string
/** Expanded-body text; null = not expandable (leading slot never toggles). */
body: string | null
state: ToolRowState
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
}
/** Leading-slot state substitution: the tool icon yields to the state semantic
* (running = blue ring, error = red, interrupted = amber halo; ok = icon). */
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return icon
}
}
export function ToolRow({ variant, icon, title, summary, body, state, onOpenDetails }: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
const open = expanded && expandable
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div
className={css.row}
data-clickable={onOpenDetails !== undefined || undefined}
onClick={onOpenDetails}
>
{expandable ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</button>
) : (
<span className={css.leading}>{leadingFor(state, icon)}</span>
)}
<span className={css.title}>{title}</span>
{!open && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
</>
)}
</div>
{open && <div className={css.body}>{body}</div>}
</div>
)
}

View File

@@ -0,0 +1,89 @@
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
// (uSES over the registry version so unload falls back live) and renders it
// behind a per-row error boundary. GenericToolCard is the render-side
// fallback for both a registry miss and a crashed custom row. A registrant
// inject factory is called once per (registration x binding) and cached,
// mirroring the scoped-slots injection discipline.
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { useSessionBinding } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
import { GenericToolCard } from './GenericToolCard.tsx'
export interface ToolViewOutletProps {
registry: ToolViewResolver
sessionId: SessionId
toolName: string
viewProps: ToolViewProps
}
/** Inject cache: per inject-factory (stable per registration) x binding object. */
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
let perBinding = injectCache.get(inject)
if (!perBinding) {
perBinding = new WeakMap()
injectCache.set(inject, perBinding)
}
let props = perBinding.get(binding)
if (!props) {
props = inject(binding)
perBinding.set(binding, props)
}
return props
}
class RowErrorBoundary extends Component<
{ resetKey: unknown; fallback: ReactNode; children: ReactNode }, { failed: boolean }
> {
override state = { failed: false }
// Fallback state MUST flip here (render phase): a boundary whose derived
// state does not change re-renders the crashing children and React gives
// up after the second throw, escalating past the boundary.
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
override componentDidCatch(error: unknown): void {
console.error('toolview row crashed:', error)
}
// A re-registration (resetKey bump) retries the custom row.
override componentDidUpdate(prev: { resetKey: unknown }): void {
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
override render(): ReactNode {
if (this.state.failed) return this.props.fallback
return this.props.children
}
}
/** Split component: only inject-carrying registrations need the session
* binding hook (keeps injectless rendering free of the Provider requirement). */
function InjectedRow({ Row, inject, viewProps }: {
Row: FC<ToolViewProps & object>; inject: ToolViewInject<object>; viewProps: ToolViewProps
}) {
const binding = useSessionBinding()
const injected = cachedInject(inject, binding)
return <Row {...{ ...injected, ...viewProps }} />
}
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
const version = useSyncExternalStore(
(fn) => registry.subscribe(fn),
() => registry.getVersion(),
)
const resolved = registry.resolve(toolName, sessionId)
if (resolved === undefined) return <GenericToolCard {...viewProps} />
const Row = resolved.component
return (
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
{resolved.inject === undefined
? <Row {...viewProps} />
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
</RowErrorBoundary>
)
}

View File

@@ -0,0 +1,46 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
let group: ToolResultNode[] | null = null
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (group === null) {
group = [node]
items.push({ kind: 'tool-group', key: `g${node.seq}`, results: group })
} else {
group.push(node)
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
}
return items
}
/**
* Key projection for the list parent's selector (content-blind identity).
* @param items - derived flow items.
* @returns joined key string usable with Object.is short-circuiting.
*/
export function flowKeys(items: readonly ChatFlowItem[]): string {
return items.map(i => i.key).join('|')
}

View File

@@ -0,0 +1,52 @@
/**
* Chat-side registration entry, called from the plugin apply (the assembly
* point): registers the chat view with the stats-line footer chrome. The
* chat domain touches the tool ring only through the contract resolver face;
* bash sample registration moved to apply (cross-domain assembly).
*/
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationService } from '../service.ts'
import type { Translate } from '../contract/views.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import { createChatView } from './ChatView.tsx'
import { StatsLine } from './StatsLine.tsx'
/** Read face of the sessions list store (subscription not needed: the filter
* reads the latest snapshot at each resolve). */
export interface SessionListReader { getSnapshot(): SessionListState }
/**
* Default scoped-sample filter: the sub-session family. Sub-agent rows
* rendering differently is the registry's canonical product scenario, and
* forking gives W5 acceptance a real entry point to observe the differential.
* @param list - injected sessions list read face.
* @returns filter matching sessions with a parent.
*/
export function childSessionScope(list: SessionListReader): (sessionId: SessionId) => boolean {
return sessionId => list.getSnapshot().byId[sessionId]?.parentId !== undefined
}
/** Assembly inputs for {@link registerChat} (resolved by apply, not here). */
export interface RegisterChatDeps {
conversation: ConversationService
/** Toolview read face consumed by the chat rows' outlet. */
toolviews: ToolViewResolver
/** Translator bound to the conversation namespace. */
t: Translate
}
/**
* Register the chat view (footer chrome included).
* @param deps - assembled service instances.
* @returns disposer removing the registration.
*/
export function registerChat(deps: RegisterChatDeps): () => void {
const { conversation, toolviews, t } = deps
return conversation.registerView({
id: 'chat',
label: 'Chat',
order: 0,
component: createChatView({ toolviews, t }),
chrome: { footer: StatsLine },
})
}

View File

@@ -0,0 +1,63 @@
/**
* Slot-ring contract for the conversation package: the composed props shapes
* its registrants mount into the layout-owned slots (conversation / details /
* conversation.empty — the SlotMap declarations live with ui-layout, the
* slot owner). Per the share-ownership rule, the owner share is REFERENCED
* from ui-layout and each registrant's injected share is declared here, next
* to the component that receives it; full component props = owner share &
* standard share & own injected share.
*/
import type { ReactNode } from 'react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
/** Injected share of the conversation slot (assembled by apply's inject factory). */
export interface ConversationInjected {
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
useAncestry: () => readonly SessionSummary[]
/** View registry read face (uSES triple from the conversation service). */
views: {
list(): readonly ViewEntry[]
subscribe(fn: () => void): () => void
version(): number
}
/** Active view accessor (layout.viewFor backed; undefined falls to 'chat'). */
useActiveView: () => ViewId | undefined
/** Composer surface: draft store hook pair + send/stop choreography. */
composer: {
useDraft: () => string
setDraft(text: string): void
send(mode: 'queue' | 'steer'): void
stop(): void
}
actions: {
openView(view: ViewId): void
open(id: SessionId): void
}
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
renderView: (entry: ViewEntry) => ReactNode
}
/** Full conversation-slot component props: owner share & standard share & injected share. */
export type ConversationSlotProps = ConvOwnerProps & { useSession: UseSession } & ConversationInjected
/** Injected share of the details slot. */
export interface DetailsInjected {
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { closeDetails(): void }
}
/** Full details-slot component props. */
export type DetailsSlotProps = DetailsOwnerProps & { useSession: UseSession } & DetailsInjected
/** Injected share of the no-session empty-state slot (root slot: no standard share). */
export interface EmptyStateInjected {
/** cwd options derived from sessions.list (deduped; assembled by the inject factory). */
useCwds: SnapshotSelectorHook<readonly string[]>
actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> }
}
/** Full empty-state component props. */
export type EmptyStateSlotProps = EmptyOwnerProps & EmptyStateInjected

View File

@@ -0,0 +1,126 @@
/**
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary and expanded-body text from the frozen call slice. No
* inline output ever — full results live in the details panel.
*/
import type { ToolCallBlock } from './toolview.ts'
export type { ToolCallBlock } from './toolview.ts'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The five figma row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', others: 'Tool call',
}
/** Known tool name -> variant; fs write/edit intentionally fall to others (no figma form). */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
read: 'read',
web_fetch: 'read',
web_search: 'search',
grep: 'search',
glob: 'search',
}
/**
* Classify a tool name into its row variant.
* @param toolName - wire tool name.
* @returns matching variant, others when unknown.
*/
export function classifyTool(toolName: string): ToolRowVariant {
return TOOL_VARIANTS[toolName] ?? 'others'
}
/** Everything ToolRow needs, derived once from the frozen slice. */
export interface ToolRowModel {
variant: ToolRowVariant
title: string
summary: string
/** Expanded-body text (pretty args); null = row not expandable. */
body: string | null
state: ToolRowState
}
function parseArgs(argsRaw: string): unknown {
try {
return JSON.parse(argsRaw)
} catch {
// Non-JSON args (mid-stream truncation): summary/body fall back to the raw string.
return undefined
}
}
function firstLine(text: string): string {
const nl = text.indexOf('\n')
return nl === -1 ? text : text.slice(0, nl)
}
function pickString(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const v = args[key]
if (typeof v === 'string' && v !== '') return v
}
return undefined
}
/** Summary key preference per variant (args-derived; result-derived summaries are a ledger item). */
const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
bash: ['description', 'command'],
read: ['path', 'file_path', 'url'],
search: ['query', 'pattern', 'url'],
think: [],
others: [],
}
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
const args = parsed as Record<string, unknown>
const picked = pickString(args, SUMMARY_KEYS[variant])
if (picked !== undefined) return firstLine(picked)
for (const v of Object.values(args)) {
if (typeof v === 'string' && v !== '') return firstLine(v)
}
return firstLine(argsRaw)
}
function deriveBody(argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
return parsed === undefined ? argsRaw : JSON.stringify(parsed, null, 2)
}
/**
* Derive the full row model from a frozen call slice.
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the row model.
*/
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel {
const variant = classifyTool(toolName)
const done = 'kind' in block
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: ToolRowState = !done ? 'running'
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
// Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot so no information is lost.
const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base
return {
variant,
title: VARIANT_TITLES[variant],
summary,
body: deriveBody(argsRaw),
state,
}
}

View File

@@ -0,0 +1,77 @@
/**
* Tool-ring contract: the props surface handed to toolview components, the
* registry's resolve/registration shapes, and the tool-call block union.
* Shared face between the chat domain (ToolViewOutlet consumes resolve) and
* the toolviews domain (registry implementation + sample rows); domain
* implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'
// The block union's defining home is runtime (fold-product types); the
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** Props handed to registered toolview components. */
export interface ToolViewProps {
callId: CallId
toolName: string
block: ToolCallBlock
useSession: UseSession
actions: { openDetails(): void }
t: Translate
}
/**
* Toolview inject factory: produces the registrant's private injected share
* `I`, called once per (registration x session binding) and cached by the
* render outlet. Session-bound by nature — tool rows always render inside a
* session subtree.
*/
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
export interface ToolViewOptions<I extends object = object> {
/** Session filter; absent = global registration. */
scope?: (sessionId: SessionId) => boolean
/** Private inject factory merged into the row's props by the render outlet. */
inject?: ToolViewInject<I>
}
/**
* A resolved toolview registration. `I` is erased to `object` on the resolve
* read face (storage erases the per-registration parameter; the outlet merges
* injected props untyped — the register site already proved component ⊇ I).
*/
export interface ResolvedToolView<I extends object = object> {
component: FC<ToolViewProps & I>
inject?: ToolViewInject<I>
}
/** The registry's read face consumed by render outlets (implementation lives in the toolviews domain). */
export interface ToolViewResolver {
/**
* Resolve the renderer for a tool in a session. Order: scope match (later
* registration wins) > global > undefined (caller falls back to the
* generic card).
* @param tool - tool name.
* @param sessionId - session the row renders in.
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined
/**
* Subscribe to registration changes (synchronous).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @returns current version.
*/
getVersion(): number
}

View File

@@ -0,0 +1,68 @@
/**
* View-ring contract: the typed conversation view table and the props
* surfaces handed to registered views. Shared face between the skeleton
* domain (ConversationRoot renders views) and the chat domain (registers the
* chat view); domain implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* One ConversationViewMap entry: per-view props extension shapes (design
* ledger, view ring). `chromeProps` extends {@link ChromeProps} for the
* view's chrome attachments; `extraProps` extends {@link ConvViewProps} for
* the view component itself. Both optional — the common bases stay the floor.
*/
export interface ViewEntryDef { chromeProps?: object; extraProps?: object }
/**
* Typed conversation view table; ui-trajectory merges {trajectory, waterfall}.
* The chat entry is declared inline here (self-merge from a sibling module
* trips TS6305 under tsc -b).
*/
export interface ConversationViewMap { chat: ViewEntryDef }
/** View id constrained to registered ConversationViewMap keys (all string literals; chat is declared inline). */
export type ViewId = keyof ConversationViewMap
/** Per-view chrome props: the common base plus the entry's declared extension. */
export type ChromePropsOf<Id extends ViewId> =
ChromeProps & (ConversationViewMap[Id] extends { chromeProps: infer C extends object } ? C : object)
/** Per-view component props: the common base plus the entry's declared extension. */
export type ConvViewPropsOf<Id extends ViewId> =
ConvViewProps & (ConversationViewMap[Id] extends { extraProps: infer E extends object } ? E : object)
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
/** Translate function bound to a namespace via i18n. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** One registered conversation view (props positions keyed by the entry's declared shapes). */
export interface ViewEntry<Id extends ViewId = ViewId> {
id: Id
label: string
order?: number
component: FC<ConvViewPropsOf<Id>>
/** Per-view chrome attachments (chat mounts the stats line as footer). */
chrome?: { header?: FC<ChromePropsOf<Id>>; footer?: FC<ChromePropsOf<Id>> }
}
/** Props for view chrome attachments. */
export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
/** Selection target for the details linkage channel (toolcall is the step special case). */
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
/** Props handed to registered conversation views. */
export interface ConvViewProps {
sessionId: SessionId
useSession: UseSession
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
/** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */
slots: ScopedSlots<never>
}

View File

@@ -0,0 +1,42 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* typed view registry, scope-addressed ConversationService, named toolview
* registry, minimal details panel. Contract: api-contracts v3 section 7.
* Thin shell: type surfaces live in contract/, assembly in apply.ts; the
* three implementation domains (skeleton/chat/toolviews) never import each
* other — contract/ is their only shared face.
*/
import type { ConversationService } from './service.ts'
import type { ToolViewRegistry } from './toolviews/registry.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export { ToolViewRegistry } from './toolviews/registry.ts'
export type {
CallId, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, ConvViewPropsOf,
SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
} from './contract/views.ts'
export type {
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
} from './contract/toolview.ts'
export type {
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps,
} from './contract/slots.ts'
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
export type { ConversationRootProps } from './skeleton/ConversationRoot.tsx'
export { InputBar } from './skeleton/InputBar.tsx'
export type { InputBarError, InputBarProps } from './skeleton/InputBar.tsx'
export { EmptyState } from './skeleton/EmptyState.tsx'
export type { EmptyStateProps } from './skeleton/EmptyState.tsx'
export { DetailsPanel } from './skeleton/DetailsPanel.tsx'
export type { DetailsPanelProps } from './skeleton/DetailsPanel.tsx'
declare module 'cordis' {
interface Context {
conversation: ConversationService
toolviews: ToolViewRegistry
}
}

View File

@@ -0,0 +1,251 @@
/**
* ConversationService implementation: scope-addressed send/cancel, per-scope
* selection/draft stores booked on the session scope fiber, view registry
* with a uSES read face, openDetails orchestration, and the empty-state
* startSession chain. Contract: api-contracts v3 section 7.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
* read the session tag with scopeOf (same mechanism as the host tool
* registry). Mutable state lives in plain objects reached by one property
* read — field assignment through the tracker's shadow proxy is off-limits,
* as are `#` hard-private fields.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
// Value import MUST use the /client subpath: only that specifier is in the
// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
// module at load time. A bare-specifier value import gets INLINED as a second
// module instance whose private scope-tag Symbol never matches the one
// SessionsService tags contexts with — scopeOf then always returns undefined
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './index.ts'
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
entries: Map<string, ViewEntry>
/** Sorted projection cache; null = rebuild on next read. */
cache: readonly ViewEntry[] | null
tick: number
listeners: Set<() => void>
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly selections = new Map<SessionId, SnapshotStore<SelectionTarget | null>>()
private readonly draftStores = new Map<SessionId, SnapshotStore<string>>()
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
}
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
*/
constructor(ctx: Context) {
super(ctx, 'conversation')
}
/**
* Send a prompt into the scoped session. Business failures also land in the
* session snapshot's promptError (object-layer surface); the rejection here
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
*/
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
const session = this.scopedSession('send')
const result = await session.prompt([{ type: 'text', text }], mode)
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')
const result = await session.cancel()
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/** Per-scope selection channel (details linkage); root access throws. */
get selection(): SnapshotStore<SelectionTarget | null> {
return this.scopeStore(this.selections, 'selection',
() => createSnapshotStore<SelectionTarget | null>(null))
}
/**
* Per-scope draft store, persisted per session id; root access throws.
* Persistence is hand-rolled (raw string per key): the snapshot-store
* engine's persist middleware object-spreads state on save, corrupting
* primitive-state stores.
*/
get drafts(): SnapshotStore<string> {
return this.scopeStore(this.draftStores, 'drafts', (id) => {
const key = `dsh.conversation.draft.${id}`
const store = createSnapshotStore<string>(loadDraft(key))
store.subscribe(() => { saveDraft(key, store.getSnapshot()) })
return store
})
}
/**
* Write the scoped selection and open the details panel. Orchestration
* only — panel geometry stays with ctx.layout.
* @param target - selection target.
*/
openDetails(target: SelectionTarget): void {
this.selection.set(target)
this.requireLayout().openDetails()
}
/**
* Register a conversation view. Duplicate ids throw; the registration is an
* effect on the caller's fiber (plugin unload collects it).
* @param entry - the view entry.
* @returns disposer removing the view.
*/
registerView<Id extends ViewId>(entry: ViewEntry<Id>): () => void {
const views = this.viewsState
const dispose = this.ctx.effect(() => {
if (views.entries.has(entry.id)) {
throw new Error(`conversation view "${entry.id}" is already registered`)
}
views.entries.set(entry.id, entry)
bumpViews(views)
return () => {
views.entries.delete(entry.id)
bumpViews(views)
}
}, 'conversation.registerView()')
// The effect disposer settles asynchronously; the registry face stays a
// synchronous fire-and-forget disposer.
return () => { void dispose() }
}
/**
* Registered views ordered by `order` (ties keep registration sequence).
* Stable array reference between mutations (uSES getSnapshot source).
* @returns the view entries.
*/
views(): readonly ViewEntry[] {
const state = this.viewsState
state.cache ??= [...state.entries.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return state.cache
}
/**
* Subscribe to view registry changes (synchronous, like the toolview registry).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribeViews(fn: () => void): () => void {
const { listeners } = this.viewsState
listeners.add(fn)
return () => { listeners.delete(fn) }
}
/**
* Monotonic view registry version for uSES pairing.
* @returns current version.
*/
viewsVersion(): number {
return this.viewsState.tick
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
* The create → open ordering is safe: the manager merges the new summary
* synchronously before create() resolves, so the list store is projected by
* the time open() validates against it (manager notification batching is
* microtask-based; SessionsService projects on the same flush that create
* awaited through the RPC round trip).
* @param opts - project directory, prompt text, and send mode.
*/
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
const sessions = this.requireSessions()
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
// The manager notifier flushes per microtask; one await guarantees the
// list-store projection landed before layout.open validates against it.
await Promise.resolve()
this.requireLayout().open(id)
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
// ctx.get, not scoped.conversation: property access walks the fiber
// topology (a scope fiber never injects services), while get reads the
// global store and still binds this service to the scoped ctx.
const scopedConversation = scoped.get('conversation')
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
await scopedConversation.send(opts.text, opts.mode)
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
}
/** Read the caller's session scope tag; root contexts fail loud. */
private scopeId(op: string): SessionId {
const id = scopeOf(this.ctx)
if (id === undefined) {
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
}
return id
}
/**
* Per-scope store account: lazily created, booked on the scope fiber so the
* scope teardown (SessionsService prune) collects the entry.
*/
private scopeStore<T>(
map: Map<SessionId, SnapshotStore<T>>, op: string,
make: (id: SessionId) => SnapshotStore<T>): SnapshotStore<T> {
const id = this.scopeId(op)
let store = map.get(id)
if (store === undefined) {
store = make(id)
map.set(id, store)
this.ctx.effect(() => () => { map.delete(id) }, `conversation.${op} scope account`)
}
return store
}
private requireSessions(): SessionsService {
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
// while the client/host `sessions` declaration collision awaits
// arbitration (see the runtime package's Context merge note).
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions
}
private requireLayout(): LayoutService {
const layout = this.ctx.get('layout')
if (layout === undefined) throw new Error('conversation: layout service unavailable')
return layout
}
}
function bumpViews(state: ViewsState): void {
state.cache = null
state.tick += 1
for (const fn of [...state.listeners]) fn()
}
function loadDraft(key: string): string {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return ''
return localStorage.getItem(key) ?? ''
}
function saveDraft(key: string, text: string): void {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return
if (text === '') localStorage.removeItem(key)
else localStorage.setItem(key, text)
}

View File

@@ -0,0 +1,129 @@
/* Conversation column skeleton: header (breadcrumb row + tabs) over the view
area, composer InputBar at the bottom. Column width/squeeze is layout's;
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
a 3px active bar. */
.root {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
background: var(--dsw-alias-bg-base);
}
.header {
flex: none;
padding: 12px 28px 0 20px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.crumbRow {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.crumb {
max-width: 220px;
overflow: hidden;
padding: 4px 8px;
border: none;
border-radius: 12px;
background: transparent;
font-size: 13px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumb:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
}
.meta {
margin-left: 4px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
display: flex;
gap: 36px;
margin-top: 4px;
padding-left: 8px;
}
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
.tab {
position: relative;
padding: 0 0 11px;
border: none;
background: transparent;
font-size: 13px;
line-height: 16px;
font-weight: 510;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.tab::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
background: transparent;
}
/* Selected tab is blue, not ink: brand-primary resolves to neutral black in
this token sheet, so the selected state rides the business blue — the
nearest semantic token that stays blue in both themes. */
.tabActive {
color: var(--dsw-alias-state-business-primary);
}
.tabActive::after {
background: var(--dsw-alias-state-business-primary);
}
.viewArea {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}

View File

@@ -0,0 +1,109 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Zero framework imports — everything
// arrives via props from the inject factory: breadcrumb feed, view registry
// read face, per-view render, and the composer's draft/send choreography.
// The active view id lives in layout.viewFor (shell viewing state), read and
// written through injected accessors.
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './ConversationRoot.module.css'
/**
* Full props = owner share (sessionId) & standard share (useSession) &
* injected share — composed by reference from the contract, never re-typed
* here (share-ownership rule).
*/
export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const activeId = useActiveView() ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[0]
const ancestry = useAncestry()
const draft = composer.useDraft()
const running = useSession(s => (s as { running: boolean }).running)
const removed = useSession(s => (s as { removed: boolean }).removed)
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
return (
<div className={css.root}>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="会话层级">
{ancestry.map((s, i) => {
const last = i === ancestry.length - 1
return (
<span key={s.id} className={css.crumbSeg}>
{i > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { actions.open(s.id) }}
>
{s.title}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
<span className={css.meta}>· {turns} turns</span>
</nav>
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
placeholder registry slot is deferred — buttons land with their features. */}
</div>
{list.length > 1 && (
<div className={css.tabs} role="tablist">
{list.map(v => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={v.id === active?.id}
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
onClick={() => { actions.openView(v.id) }}
>
{v.label}
</button>
))}
</div>
)}
</header>
<div className={css.viewArea}>
{active !== undefined && renderView(active)}
</div>
<InputBar
draft={draft}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
/>
</div>
)
}
/** Turn count = user message nodes in the window (display meta; exact host count deferred). */
function countTurns(s: { nodes: readonly { kind: string }[] }): number {
let n = 0
for (const node of s.nodes) if (node.kind === 'user') n += 1
return n
}

View File

@@ -0,0 +1,94 @@
/* Details third column, minimal P-I fill: header (name + close) over a
scrolling body of Input/Output code sections. Panel width/squeeze belongs
to layout; this fills whatever the column gives. */
.root {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
border-left: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-base);
}
/* figma RightSidebar header frame (I54:42735;43:36451): pad 14/12/12/12, gap 8. */
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 12px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* figma I54:42735;43:41479: 14/20 wt500. */
.title {
overflow: hidden;
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.close {
display: grid;
flex: none;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.body {
flex: 1;
min-height: 0;
padding: 12px 16px;
overflow-y: auto;
}
.empty {
padding: 8px 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
.section {
margin-bottom: 16px;
}
.sectionLabel {
margin-bottom: 6px;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
/* figma Code-block (I54:42735;43:41429): r12, pad 16, mono 13/22. */
.code {
margin: 0;
padding: 16px;
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
font-family: var(--ds-font-family-code);
font-size: 13px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.code[data-error] {
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -0,0 +1,111 @@
// DetailsPanel, P-I minimal form: close button + the selected call's args and
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
// trajectory are deferred (ledger). Subscribes to the per-scope selection and
// derives the call material from the session snapshot — no data of its own.
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { DetailsSlotProps } from '../contract/slots.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (owner & standard & injected shares). */
export type DetailsPanelProps = DetailsSlotProps
/** Selected call material: resolved result node, or the in-flight running call's args. */
interface CallMaterial {
name: string
argsRaw: string | null
result: ToolResultNode | null
running: boolean
}
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
for (const node of s.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) {
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
}
}
const open = s.runningCalls.find(c => c.callId === callId)
if (open !== undefined) {
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
}
return null
}
function pretty(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
// Not JSON (streaming fragment or plain text): show verbatim.
return raw
}
}
export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanelProps) {
const selection = useSelection(s => s)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
const material = useSession(
s => (callId === undefined ? null : materialFor(s as ConversationSnapshot, callId)),
(a, b) => shallowEqual(a, b))
return (
<div className={css.root}>
<div className={css.header}>
<div className={css.title}>
{selection === null ? '详情' : material?.name ?? selection.toolName ?? '详情'}
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
onClick={() => { actions.closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className={css.body}>
{selection === null || callId === undefined
? <div className={css.empty}></div>
: material === null
? <div className={css.empty}></div>
: (
<>
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<pre className={css.code}>{pretty(material.argsRaw)}</pre>
</section>
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
{/* materialFor invariant: result===null ⇔ running (a settled
material always carries its result node). */}
{material.result === null
? <div className={css.empty}></div>
: (
<pre className={css.code} data-error={material.result.isError || undefined}>
{renderResult(material.result)}
</pre>
)}
</section>
</>
)}
</div>
</div>
)
}
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
function renderResult(node: ToolResultNode): string {
const parts: string[] = []
for (const block of node.content) {
if (block.type === 'text') parts.push(block.text)
else parts.push(JSON.stringify(block, null, 2))
}
if (parts.length === 0 && node.error !== undefined) {
parts.push(`${node.error.name}: ${node.error.code}`)
}
return parts.join('\n')
}

View File

@@ -0,0 +1,68 @@
/* NEW SESSION hero: headline over the shared InputBar card, centered in the
conversation column. The card is the same component as the composer —
only positioning lives here. */
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
padding: 24px;
}
/* figma hero group 34:10409: headline block sits 36px above the input card. */
.card {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
max-width: 776px;
}
/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 26px;
line-height: 32px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
/* figma 34:10412/10413: brand-blue vector. */
.fish {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.picker {
display: flex;
align-items: center;
min-width: 0;
}
.select,
.customInput {
max-width: 320px;
padding: 4px 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-bg-base);
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.customInput {
width: 320px;
outline: none;
}
.customInput:focus {
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
border-color: var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-label-primary);
}

View File

@@ -0,0 +1,106 @@
// EmptyState (figma NEW SESSION screen): centered hero card built around the
// SAME InputBar component the resident composer uses (the empty→content
// transition is one component changing position, never a swap). Project
// picker: cwd set derived from sessions.list plus a free-form new-directory
// input; submit runs the startSession chain (create → open → send) in one
// service call.
import { useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
const NEW_DIR = '::new-directory'
/** Full props composed by reference from the contract (owner & injected shares; root slot has no standard share). */
export type EmptyStateProps = EmptyStateSlotProps
export function EmptyState({ useCwds, actions }: EmptyStateProps) {
const cwds = useCwds(s => s)
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
const [cwd, setCwd] = useState<string>('')
const [custom, setCustom] = useState(false)
const [sending, setSending] = useState(false)
const [error, setError] = useState<InputBarError | null>(null)
const submit = (mode: 'queue' | 'steer'): void => {
const text = draft.trim()
/* v8 ignore next -- defensive: InputBar disables send while empty. */
if (text === '' || sending) return
setSending(true)
setError(null)
const chosen = cwd.trim()
actions.startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
setSending(false)
})
// Success needs no cleanup: layout.open swaps this slot out for the session body.
}
const picker = (
<div className={css.picker}>
{custom
? (
<input
className={css.customInput}
value={cwd}
autoFocus
placeholder="目录路径,如 /home/me/proj"
onChange={(e) => { setCwd(e.target.value) }}
/>
)
: (
<select
className={css.select}
value={cwd}
aria-label="项目目录"
onChange={(e) => {
if (e.target.value === NEW_DIR) {
setCustom(true)
setCwd('')
} else {
setCwd(e.target.value)
}
}}
>
<option value=""></option>
{cwds.map(c => <option key={c} value={c}>{c}</option>)}
<option value={NEW_DIR}></option>
</select>
)}
</div>
)
return (
<div className={css.root}>
<div className={css.card}>
<div className={css.headline}>
{/* figma 34:10412: fish 34x25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<InputBar
draft={draft}
running={false}
disabled={sending}
error={error}
variant="hero"
placeholder="Message to run task, plan and build"
accessory={picker}
onDraftChange={setDraft}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,160 @@
/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
column (776 is a cap, not a fixed size — layout rule: the box shrinks with
the center column keeping its padding). Hero variant = the same card
centered in the empty state; the transition between the two is a position
move of one component. */
.root {
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
padding: 8px 32px 12px;
}
.hero {
padding: 0;
}
.error {
width: 100%;
max-width: 776px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.card {
display: flex;
flex-direction: column;
/* figma Input 34:11458: 12px between the text area and the button row. */
gap: 12px;
width: 100%;
max-width: 776px;
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
the input border is one notch weaker than buttons) — exactly the
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
line-height: 24px;
}
/* New-session state rounds up (figma: r24 and a taller box). */
.hero .card {
border-radius: 24px;
}
.accessory {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 0;
}
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
MUST share font, line-height, padding and wrapping rules or heights diverge. */
.grow {
position: relative;
}
.input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
resize: none;
overflow-y: auto;
border: none;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
caret-color: var(--dsw-alias-state-business-primary);
}
.input,
.mirror {
padding: 12px 16px 0;
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
.input::placeholder {
color: var(--dsw-alias-label-caption);
user-select: none;
}
/* Running lock: grayed but the draft stays visible; the turn ending re-enables. */
.input:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: not-allowed;
}
.mirror {
visibility: hidden;
pointer-events: none;
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
min-height: 60px;
max-height: 336px;
overflow: hidden;
}
.hero .mirror {
/* New-session box is taller at rest (figma 118px input area). */
min-height: 84px;
}
/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */
.row {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 10px 10px 12px;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
white glyph; empty text = 0.4 opacity. */
.primary {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill);
color: var(--dsw-alias-label-primary-foreground);
cursor: pointer;
}
.primary:hover {
background: var(--dsw-alias-button-info-hover);
}
.primary:disabled {
opacity: 0.4;
cursor: default;
}
/* Stop state: same slot, dimmed brand fill — the running-state send-key
replacement is a design gap filled by us (figma gives no stop form). */
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-brand-text);
}

View File

@@ -0,0 +1,142 @@
// InputBar: the one composer input (figma Input_Bottom). The same component
// serves the empty state (variant='hero': centered launch card) and the
// resident composer (variant='composer') — the empty→content transition is a
// position move of this component, never a swap (layout ruling). Running
// LOCKS the input: textarea disabled with the draft visible, stop is the only
// action; the turn ending re-enables and refocuses.
import { useEffect, useRef } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import css from './InputBar.module.css'
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
export interface InputBarError {
op: 'send' | 'stop'
message: string
}
export interface InputBarProps {
draft: string
running: boolean
disabled: boolean
error: InputBarError | null
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
const onCompositionStart = (): void => {
composingRef.current = true
}
const onCompositionEnd = (): void => {
setTimeout(() => {
composingRef.current = false
}, 10)
}
// Locked while running: the browser drops keystrokes AND focus on a disabled
// textarea — no sending mid-turn, stop or wait.
const locked = disabled || running
// Unlock (mount / session switch / turn end) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key !== 'Enter') return
if (composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return
if (e.shiftKey) return // native newline
if (e.ctrlKey || e.metaKey) {
// execCommand keeps the browser undo stack intact, unlike a setState splice.
e.preventDefault()
document.execCommand('insertText', false, '\n')
return
}
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (!empty && !locked) onSend('queue')
}
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
inputRef.current?.focus()
}
const primaryLabel = running ? '停止' : '发送'
const onPrimary = (): void => {
if (running) {
onStop()
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled) onSend('queue')
}
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
<div className={css.error}>
{error.op === 'stop' ? '停止失败' : '发送失败'}{error.message}
</div>
)}
<div className={css.card}>
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息Enter 发送Shift+Enter 换行')}
rows={2}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={running ? '停止本轮' : '发送Enter'}
disabled={!running && (empty || disabled)}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
/* Sample bash rows: deliberately distinct from ToolRow so the differential
registry hit is visible at a glance. */
.row {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-family: var(--ds-font-family-code);
font-size: 13px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.prompt {
flex: none;
color: var(--dsw-alias-state-success-primary);
}
.scopeBadge {
flex: none;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
line-height: 18px;
color: var(--dsw-alias-label-primary-foreground);
background: var(--dsw-alias-state-business-primary);
}
.command {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
}

View File

@@ -0,0 +1,52 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public registration surface (ctx.toolviews.register + ToolViewProps)
// — the differential-rendering acceptance proof for the registry chain.
// Two registrations: a global bash row, and a scope-filtered variant that
// takes over for matching sessions only (later registration wins its tier).
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewRegistry } from './registry.ts'
import { toolRowModel, type ToolCallBlock } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Global bash row: command-first monospace summary (replaces the generic row). */
export function BashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
return (
<div className={css.row} data-sample="bash-global" onClick={actions.openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
</div>
)
}
/** Scoped variant: visually distinct so the differential hit is observable. */
export function ScopedBashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
return (
<div className={css.row} data-sample="bash-scoped" onClick={actions.openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
/**
* Register both sample rows.
* @param toolviews - the conversation plugin's registry service.
* @param scope - session filter for the scoped variant.
* @returns disposer removing both registrations.
*/
export function registerBashSamples(
toolviews: ToolViewRegistry,
scope: (sessionId: SessionId) => boolean,
): () => void {
const offGlobal = toolviews.register('bash', BashRow)
const offScoped = toolviews.register('bash', ScopedBashRow, { scope })
return () => {
offGlobal()
offScoped()
}
}

View File

@@ -0,0 +1,103 @@
/**
* ToolViewRegistry: named per-tool component registry, session-scope aware
* (api-contracts v3 section 7). Consumed by chat now, trajectory/waterfall
* later — deliberately a named service, not a SlotMap key. The tool key set
* is deliberately open (model-side tools arrive at runtime): the strong
* typing lives inside the Entry — `I` is inferred from the inject factory at
* the register site and proves component props ⊇ ToolViewProps & I.
*/
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ResolvedToolView, ToolViewOptions, ToolViewProps } from '../contract/toolview.ts'
/** Stored registration: the per-registration inject parameter is erased
* (storage-erase/read-restore is the typed-Map boundary, one cast budgeted). */
interface Registration extends ToolViewOptions {
component: FC<ToolViewProps & object>
}
/**
* Per-tool renderer registry. Resolution order: scope match (later
* registration wins) > global (same tie-break) > undefined, where the caller
* falls back to GenericToolCard.
*/
export class ToolViewRegistry {
private byTool = new Map<string, Registration[]>()
private version = 0
private listeners = new Set<() => void>()
/**
* Register a tool row renderer. The component must accept the shared
* ToolViewProps plus its own injected share `I` — mismatches (missing keys,
* wrong types, an inject factory that does not produce what the component
* declares) are register-site compile errors.
* @param tool - tool name the renderer takes over.
* @param component - row component over ToolViewProps & I.
* @param opts - optional session-scope filter and private inject factory.
* @returns disposer removing this registration.
*/
register<I extends object = object>(
tool: string, component: FC<ToolViewProps & I>, opts?: ToolViewOptions<I>): () => void {
const list = this.byTool.get(tool) ?? []
if (list.length === 0) this.byTool.set(tool, list)
// Storage erases I (heterogeneous registrations share one list); resolve
// restores the erased shape on the read face.
const entry: Registration = { component: component as FC<ToolViewProps & object>, ...opts }
list.push(entry)
this.bump()
let disposed = false
return () => {
if (disposed) return
disposed = true
const at = list.indexOf(entry)
/* v8 ignore next -- negative arm: an entry lives in one list and only its
own once-guarded disposer removes it, so a live disposer always finds it. */
if (at >= 0) list.splice(at, 1)
if (list.length === 0) this.byTool.delete(tool)
this.bump()
}
}
/**
* Resolve the renderer for a tool in a session.
* @param tool - tool name.
* @param sessionId - session the row renders in (fed to scope filters).
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined {
const list = this.byTool.get(tool)
if (list === undefined) return undefined
let global: Registration | undefined
let scoped: Registration | undefined
for (const entry of list) {
if (entry.scope === undefined) global = entry
else if (entry.scope(sessionId)) scoped = entry
}
const hit = scoped ?? global
if (hit === undefined) return undefined
return hit.inject === undefined ? { component: hit.component } : { component: hit.component, inject: hit.inject }
}
/**
* Subscribe to registration changes (render outlets re-resolve on notify).
* @param fn - change listener.
* @returns disposer.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
/**
* Monotonic registration version for uSES getSnapshot.
* @returns current version.
*/
getVersion(): number {
return this.version
}
private bump(): void {
this.version += 1
for (const fn of this.listeners) fn()
}
}

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