Merge PR #500 into CI optimization
This commit is contained in:
71
packages/client/AGENTS.md
Normal file
71
packages/client/AGENTS.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# AGENTS.md — Web client stack
|
||||
|
||||
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
|
||||
|
||||
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
## Layering red lines
|
||||
|
||||
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
|
||||
|
||||
1. **Data object layer** (`web-runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
|
||||
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
|
||||
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
|
||||
|
||||
Non-negotiables across the layers:
|
||||
|
||||
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
|
||||
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Directory regime (`web-ui/src`)
|
||||
|
||||
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
|
||||
|
||||
Two-level feature directories, one contributor per directory — physical conflict avoidance:
|
||||
|
||||
```
|
||||
web-ui/src/
|
||||
shell/ # AppShell + the three slot registries + builtins
|
||||
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
|
||||
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
|
||||
components/ # shared leaves (MessageText, JsonBlock, …)
|
||||
hooks/ utils/ style/ # cross-cutting; not feature-owned
|
||||
```
|
||||
|
||||
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
|
||||
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
|
||||
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
|
||||
|
||||
## Styling
|
||||
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
|
||||
|
||||
## Testing and coverage
|
||||
|
||||
The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
|
||||
|
||||
- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
|
||||
- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
|
||||
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
|
||||
- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
|
||||
|
||||
## Before you push: the local check ladder
|
||||
|
||||
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
|
||||
|
||||
1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
|
||||
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
|
||||
3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
|
||||
|
||||
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
|
||||
|
||||
## New component checklist
|
||||
|
||||
1. Claim the slot (see the directory regime above): one feature, one directory.
|
||||
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
|
||||
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
|
||||
4. Tokens only in CSS; Chinese product copy; English comments.
|
||||
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.
|
||||
16
packages/client/connection/README.md
Normal file
16
packages/client/connection/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
|
||||
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
|
||||
53
packages/client/connection/package.json
Normal file
53
packages/client/connection/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
46
packages/client/connection/src/client/api.ts
Normal file
46
packages/client/connection/src/client/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Central contract re-export point: every contract import inside
|
||||
// web-runtime goes through this single file.
|
||||
// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
|
||||
// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels added for this.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/**
|
||||
* Unwrap a unary response: RpcResponse<T> -> RpcResult<T> (business code only
|
||||
* cares about the result slot).
|
||||
* @param response - the unary response.
|
||||
* @returns its result slot.
|
||||
*/
|
||||
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
|
||||
return response.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code).
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
190
packages/client/connection/src/client/connection.ts
Normal file
190
packages/client/connection/src/client/connection.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
export interface ConnectionConfig {
|
||||
/** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
|
||||
backoffBaseMs?: number
|
||||
/** Exponential growth factor per consecutive failed attempt. */
|
||||
backoffFactor?: number
|
||||
/** Upper bound for the backoff cap in ms. */
|
||||
backoffMaxMs?: number
|
||||
/** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
|
||||
* (audit C2) waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
|
||||
* generation proceeds as connected and the live-gap repair path (audit S3) covers stragglers. */
|
||||
streamOpenTimeoutMs?: number
|
||||
}
|
||||
|
||||
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
|
||||
backoffBaseMs: 500,
|
||||
backoffFactor: 2,
|
||||
backoffMaxMs: 10_000,
|
||||
streamOpenTimeoutMs: 3_000,
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(done, ms)
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
function done(): void {
|
||||
clearTimeout(t)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
|
||||
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
|
||||
export type ConnectionState = 'connected' | 'reconnecting'
|
||||
|
||||
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
|
||||
* SessionManager. */
|
||||
export interface ConnectionSinks {
|
||||
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap
|
||||
* never fires unless someone for-awaits), reconnecting with exponential backoff on loss.
|
||||
* State (generation/attempt) is instance-private, never in the store.
|
||||
* The pump body feeds each frame to a sink (sink exceptions must
|
||||
* not kill the pump — a broken business layer must not drag down the connection layer).
|
||||
*/
|
||||
export class ConnectionController {
|
||||
private generation = 0
|
||||
private attempt = 0
|
||||
private current: AbortController | null = null
|
||||
private running = false
|
||||
private lastState: ConnectionState | null = null
|
||||
private readonly config: Required<ConnectionConfig>
|
||||
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly sinks: ConnectionSinks = {},
|
||||
config: ConnectionConfig = {},
|
||||
) {
|
||||
this.config = { ...CONNECTION_DEFAULTS, ...config }
|
||||
}
|
||||
|
||||
/** Idempotent: begin the connect/pump/reconnect loop. */
|
||||
start(): void {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
void this.loop()
|
||||
}
|
||||
|
||||
/** Stop the loop and abort the current generation's streams. */
|
||||
stop(): void {
|
||||
this.running = false
|
||||
this.current?.abort()
|
||||
this.current = null
|
||||
}
|
||||
|
||||
private backoffDelay(attempt: number): number {
|
||||
const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config
|
||||
const cap = Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1))
|
||||
return cap / 2 + Math.random() * (cap / 2)
|
||||
}
|
||||
|
||||
/** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
|
||||
private isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
private async loop(): Promise<void> {
|
||||
while (this.running) {
|
||||
const gen = ++this.generation
|
||||
const ac = new AbortController()
|
||||
this.current = ac
|
||||
|
||||
/* v8 ignore next -- initializer placeholder: the Promise executor
|
||||
* below runs synchronously and replaces it before anyone can call it. */
|
||||
let muxOpened = (): void => {}
|
||||
/* v8 ignore next -- same placeholder pattern as muxOpened. */
|
||||
let hostOpened = (): void => {}
|
||||
const streamsOpen = Promise.all([
|
||||
new Promise<void>((resolve) => { muxOpened = resolve }),
|
||||
new Promise<void>((resolve) => { hostOpened = resolve }),
|
||||
])
|
||||
|
||||
const failed = new Promise<void>((resolve) => {
|
||||
const settle = (): void => {
|
||||
if (gen === this.generation && !ac.signal.aborted) ac.abort()
|
||||
resolve()
|
||||
}
|
||||
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
|
||||
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
|
||||
})
|
||||
|
||||
try {
|
||||
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
|
||||
// proves each SSE transport is established (response headers in, before any frame) —
|
||||
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
// (see ConnectionConfig.streamOpenTimeoutMs).
|
||||
const timeout = new AbortController()
|
||||
await Promise.all([
|
||||
this.api.host.describe({}),
|
||||
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
|
||||
])
|
||||
timeout.abort()
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
} catch {
|
||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||
if (!ac.signal.aborted) ac.abort()
|
||||
}
|
||||
|
||||
await failed
|
||||
if (!this.isRunning()) return
|
||||
this.emitState('reconnecting')
|
||||
this.attempt += 1
|
||||
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
|
||||
const idle = new AbortController()
|
||||
await sleep(this.backoffDelay(this.attempt), idle.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deduplicated state emission (sink isolation applies). */
|
||||
private emitState(state: ConnectionState): void {
|
||||
if (this.lastState === state) return
|
||||
this.lastState = state
|
||||
this.callSink(() => this.sinks.onStateChange?.(state))
|
||||
}
|
||||
|
||||
private async pumpStream<F extends { type: string }>(
|
||||
stream: AsyncIterable<RpcRequest<F>>,
|
||||
sink: ((envelope: RpcRequest<F>) => void) | undefined,
|
||||
onEnd: () => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for await (const envelope of stream) {
|
||||
if (envelope.payload.type === 'stream/error') break
|
||||
if (sink !== undefined) this.callSink(() => { sink(envelope) })
|
||||
}
|
||||
} catch {
|
||||
// Stream loss: converge on onEnd, which triggers the shared reconnect.
|
||||
}
|
||||
onEnd()
|
||||
}
|
||||
|
||||
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
|
||||
private callSink(fn: (() => void) | undefined): void {
|
||||
if (fn === undefined) return
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] connection sink threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
560
packages/client/connection/src/client/fixture.ts
Normal file
560
packages/client/connection/src/client/fixture.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
|
||||
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
||||
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
||||
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
|
||||
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId } from './api.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(crypto.randomUUID()), payload }
|
||||
}
|
||||
|
||||
function text(t: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: t }]
|
||||
}
|
||||
|
||||
function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
|
||||
* mixing reasoning blocks / tool call+result / steering / context. */
|
||||
function buildAlphaLog(): SessionEvent[] {
|
||||
const events: Record<string, unknown>[] = []
|
||||
let time = Date.now() - 3_600_000
|
||||
const push = (e: Record<string, unknown>): number => {
|
||||
const seq = events.length
|
||||
events.push({ seq, time: (time += 800), ...e })
|
||||
return seq
|
||||
}
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
|
||||
if (turn % 9 === 4) {
|
||||
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
|
||||
}
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
const withTool = turn % 5 === 2
|
||||
const withReasoning = turn % 3 === 1
|
||||
const blocks: ContentBlock[] = []
|
||||
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
|
||||
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
|
||||
if (withTool) {
|
||||
const callId = `fx-call-${turn}`
|
||||
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'step/start', data: { turn, step: 1 } })
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化(turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'step/end', data: { turn, step: 1 } })
|
||||
} else {
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
}
|
||||
if (turn % 13 === 6) {
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, content: text(`插话 ${turn}:fixture steering 消息。`), source: { kind: 'user' } } })
|
||||
}
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
|
||||
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:${name} 样本。`), source: { kind: 'user' } } })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
|
||||
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
|
||||
return events as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
|
||||
/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
|
||||
const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
|
||||
|
||||
/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
|
||||
function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
let args: Record<string, unknown>
|
||||
try {
|
||||
args = JSON.parse(argsRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
/* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
|
||||
return undefined
|
||||
}
|
||||
switch (name) {
|
||||
case 'fx-bash':
|
||||
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
|
||||
case 'fx-write':
|
||||
return {
|
||||
card: 'diff', title: `Write ${str(args.path)}`,
|
||||
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
|
||||
}
|
||||
case 'fx-note':
|
||||
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
|
||||
default:
|
||||
return undefined // echo et al: the documented no-view fallback path
|
||||
}
|
||||
}
|
||||
|
||||
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
|
||||
const call = presentCall(name, argsRaw)
|
||||
if (call === undefined) return undefined
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
return { card: 'terminal', output: resultText, exitCode: 0 }
|
||||
case 'diff':
|
||||
return { card: 'diff', diffs: call.diffs }
|
||||
case 'generic':
|
||||
return { card: 'generic', content: text(resultText) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
|
||||
function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined {
|
||||
if (event.type === 'tool/call') {
|
||||
const view = presentCall(event.data.name, event.data.arguments)
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const callId = String(event.data.callId)
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const candidate = log[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
|
||||
so the undefined arm needs a sparse log no code path builds. */
|
||||
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
|
||||
const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
|
||||
return view === undefined ? undefined : { for: 'result', view }
|
||||
}
|
||||
}
|
||||
return undefined // cross-page unpaired: documented default
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary paging (mirrors the host's paging contract): count
|
||||
* maxMessages messages
|
||||
* backwards from end, cut at a turn/start boundary.
|
||||
Entries carry pagination-time views
|
||||
* (the host analogue computes viewFor per entry at page time). */
|
||||
function pageOf(
|
||||
log: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
maxMessages: number,
|
||||
): { events: HistoryEntry[]; hasMore: boolean } {
|
||||
const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length))
|
||||
let start = 0
|
||||
let messages = 0
|
||||
for (let i = end - 1; i >= 0; i--) {
|
||||
const event = log[i]
|
||||
/* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */
|
||||
if (event === undefined) break
|
||||
if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++
|
||||
if (event.type === 'turn/start' && messages >= maxMessages) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const events = log.slice(start, end).map((event): HistoryEntry => {
|
||||
const view = viewFor(event, log)
|
||||
return view === undefined ? { event } : { event, view }
|
||||
})
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
|
||||
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
|
||||
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
|
||||
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
|
||||
* client's signal (timing hook: simulated connection loss). */
|
||||
class FxInbox<F> implements StreamConn<F> {
|
||||
private readonly inbox: RpcRequest<F>[] = []
|
||||
private wake: (() => void) | null = null
|
||||
private broken = false
|
||||
|
||||
push(envelope: RpcRequest<F>): void {
|
||||
this.inbox.push(envelope)
|
||||
this.wake?.()
|
||||
}
|
||||
|
||||
breakNow(): void {
|
||||
this.broken = true
|
||||
this.wake?.()
|
||||
}
|
||||
|
||||
/** Read through a method: breakNow()/abort flip state across yields, so narrowing from the loop condition must not stick. */
|
||||
private isLive(signal: AbortSignal): boolean {
|
||||
return !signal.aborted && !this.broken
|
||||
}
|
||||
|
||||
async *drain(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
|
||||
const onAbort = (): void => this.wake?.()
|
||||
signal.addEventListener('abort', onAbort)
|
||||
try {
|
||||
while (this.isLive(signal)) {
|
||||
while (this.inbox.length > 0) yield this.inbox.shift() as RpcRequest<F>
|
||||
if (!this.isLive(signal)) break
|
||||
await new Promise<void>((resolve) => {
|
||||
this.wake = resolve
|
||||
})
|
||||
this.wake = null
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(): ApiProxy {
|
||||
const sessions: SessionSummary[] = [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||
const pendingApprovalRpcId = mint()
|
||||
|
||||
const muxConns = new Set<StreamConn<MuxFrame>>()
|
||||
const hostConns = new Set<StreamConn<HostFrame>>()
|
||||
const emitMux = (frame: MuxFrame): void => {
|
||||
for (const conn of muxConns) conn.push({ rpcId: mint(), payload: frame })
|
||||
}
|
||||
const emitHost = (frame: HostFrame): void => {
|
||||
for (const conn of hostConns) conn.push({ rpcId: mint(), payload: frame })
|
||||
}
|
||||
|
||||
/** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */
|
||||
function ok<P, T>(request: RpcRequest<P>, value: T): Promise<RpcResponse<T>> {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
|
||||
}
|
||||
function err<P, T>(request: RpcRequest<P>, error: Extract<RpcResult<T>, { ok: false }>['error']): Promise<RpcResponse<T>> {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } })
|
||||
}
|
||||
|
||||
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
|
||||
const setRunning = (id: SessionId, running: boolean): void => {
|
||||
const summary = summaryOf(id)
|
||||
if (summary === undefined || summary.running === running) return
|
||||
summary.running = running
|
||||
emitHost({ type: 'host/session-status', sessionId: id, running })
|
||||
}
|
||||
const logOf = (id: SessionId): SessionEvent[] => {
|
||||
let log = logs.get(id)
|
||||
if (log === undefined) {
|
||||
log = []
|
||||
logs.set(id, log)
|
||||
}
|
||||
return log
|
||||
}
|
||||
const append = (id: SessionId, e: Record<string, unknown>): void => {
|
||||
const log = logOf(id)
|
||||
const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
|
||||
log.push(event)
|
||||
// Emission-time view derivation (mirrors the host's live path).
|
||||
const view = viewFor(event, log)
|
||||
/* v8 ignore next 3 -- the view-present arm needs a live tool/call emission,
|
||||
but the fixture replay produces text-only turns; view vocabulary is
|
||||
exercised through the history samples (turns 60-62). */
|
||||
emitMux(view === undefined
|
||||
? { type: 'session/event', sessionId: id, event }
|
||||
: { type: 'session/event', sessionId: id, event, view })
|
||||
}
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
/** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
|
||||
let historyDelayMs = 0
|
||||
/** One-shot history failure (timing hook: the doomed in-flight request of the S4 reconnect scenario). */
|
||||
let failNextHistory = false
|
||||
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
|
||||
const streamBreakers = new Set<() => void>()
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
// browser acceptance runs create slow-history, lost-frame, and reconnect
|
||||
// windows a real host produces naturally.
|
||||
const timingHooks = {
|
||||
setHistoryDelay(ms: number): void {
|
||||
historyDelayMs = ms
|
||||
},
|
||||
/** Fail the NEXT history call (after its transit delay) with a transport-level throw. */
|
||||
failNextHistory(): void {
|
||||
failNextHistory = true
|
||||
},
|
||||
/** Log append + mux emit (the normal live path). */
|
||||
appendUser(id: string, msg: string): void {
|
||||
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
|
||||
},
|
||||
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
|
||||
appendSilent(id: string, msg: string): void {
|
||||
const log = logOf(sid(id))
|
||||
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
|
||||
},
|
||||
/** End every open stream generator (client sees both streams close -> reconnect + resync path). */
|
||||
breakStreams(): void {
|
||||
for (const breakNow of [...streamBreakers]) breakNow()
|
||||
},
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).__fxTiming = timingHooks
|
||||
|
||||
/** Prompt replay: chunk typewriter (80ms/frame) -> assistant/message finalize -> turn/end + running flip. */
|
||||
const startReply = (id: SessionId, turn: number, replyText: string): void => {
|
||||
const step = 0
|
||||
append(id, { type: 'step/start', data: { turn, step } })
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
|
||||
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
|
||||
let i = 0
|
||||
const finish = (aborted: boolean): void => {
|
||||
replays.delete(id)
|
||||
const done = pieces.slice(0, i).join('')
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
|
||||
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
|
||||
append(id, { type: 'step/end', data: { turn, step } })
|
||||
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
|
||||
setRunning(id, false)
|
||||
}
|
||||
const tick = (): void => {
|
||||
const piece = pieces[i]
|
||||
if (piece === undefined) {
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
i++
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index: 0, text: piece } } })
|
||||
replays.set(id, { timer: setTimeout(tick, 80), finish })
|
||||
}
|
||||
replays.set(id, { timer: setTimeout(tick, 80), finish })
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
create: (request) => {
|
||||
const created: SessionSummary = {
|
||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
|
||||
}
|
||||
sessions.push(created)
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
|
||||
return ok(request, { sessionId: created.sessionId })
|
||||
},
|
||||
history: async (request) => {
|
||||
const log = logs.get(request.payload.sessionId) ?? []
|
||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
|
||||
const doomed = failNextHistory
|
||||
failNextHistory = false
|
||||
const delay = historyDelayMs
|
||||
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
|
||||
if (doomed) throw new Error('fixture: simulated history transport failure')
|
||||
return ok(request, page)
|
||||
},
|
||||
prompt: (request) => {
|
||||
const { sessionId: id, mode, content } = request.payload
|
||||
const summary = summaryOf(id)
|
||||
if (summary === undefined) {
|
||||
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
|
||||
}
|
||||
summary.updatedAt = Date.now()
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: insert a steering message into the current turn; the replay continues.
|
||||
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
|
||||
const turn = (nextTurn.get(id) ?? 1) - 1
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
nextTurn.set(id, turn + 1)
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
|
||||
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
cancel: (request) => {
|
||||
const replay = replays.get(request.payload.sessionId)
|
||||
if (replay !== undefined) {
|
||||
clearTimeout(replay.timer)
|
||||
replay.finish(true)
|
||||
} else {
|
||||
setRunning(request.payload.sessionId, false)
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
const conn = new FxInbox<MuxFrame>()
|
||||
muxConns.add(conn)
|
||||
const breakNow = (): void => { conn.breakNow() }
|
||||
streamBreakers.add(breakNow)
|
||||
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
|
||||
for (const s of sessions) {
|
||||
if (!s.running) continue
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
||||
}
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
payload: {
|
||||
type: 'approval/requested', sessionId: sid('fx-alpha'),
|
||||
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
|
||||
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
||||
},
|
||||
})
|
||||
try {
|
||||
yield* conn.drain(signal)
|
||||
} finally {
|
||||
streamBreakers.delete(breakNow)
|
||||
muxConns.delete(conn)
|
||||
}
|
||||
},
|
||||
async *host(_request, signal) {
|
||||
const conn = new FxInbox<HostFrame>()
|
||||
hostConns.add(conn)
|
||||
const breakNow = (): void => { conn.breakNow() }
|
||||
streamBreakers.add(breakNow)
|
||||
// Periodic material (the RPC-panel acceptance's clear-then-new-frames step depends on it): flip fx-gamma every 5s.
|
||||
// fx-gamma only: never touch fx-alpha's running semantics (the conversation replay drives that).
|
||||
const timer = setInterval(() => {
|
||||
const gamma = summaryOf(sid('fx-gamma'))
|
||||
/* v8 ignore next -- the undefined arm needs fx-gamma deleted, but the fixture never removes sessions. */
|
||||
if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running)
|
||||
}, 5000)
|
||||
try {
|
||||
yield* conn.drain(signal)
|
||||
} finally {
|
||||
clearInterval(timer)
|
||||
streamBreakers.delete(breakNow)
|
||||
hostConns.delete(conn)
|
||||
}
|
||||
},
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
|
||||
void message
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
|
||||
* overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch
|
||||
* straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four
|
||||
* named full forms, and feeding the same tap as a real carrier. Delete when the fixture moves
|
||||
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api = createFixtureApi()
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
|
||||
}
|
||||
|
||||
protected override async callUnary<K extends keyof RpcMethodMap>(
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const request = rpcRequest(payload)
|
||||
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
|
||||
this.onEnvelope(full)
|
||||
const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
|
||||
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
|
||||
this.onEnvelope(fullResponse)
|
||||
return response
|
||||
}
|
||||
|
||||
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
|
||||
private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
|
||||
switch (method) {
|
||||
case 'session.list': return this.api.sessions.list(request)
|
||||
case 'session.create': return this.api.sessions.create(request)
|
||||
case 'session.history': return this.api.sessions.history(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
}
|
||||
}
|
||||
|
||||
protected override openMux(
|
||||
payload: { since?: Record<SessionId, number> },
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
): AsyncIterable<RpcRequest<MuxFrame>> {
|
||||
return this.tapStream(this.api.events.mux(rpcRequest(payload), signal), onOpen)
|
||||
}
|
||||
|
||||
protected override openHost(
|
||||
payload: Record<never, never>,
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
): AsyncIterable<RpcRequest<HostFrame>> {
|
||||
return this.tapStream(this.api.events.host(rpcRequest(payload), signal), onOpen)
|
||||
}
|
||||
|
||||
private async *tapStream<F extends MuxFrame | HostFrame>(
|
||||
stream: AsyncIterable<RpcRequest<F>>,
|
||||
onOpen?: () => void,
|
||||
): AsyncGenerator<RpcRequest<F>> {
|
||||
// No HTTP here: the in-memory stream is established the moment iteration starts (mirrors
|
||||
// readSse firing onOpen after response headers, before any frame).
|
||||
onOpen?.()
|
||||
for await (const envelope of stream) {
|
||||
const full: ServerRequest = { type: 'server-request', rpcId: envelope.rpcId, method: envelope.payload.type, payload: envelope.payload }
|
||||
this.onEnvelope(full)
|
||||
yield envelope
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a client response to the in-memory contract impl (no HTTP POST),
|
||||
* echoing the envelope to the observation tap like every other path.
|
||||
* @param message - the client-response envelope answering a server request.
|
||||
* @returns the carrier receipt from the fixture impl.
|
||||
*/
|
||||
override async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
this.onEnvelope(message)
|
||||
return this.api.respond(message)
|
||||
}
|
||||
}
|
||||
76
packages/client/connection/src/client/index.ts
Normal file
76
packages/client/connection/src/client/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Browser half of the wire consumer layer (contract: api-contracts v3
|
||||
* section 3; export inventory = v3 §3.2). The wire is this package's client
|
||||
* half in its entirety — apply mounts ctx.connection: the shared api client
|
||||
* plus the connection controller handle. Mode selection (?fixture) happens
|
||||
* here so the rest of the client tree is mode-blind; the controller's sinks
|
||||
* are wired by the runtime plugin (object layer), which injects this service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'
|
||||
|
||||
// ---- Connection loop ----
|
||||
export { ConnectionController } from './connection.ts'
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
|
||||
// ---- Platform client subclasses ----
|
||||
export { WebApiClient } from './web-api-client.ts'
|
||||
export { FixtureApiClient, createFixtureApi } from './fixture.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* The ctx.connection service surface: the api client plus a one-shot
|
||||
* controller starter (the runtime plugin supplies sinks when its object layer
|
||||
* is ready — connection stays consumer-agnostic).
|
||||
*/
|
||||
export interface ConnectionHandle {
|
||||
/** Shared api client (fixture or real, decided at boot from the page URL). */
|
||||
readonly api: IApiClient
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
* throws.
|
||||
* @param sinks - frame/state callbacks.
|
||||
* @param config - reconnect/backoff tunables.
|
||||
* @returns stop handle for the loop.
|
||||
*/
|
||||
start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: pick the api by page mode and provide ctx.connection.
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
const controller = new ConnectionController(api, sinks, config ?? {})
|
||||
controller.start()
|
||||
return { stop: () => { controller.stop() } }
|
||||
},
|
||||
}
|
||||
ctx.provide('connection', handle)
|
||||
}
|
||||
12
packages/client/connection/src/client/web-api-client.ts
Normal file
12
packages/client/connection/src/client/web-api-client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// WebApiClient: the browser platform subclass — transport = global fetch over same-origin
|
||||
// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the
|
||||
// base batching aspect; subscribers attach via subscribeEnvelopes (see boot).
|
||||
|
||||
import { AbstractApiClient } from './api.ts'
|
||||
|
||||
/** Browser platform subclass: transport = global fetch over same-origin /api/*. */
|
||||
export class WebApiClient extends AbstractApiClient {
|
||||
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
}
|
||||
10
packages/client/connection/src/index.ts
Normal file
10
packages/client/connection/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Connection plugin, node half. The package IS a dshClient plugin: the wire
|
||||
* consumer layer lives in its client half in full (src/client/ — contract:
|
||||
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
|
||||
* subpath. The empty apply exists so the plugin appears in the host Loader
|
||||
* (lifecycle governance + dshClient discovery).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the connection plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
32
packages/client/connection/src/invariant.ts
Normal file
32
packages/client/connection/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-connection`.
|
||||
* @module @deepseek-ai/dsh-client-connection/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-connection-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the pure wire layer emits no cordis events and owns no
|
||||
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
|
||||
* directly by its behavior specs, and rpcId round-trip discipline is owned by
|
||||
* the apiproxy contract layer.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
21
packages/client/connection/tests/api-helpers.spec.ts
Normal file
21
packages/client/connection/tests/api-helpers.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Contract-layer helpers: transport-error folding and response unwrapping.
|
||||
* (The assistant block classifier half of the legacy spec lives in
|
||||
* runtime/tests — the classifier moved there.)
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcId, resultOf, transportError } from '../src/client/api.ts'
|
||||
|
||||
describe('transportError', () => {
|
||||
it('folds an Error to internal keeping the message, and stringifies non-Errors', () => {
|
||||
expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'internal', message: '线断了', details: {} } })
|
||||
expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resultOf', () => {
|
||||
it('unwraps the result slot', () => {
|
||||
expect(resultOf({ rpcId: RpcId('r'), result: { ok: true, value: 7 } })).toEqual({ ok: true, value: 7 })
|
||||
})
|
||||
})
|
||||
65
packages/client/connection/tests/client-apply.spec.ts
Normal file
65
packages/client/connection/tests/client-apply.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Connection plugin browser-half apply: ctx.connection handle mounting, mode
|
||||
* selection off the page URL, and the single-consumer stream-loop ownership.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { search: string } }
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
})
|
||||
|
||||
async function mount(): Promise<ConnectionHandle> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ apply, inject: [] })
|
||||
const handle = ctx.get('connection') as ConnectionHandle | undefined
|
||||
if (handle === undefined) throw new Error('ctx.connection not provided')
|
||||
return handle
|
||||
}
|
||||
|
||||
describe('connection client apply', () => {
|
||||
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
})
|
||||
|
||||
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
|
||||
delete (globalThis as Win).location
|
||||
expect((await mount()).api).toBeInstanceOf(WebApiClient)
|
||||
})
|
||||
|
||||
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
const handle = await mount()
|
||||
// config omitted: the `config ?? {}` default arm is part of the surface.
|
||||
const loop = handle.start({})
|
||||
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
})
|
||||
|
||||
it('WebApiClient carries requests over globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: string[] = []
|
||||
globalThis.fetch = (input: URL | RequestInfo) => {
|
||||
seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}
|
||||
try {
|
||||
// Schema rejection is fine — the transport hop is the assertion.
|
||||
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/'))).toBe(true)
|
||||
})
|
||||
})
|
||||
238
packages/client/connection/tests/connection.spec.ts
Normal file
238
packages/client/connection/tests/connection.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* ConnectionController: stream pumping into sinks, the strict readiness
|
||||
* handshake (describe + both streams' onOpen, timeout-guarded), generation
|
||||
* abort on loss, backoff reconnection, state transitions, and sink-exception
|
||||
* isolation. Real (short) timers — the timeout and backoff are configurable,
|
||||
* so tests run them at millisecond scale.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import type { ConnectionState } from '../src/client/connection.ts'
|
||||
import { ConnectionController } from '../src/client/connection.ts'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||
|
||||
const SID = 'fk-c1' as SessionId
|
||||
const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }
|
||||
|
||||
function subscribedFrame(lastSeq = 0) {
|
||||
return { type: 'session/subscribed', sessionId: SID, lastSeq } as const
|
||||
}
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
|
||||
onConnected: () => { connected++ },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
api.pushMux(subscribedFrame())
|
||||
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => {
|
||||
const api = new FakeApiClient()
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
api.failStreams(new Error('stream torn'))
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
|
||||
expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
// stop() aborts the live generation (streams tear down) and no reconnect follows.
|
||||
await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(api.openMuxCount).toBe(0)
|
||||
})
|
||||
|
||||
it('treats describe failure as generation failure and retries', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls++
|
||||
return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
|
||||
expect(connected).toBe(0) // never announced during the failed generation
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
|
||||
onConnected: () => { connected++ },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } })
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect
|
||||
expect(muxSeen).toEqual([]) // never forwarded to the business sink
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates sink exceptions from the pump', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const seen: string[] = []
|
||||
let connected = 0
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: (envelope) => {
|
||||
seen.push(envelope.payload.type)
|
||||
throw new Error('business layer bug')
|
||||
},
|
||||
onConnected: () => { connected++ },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
api.pushMux(subscribedFrame(1))
|
||||
api.pushMux(subscribedFrame(2))
|
||||
await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped
|
||||
expect(connected).toBe(1) // no reconnect triggered by the sink throw
|
||||
} finally {
|
||||
controller.stop()
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('holds onConnected until both streams establish even after describe succeeds', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
expect(connected).toBe(0) // describe alone must not announce
|
||||
api.releaseStreamOpens()
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 })
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits deduplicated connected/reconnecting state transitions', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['connected'])
|
||||
api.failStreams(new Error('torn'))
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) })
|
||||
expect(states).toEqual(['connected', 'reconnecting', 'connected'])
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls++
|
||||
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
|
||||
}
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs with no sinks at all (every callback slot optional)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const controller = new ConnectionController(api, {}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
|
||||
api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('start() is idempotent (one loop, one stream set)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(api.openMuxCount).toBe(1)
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
160
packages/client/connection/tests/fake-api.ts
Normal file
160
packages/client/connection/tests/fake-api.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
reject(error: unknown): void
|
||||
}
|
||||
|
||||
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let nextRpc = 0
|
||||
|
||||
export function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
|
||||
}
|
||||
|
||||
|
||||
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
|
||||
|
||||
interface StreamConn<F> {
|
||||
feed(item: StreamItem<F>): void
|
||||
}
|
||||
|
||||
export class FakeApiClient implements IApiClient {
|
||||
/** Chronological call record: [method, payload]. */
|
||||
readonly calls: { method: string; payload: unknown }[] = []
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
// Parameter annotations below are local structural types on purpose: the CI
|
||||
// lint lane runs without built artifacts, where IApiClient's wire types
|
||||
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
/** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
|
||||
* Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
|
||||
holdStreamOpen = false
|
||||
private heldOpens: (() => void)[] = []
|
||||
|
||||
releaseStreamOpens(): void {
|
||||
const held = this.heldOpens
|
||||
this.heldOpens = []
|
||||
for (const fire of held) fire()
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
|
||||
this.openStream(this.muxConns, signal, onOpen),
|
||||
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
|
||||
this.openStream(this.hostConns, signal, onOpen),
|
||||
}
|
||||
|
||||
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
}
|
||||
|
||||
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
|
||||
pushMux(frame: MuxFrame, rpcId?: string): void {
|
||||
for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
|
||||
}
|
||||
|
||||
pushHost(frame: HostFrame, rpcId?: string): void {
|
||||
for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
|
||||
}
|
||||
|
||||
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
|
||||
endStreams(): void {
|
||||
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
|
||||
}
|
||||
|
||||
failStreams(error: unknown): void {
|
||||
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
|
||||
}
|
||||
|
||||
get openMuxCount(): number {
|
||||
return this.muxConns.length
|
||||
}
|
||||
|
||||
callsOf(method: string): unknown[] {
|
||||
return this.calls.filter(c => c.method === method).map(c => c.payload)
|
||||
}
|
||||
|
||||
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
|
||||
this.calls.push({ method, payload })
|
||||
return response
|
||||
}
|
||||
|
||||
private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
|
||||
const inbox: StreamItem<F>[] = []
|
||||
let wake: (() => void) | null = null
|
||||
const conn: StreamConn<F> = {
|
||||
feed: (item) => {
|
||||
inbox.push(item)
|
||||
wake?.()
|
||||
},
|
||||
}
|
||||
registry.push(conn)
|
||||
if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
|
||||
else if (!this.suppressStreamOpen) onOpen?.()
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
while (inbox.length > 0) {
|
||||
const item = inbox.shift() as StreamItem<F>
|
||||
if (item.kind === 'end') return
|
||||
if (item.kind === 'fail') throw item.error
|
||||
yield item.envelope
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
wake = null
|
||||
}
|
||||
} finally {
|
||||
registry.splice(registry.indexOf(conn), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
337
packages/client/connection/tests/fixture.spec.ts
Normal file
337
packages/client/connection/tests/fixture.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Fixture impl semantics: the demo data source must honor the same contract
|
||||
* shapes as the real host (paging boundaries, rpcId echo, replay lifecycle,
|
||||
* baseline replay, timing hooks) — this is the vitest-side drift detector for
|
||||
* the hand-written fixture/host parallel implementations.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload })
|
||||
let reqCount = 0
|
||||
|
||||
interface TimingHooks {
|
||||
setHistoryDelay(ms: number): void
|
||||
failNextHistory(): void
|
||||
appendUser(id: string, msg: string): void
|
||||
appendSilent(id: string, msg: string): void
|
||||
breakStreams(): void
|
||||
}
|
||||
const timing = (): TimingHooks => (globalThis as Record<string, unknown>).__fxTiming as TimingHooks
|
||||
|
||||
/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */
|
||||
async function collect<F>(stream: AsyncIterable<RpcRequest<F>>, abort: AbortController, done: (frames: F[]) => boolean): Promise<F[]> {
|
||||
const frames: F[] = []
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (done(frames) || frames.length > 500) {
|
||||
abort.abort()
|
||||
break
|
||||
}
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('createFixtureApi', () => {
|
||||
it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => {
|
||||
const api = createFixtureApi()
|
||||
const request = req({})
|
||||
const response = await api.sessions.list(request)
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
expect(response.result.value.items.map(s => s.sessionId)).toEqual(['fx-alpha', 'fx-beta', 'fx-gamma'])
|
||||
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
|
||||
})
|
||||
|
||||
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
|
||||
const api = createFixtureApi()
|
||||
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
|
||||
if (!tail.result.ok) throw new Error('history failed')
|
||||
const tailPage = tail.result.value
|
||||
expect(tailPage.hasMore).toBe(true)
|
||||
expect(tailPage.events[0]?.event.type).toBe('turn/start') // cut lands on a turn boundary
|
||||
const boundary = tailPage.events[0]?.event.seq ?? 0
|
||||
expect(boundary).toBeGreaterThan(0)
|
||||
const older = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: boundary, maxMessages: 10 }))
|
||||
if (!older.result.ok) throw new Error('older failed')
|
||||
const olderTail = older.result.value.events.at(-1)?.event
|
||||
expect((olderTail?.seq ?? -1) + 1).toBe(boundary) // pages stitch with no hole/overlap
|
||||
// Out-of-range beforeSeq clamps instead of exploding.
|
||||
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
|
||||
if (!clamped.result.ok) throw new Error('clamped failed')
|
||||
expect(clamped.result.value.events).toEqual([])
|
||||
// Unknown session: empty page, not an error (history of a bare id).
|
||||
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
|
||||
if (!empty.result.ok) throw new Error('empty failed')
|
||||
expect(empty.result.value).toEqual({ events: [], hasMore: false })
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 1) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
await consuming
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const createdId = created.result.value.sessionId
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
|
||||
const list = await api.sessions.list(req({}))
|
||||
if (!list.result.ok) throw new Error('list failed')
|
||||
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
|
||||
})
|
||||
|
||||
it('prompt replays a full streamed turn and cancel mid-replay freezes with (已中断)', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
const abort = new AbortController()
|
||||
const frames: MuxFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
frames.push(envelope.payload)
|
||||
const last = envelope.payload
|
||||
if (last.type === 'session/event' && last.event.type === 'turn/end') {
|
||||
abort.abort()
|
||||
}
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
// Unknown session → session-not-found with the id echoed in details.
|
||||
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
|
||||
// Real prompt: replay starts (running flips true), cancel freezes it.
|
||||
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
|
||||
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
|
||||
await api.sessions.cancel(req({ sessionId: id }))
|
||||
await consuming
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types).toContain('turn/start')
|
||||
expect(types).toContain('user/message')
|
||||
expect(types).toContain('assistant/chunk')
|
||||
expect(types).toContain('assistant/message')
|
||||
expect(types.at(-1)).toBe('turn/end')
|
||||
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
|
||||
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
|
||||
// Idle cancel: no replay in flight, must not explode; running flips false.
|
||||
const idleCancel = await api.sessions.cancel(req({ sessionId: id }))
|
||||
expect(idleCancel.result).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
|
||||
frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] }))
|
||||
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
|
||||
const frames = await framesPromise
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types).toContain('steering/message')
|
||||
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
||||
})
|
||||
|
||||
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
|
||||
const api = createFixtureApi()
|
||||
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
||||
const abort = new AbortController()
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 2) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
const first = await openOnce()
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
|
||||
frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
// steer while idle + a non-text content block (covers the '' arm of the text join).
|
||||
await api.sessions.prompt(req({
|
||||
sessionId: created.result.value.sessionId, mode: 'steer' as const,
|
||||
content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never],
|
||||
}))
|
||||
const frames = await framesPromise
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
|
||||
})
|
||||
|
||||
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const hostSeen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload)
|
||||
})()
|
||||
await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists)
|
||||
expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true })
|
||||
// A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm).
|
||||
const mabort = new AbortController()
|
||||
const baseline: MuxFrame[] = []
|
||||
const muxConsuming = (async () => {
|
||||
for await (const envelope of api.events.mux(req({}), mabort.signal)) {
|
||||
baseline.push(envelope.payload)
|
||||
if (baseline.length >= 3) mabort.abort()
|
||||
}
|
||||
})()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
mabort.abort()
|
||||
await muxConsuming
|
||||
expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 })
|
||||
abort.abort()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await consuming
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('respond is a typed stub: always not-pending', async () => {
|
||||
const api = createFixtureApi()
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
})
|
||||
|
||||
it('describe answers the fixture identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
|
||||
})
|
||||
|
||||
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hooks = timing()
|
||||
// One-shot transport failure after transit delay.
|
||||
hooks.setHistoryDelay(5)
|
||||
hooks.failNextHistory()
|
||||
await expect(api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))).rejects.toThrow(/simulated history transport failure/)
|
||||
hooks.setHistoryDelay(0)
|
||||
// The failure was one-shot: the next call succeeds.
|
||||
const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
|
||||
expect(ok.result.ok).toBe(true)
|
||||
// appendUser emits on the mux stream; appendSilent only lands in the log (lost frame).
|
||||
const abort = new AbortController()
|
||||
const seen: MuxFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload)
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
hooks.appendSilent('fx-alpha', '静默丢帧')
|
||||
hooks.appendUser('fx-alpha', '正常直播')
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
|
||||
})
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
|
||||
// But history serves the silent event (the client's repull finds it).
|
||||
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
|
||||
if (!repull.result.ok) throw new Error('repull failed')
|
||||
expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧')
|
||||
// breakStreams force-ends BOTH stream kinds without the client abort.
|
||||
const habort = new AbortController()
|
||||
const hostConsuming = (async () => {
|
||||
for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ }
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
hooks.breakStreams()
|
||||
await consuming // returns because the stream broke, not because we aborted
|
||||
await hostConsuming
|
||||
expect(abort.signal.aborted).toBe(false)
|
||||
expect(habort.signal.aborted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
|
||||
const client = new FixtureApiClient()
|
||||
// Protected at compile time only; reach it directly to pin the tripwire message.
|
||||
expect(() => (client as unknown as { doFetch(): Promise<Response> }).doFetch()).toThrow(/doFetch must be unreachable/)
|
||||
})
|
||||
|
||||
it('mints request ids, taps all four full forms, and never touches doFetch', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const tapped: RpcMessage[] = []
|
||||
client.subscribeEnvelopes(batch => tapped.push(...batch))
|
||||
const response = await client.sessions.list({})
|
||||
expect(response.result.ok).toBe(true)
|
||||
await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } })
|
||||
await vi.waitFor(() => {
|
||||
const kinds = tapped.map(m => m.type)
|
||||
expect(kinds).toContain('client-request')
|
||||
expect(kinds).toContain('server-response')
|
||||
expect(kinds).toContain('client-response')
|
||||
})
|
||||
const request = tapped.find(m => m.type === 'client-request')
|
||||
const reply = tapped.find(m => m.type === 'server-response')
|
||||
expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier
|
||||
})
|
||||
|
||||
it('covers the whole unary dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const created = await client.sessions.create({})
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
||||
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const tapped: RpcMessage[] = []
|
||||
client.subscribeEnvelopes(batch => tapped.push(...batch))
|
||||
const order: string[] = []
|
||||
const abort = new AbortController()
|
||||
for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) {
|
||||
order.push(envelope.payload.type)
|
||||
abort.abort()
|
||||
}
|
||||
expect(order[0]).toBe('open')
|
||||
expect(order[1]).toBe('session/subscribed')
|
||||
await vi.waitFor(() => {
|
||||
expect(tapped.some(m => m.type === 'server-request')).toBe(true)
|
||||
})
|
||||
// Host stream side of the pair (same tap path).
|
||||
const habort = new AbortController()
|
||||
const hostOrder: string[] = []
|
||||
const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]()
|
||||
const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))])
|
||||
expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent
|
||||
habort.abort()
|
||||
if (raced === 'idle') await hostIterator.return?.(undefined)
|
||||
})
|
||||
})
|
||||
10
packages/client/connection/tests/node-half.spec.ts
Normal file
10
packages/client/connection/tests/node-half.spec.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
describe('node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply(undefined)
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
42
packages/client/connection/tsconfig.json
Normal file
42
packages/client/connection/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.legacy.*"
|
||||
]
|
||||
}
|
||||
3
packages/client/connection/tsdown.config.ts
Normal file
3
packages/client/connection/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
16
packages/client/i18n/README.md
Normal file
16
packages/client/i18n/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @deepseek-ai/dsh-client-i18n
|
||||
|
||||
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
|
||||
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.
|
||||
54
packages/client/i18n/package.json
Normal file
54
packages/client/i18n/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-i18n",
|
||||
"description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
108
packages/client/i18n/src/client/index.ts
Normal file
108
packages/client/i18n/src/client/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* i18n plugin, browser half: namespace x locale dictionary registry with a
|
||||
* bound translate function whose reference is stable (safe for inject
|
||||
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
|
||||
* Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
i18n: I18nService
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback locale consulted after the active locale misses. */
|
||||
export const FALLBACK_LOCALE = 'zh'
|
||||
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale switch. Lookup chain per key: active locale
|
||||
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
|
||||
* the UI rather than blank).
|
||||
*/
|
||||
export class I18nService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the locale store at call time).
|
||||
*/
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
t = (key, params) => this.translate(ns, key, params)
|
||||
this.bound.set(ns, t)
|
||||
return t
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** Active locale store (switching re-renders the tree; low frequency). */
|
||||
get locale(): SnapshotStore<string> {
|
||||
return this.localeStore
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services (none; the loader passes the export surface as an object plugin). */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the i18n service with base dictionaries.
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const i18n = new I18nService()
|
||||
i18n.register(COMMON_NS, 'zh', zh)
|
||||
i18n.register(COMMON_NS, 'en', en)
|
||||
ctx.provide('i18n', i18n)
|
||||
}
|
||||
11
packages/client/i18n/src/index.ts
Normal file
11
packages/client/i18n/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Everything else —
|
||||
* I18nService, Translate, LocaleDict — lives in the client half; consumers
|
||||
* import the /client subpath. Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the i18n plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/i18n/src/invariant.ts
Normal file
32
packages/client/i18n/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
|
||||
* @module @deepseek-ai/dsh-client-i18n/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-i18n-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: ns-by-locale dictionary registry with a stable
|
||||
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
|
||||
* mutable relation; fallback-chain resolution and locale-store behavior are
|
||||
* asserted directly by this package's behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
2
packages/client/i18n/src/locales/en.ts
Normal file
2
packages/client/i18n/src/locales/en.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const en: Record<string, string> = {}
|
||||
2
packages/client/i18n/src/locales/zh.ts
Normal file
2
packages/client/i18n/src/locales/zh.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const zh: Record<string, string> = {}
|
||||
53
packages/client/i18n/tests/i18n.spec.ts
Normal file
53
packages/client/i18n/tests/i18n.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
30
packages/client/i18n/tests/invariant.spec.ts
Normal file
30
packages/client/i18n/tests/invariant.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
27
packages/client/i18n/tsconfig.json
Normal file
27
packages/client/i18n/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/i18n/tsdown.config.ts
Normal file
3
packages/client/i18n/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
18
packages/client/runtime/README.md
Normal file
18
packages/client/runtime/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.
|
||||
63
packages/client/runtime/package.json
Normal file
63
packages/client/runtime/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./loader": {
|
||||
"types": "./lib/types/client/loader/index.d.ts",
|
||||
"default": "./lib/loader.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/loader.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
128
packages/client/runtime/src/client/index.ts
Normal file
128
packages/client/runtime/src/client/index.ts
Normal 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')
|
||||
}
|
||||
247
packages/client/runtime/src/client/loader/index.ts
Normal file
247
packages/client/runtime/src/client/loader/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
165
packages/client/runtime/src/client/sessions/conversation.ts
Normal file
165
packages/client/runtime/src/client/sessions/conversation.ts
Normal 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
|
||||
}
|
||||
194
packages/client/runtime/src/client/sessions/fold-adapter.ts
Normal file
194
packages/client/runtime/src/client/sessions/fold-adapter.ts
Normal 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).
|
||||
}
|
||||
}
|
||||
63
packages/client/runtime/src/client/sessions/lineage.ts
Normal file
63
packages/client/runtime/src/client/sessions/lineage.ts
Normal 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
|
||||
}
|
||||
250
packages/client/runtime/src/client/sessions/manager.ts
Normal file
250
packages/client/runtime/src/client/sessions/manager.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
61
packages/client/runtime/src/client/sessions/notifier.ts
Normal file
61
packages/client/runtime/src/client/sessions/notifier.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
89
packages/client/runtime/src/client/sessions/partial.ts
Normal file
89
packages/client/runtime/src/client/sessions/partial.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
227
packages/client/runtime/src/client/sessions/service.ts
Normal file
227
packages/client/runtime/src/client/sessions/service.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
527
packages/client/runtime/src/client/sessions/session.ts
Normal file
527
packages/client/runtime/src/client/sessions/session.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
107
packages/client/runtime/src/client/slots.ts
Normal file
107
packages/client/runtime/src/client/slots.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
|
||||
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
|
||||
* run through the caller's ctx.effect so a plugin's registrations are
|
||||
* collected when its fiber unloads (cordis-native cascade).
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
|
||||
* in this compilation unit (intersection reads `never`) but consumers merge
|
||||
* keys in; the rule fires on the empty-map view, not on real redundancy. */
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from './index.ts'
|
||||
|
||||
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
|
||||
export class SlotsService extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'slots')
|
||||
this._core.onMutate((key) => { ctx.emit('slots/changed', key) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param spec - kind/scope spec.
|
||||
* @returns disposer.
|
||||
*/
|
||||
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
|
||||
* @param key - SlotMap key.
|
||||
* @param component - contributed component.
|
||||
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
|
||||
* inject factory's binding is pinned to ClientContext.
|
||||
* @returns disposer.
|
||||
*/
|
||||
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
|
||||
// Client-context registrations have exactly one ctx shape: pin Ctx to
|
||||
// ClientContext so inject factories dot services without a cast.
|
||||
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
|
||||
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot entries for a key.
|
||||
* @param key - SlotMap key.
|
||||
* @returns registered entries (stable reference between mutations).
|
||||
*/
|
||||
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
|
||||
return this._core.entries(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a defined spec.
|
||||
* @param key - SlotMap key.
|
||||
* @returns spec or undefined.
|
||||
*/
|
||||
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
|
||||
return this._core.spec(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
|
||||
* @param key - candidate slot key.
|
||||
* @returns wide-typed spec or undefined.
|
||||
*/
|
||||
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
|
||||
return this._core.specDynamic(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
* @param key - SlotMap key.
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
|
||||
return this._core.subscribe(key, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Version counter for uSES pairing.
|
||||
* @param key - SlotMap key.
|
||||
* @returns current version.
|
||||
*/
|
||||
getVersion(key: keyof SlotMap & string): number {
|
||||
return this._core.getVersion(key)
|
||||
}
|
||||
|
||||
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
|
||||
get core(): SlotCore {
|
||||
return this._core
|
||||
}
|
||||
}
|
||||
11
packages/client/runtime/src/index.ts
Normal file
11
packages/client/runtime/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Runtime plugin, node half. The implementation lives entirely in the client
|
||||
* half (src/client/ — SlotsService, SessionsService + object layer, and the
|
||||
* shell-held ClientLoader under ./loader); consumers import the /client or
|
||||
* /loader subpaths. The empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery). Contract:
|
||||
* api-contracts v3 section 4.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the runtime plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
52
packages/client/runtime/src/invariant.ts
Normal file
52
packages/client/runtime/src/invariant.ts
Normal 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 */
|
||||
64
packages/client/runtime/tests/client-apply.spec.ts
Normal file
64
packages/client/runtime/tests/client-apply.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
77
packages/client/runtime/tests/client-loader-bundle.e2e.ts
Normal file
77
packages/client/runtime/tests/client-loader-bundle.e2e.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
289
packages/client/runtime/tests/client-loader.spec.ts
Normal file
289
packages/client/runtime/tests/client-loader.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
23
packages/client/runtime/tests/conversation.spec.ts
Normal file
23
packages/client/runtime/tests/conversation.spec.ts
Normal 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: '正文' })
|
||||
})
|
||||
})
|
||||
50
packages/client/runtime/tests/event-script.ts
Normal file
50
packages/client/runtime/tests/event-script.ts
Normal 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 }))
|
||||
}
|
||||
161
packages/client/runtime/tests/fake-api.ts
Normal file
161
packages/client/runtime/tests/fake-api.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
packages/client/runtime/tests/fold-adapter.spec.ts
Normal file
145
packages/client/runtime/tests/fold-adapter.spec.ts
Normal 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: '孤儿' } })
|
||||
})
|
||||
})
|
||||
47
packages/client/runtime/tests/invariant.spec.ts
Normal file
47
packages/client/runtime/tests/invariant.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
55
packages/client/runtime/tests/lineage.spec.ts
Normal file
55
packages/client/runtime/tests/lineage.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
223
packages/client/runtime/tests/manager.spec.ts
Normal file
223
packages/client/runtime/tests/manager.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
10
packages/client/runtime/tests/node-half.spec.ts
Normal file
10
packages/client/runtime/tests/node-half.spec.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
describe('node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply(undefined)
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
75
packages/client/runtime/tests/notifier.spec.ts
Normal file
75
packages/client/runtime/tests/notifier.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
91
packages/client/runtime/tests/partial.spec.ts
Normal file
91
packages/client/runtime/tests/partial.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
625
packages/client/runtime/tests/session.spec.ts
Normal file
625
packages/client/runtime/tests/session.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
193
packages/client/runtime/tests/sessions-service.spec.ts
Normal file
193
packages/client/runtime/tests/sessions-service.spec.ts
Normal 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()
|
||||
})
|
||||
|
||||
})
|
||||
80
packages/client/runtime/tests/slots-service.spec.ts
Normal file
80
packages/client/runtime/tests/slots-service.spec.ts
Normal 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()
|
||||
})
|
||||
|
||||
})
|
||||
39
packages/client/runtime/tsconfig.json
Normal file
39
packages/client/runtime/tsconfig.json
Normal 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.*"
|
||||
]
|
||||
}
|
||||
23
packages/client/runtime/tsdown.config.ts
Normal file
23
packages/client/runtime/tsdown.config.ts
Normal 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]
|
||||
152
packages/client/tsdown.client.ts
Normal file
152
packages/client/tsdown.client.ts
Normal 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;',
|
||||
},
|
||||
}]
|
||||
}
|
||||
22
packages/client/ui-conversation/README.md
Normal file
22
packages/client/ui-conversation/README.md
Normal 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.
|
||||
63
packages/client/ui-conversation/package.json
Normal file
63
packages/client/ui-conversation/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
185
packages/client/ui-conversation/src/client/apply.ts
Normal file
185
packages/client/ui-conversation/src/client/apply.ts
Normal 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 })
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
293
packages/client/ui-conversation/src/client/chat/ChatView.tsx
Normal file
293
packages/client/ui-conversation/src/client/chat/ChatView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
75
packages/client/ui-conversation/src/client/chat/ToolRow.tsx
Normal file
75
packages/client/ui-conversation/src/client/chat/ToolRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
46
packages/client/ui-conversation/src/client/chat/chat-flow.ts
Normal file
46
packages/client/ui-conversation/src/client/chat/chat-flow.ts
Normal 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('|')
|
||||
}
|
||||
52
packages/client/ui-conversation/src/client/chat/register.ts
Normal file
52
packages/client/ui-conversation/src/client/chat/register.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
63
packages/client/ui-conversation/src/client/contract/slots.ts
Normal file
63
packages/client/ui-conversation/src/client/contract/slots.ts
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
68
packages/client/ui-conversation/src/client/contract/views.ts
Normal file
68
packages/client/ui-conversation/src/client/contract/views.ts
Normal 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>
|
||||
}
|
||||
42
packages/client/ui-conversation/src/client/index.ts
Normal file
42
packages/client/ui-conversation/src/client/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
251
packages/client/ui-conversation/src/client/service.ts
Normal file
251
packages/client/ui-conversation/src/client/service.ts
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
142
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
Normal file
142
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
103
packages/client/ui-conversation/src/client/toolviews/registry.ts
Normal file
103
packages/client/ui-conversation/src/client/toolviews/registry.ts
Normal 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
Reference in New Issue
Block a user