feat(gui): step1 skeleton — dsc web serves built web UI over booted harness host

Five new modules: apps/dsc (bin: parseArgs + node:http static server +
signal shutdown), packages/host/apiproxy (programmatic harness core
composition, agents:[]), packages/client/web-runtime (React-free browser
runtime), packages/client/web-ui (React mount), apps/web (vite build
entry producing dist consumed by apps/dsc via package exports).

Root wiring: apps/* workspace glob, dsh-* paths for host/client groups,
demo:web script, apps/web/dist gitignore. No protocol/API routes yet —
contract lands in step2 (see missions/tasks/20260719-1902-apiproxy-api-design).

Includes the design + implementation archives (spec v2.1, deepseekchat
baseline and harness boot research, implementation run log).

Acceptance: 12/12 passed incl. real-key llm.stream smoke (51 chunks).

feat(gui): apiproxy — four-quadrant RPC contract + fetch carriers, live end to end

Contract layer (src/api/, 14 files): four named wire message types
(ClientRequest / ServerResponse / ServerRequest / ClientResponse) as a
discriminated union over strict bidirectional rpcId (initiator mints,
responder echoes; channel and message fully decoupled — HTTP is the
client->server pipe, SSE the reverse); narrow RpcRequest<P>/
RpcResponse<T> signature forms; RpcMethodMap with RequestPayload<K>/
ResponseValue<K> derivation; typed RpcError details map; approval/
question responses modeled as ClientResponse via a single /api/respond
endpoint (RpcReceipt carrier ack); zod schemas anchored per Wire<T>
against exactOptionalPropertyTypes.

impl/api-proxy.ts: describe/list/create, both SSE streams (frame queue
pump, subscribed baseline, lifecycle frames, signal cleanup); history
pages on message boundaries (tail-back scan, partial included in the
tail page); prompt dispatches queue->agent.send / steer->agent.steer
with rpcId carried through MessageSource; cancel for attached sessions;
cold-session resume deduped via a per-id promise map; host-level
provider/model defaults injected at create/resume.

fetch/: mechanical UNARY_ROUTES table, two-level parse with
path==method check, SSE frames completed to ServerRequest full form;
client mints -> narrows -> envelopes outbound, verifies rpcId echo
inbound, streams SSE frames, four-quadrant onEnvelope tap (debug panel
choke point). Real-browser fixes: URL base resolves to location.origin
(hardcoded internal base broke real pages), browser-safe export paths.

Design archives: contract design.md v2.0 with decision log,
core-coverage audit, comparative studies, step2 impl run log. Probed
end to end over real HTTP: prompt -> live model stream -> history
returns the finished reply.

feat(gui): RpcLog debug panel — fixture-driven milestone, playwright-verified 10/10

web-runtime: rpcLog + ui slices (zustand), four-quadrant RpcLogEntry
(client-request / server-response / server-request / client-response),
onEnvelope tap -> microtask-batched pump with 500-entry ring buffer,
ConnectionController (private state, backoff reconnect), fixture API
with fake envelopes (?fixture switch), bootWebRuntime; contract types
via temporary local copies (api-types.ts, swapped for real imports when
W3 client lands).

web-ui: components/panels/RpcLog five-piece set (badge with unread
count, floating panel, direction glyphs per quadrant, same-rpcId
pair highlighting in two families, JSON payload expand, follow/pause,
clear), App shell, utils/formatRelative, light-theme CSS variables with
dark placeholders.

dsc bin: mime lookup fixed to use the actually-served file (naked
'/?query' no longer falls through to octet-stream download); shutdown
closes SSE keep-alive connections so SIGTERM actually exits.

Acceptance: scripts/verify-rpclog-panel.mjs (chromium headless) ALL
PASS 10/10 over design.md §D 1-6.

pkg: add web scripts for building

feat(gui): session milestone — list + conversation over Session OOP, styled RpcLog v2.1

web-runtime: Session/SessionManager object layer (resident instances,
mux frame routing, lineage flattening), foldSurface adapter with padding
sentinels for paged windows, chunk accumulator for streaming partials,
batched change notification (useSyncExternalStore contract), connection
sinks + reconnect fix (the 300ms self-abort reconnect storm that made
the session list flap is gone), fixture rewritten as a scripted host
(60-turn history, typewriter replay, resident pending approval, child
session); temporary contract copies deleted in favor of real apiproxy
imports.

web-ui: sessions screen (list with lineage indent + selection as
container-local state), conversation view (turn grouping, reasoning
fold, tool cards, steering, pending interaction cards, upward paging
with scroll anchoring), input bar with queue/steer/stop; RpcLog panel
restyled per docs/web-styling.md (tokenized palette, quadrant badge
glyphs now vertical ↑↓⇟⇞, pair highlighting, floating shadow).

docs/web-styling.md: living style guide (tokens, visual baseline,
coding rules, evolution log).

Acceptance: verify-session.mjs 31/31, verify-session-real.mjs 5/5
(real model streaming), verify-rpclog-panel.mjs 10/10.

feat(gui): hostruntime split + repo-wide package prefix rename

Package split (design: 20260720-0101-hostruntime-split-design):
dsh-host-runtime carries bootHost + createApiProxy + startHost()
(RunningHost {api, handler, defaults, ctx, dispose} — the seam Electron
and any future shell reuses; ctx is the official front-door mount
point); dsh-host-webserver carries the node:http static+API bridge
(fixed: abort now keys on res 'close' + writableEnded — req 'close'
fires on body end since Node 16 and was killing every SSE stream
instantly, the reconnect-storm root cause); apps/dsc is now a thin
assembly with web/-p subcommands. dsc -p runs the full isomorphic
carrier chain in process (second real protocol consumer; probed
end-to-end against the live model).

Naming rule (user decree): packages under host/ and client/ carry the
directory prefix in their npm name — dsh-host-apiproxy,
dsh-client-web-runtime, dsh-client-web-ui renamed repo-wide in one
frozen batch; explicit tsconfig paths entries added where the wildcard
no longer matches.

Acceptance: verify-session 31/31, verify-rpclog-panel 10/10,
verify-session-real 7/7 (incl. new 12s connection-stability sentinels),
tsc green, dsc web + dsc -p smoke both pass.

refactor(gui): AbstractApiClient class hierarchy — OO client with inheritable seams

AbstractApiClient (apiproxy) carries every protocol invariant: rpcId
minting, four-quadrant envelope wrap/unwrap, zod parsing, SSE frame
parsing, the payload-direct IApiClient surface (callers no longer mint
rpcIds — the carrier does), and the instance-level envelope observation
pump (batched via microtask; moved off module-level globals in
rpc-log.ts, which is now a pure subscriber mapping envelopes into store
entries — the debug panel observes the connection, it is not part of
it).

Platform subclasses own two abstract seams (doFetch, onEnvelope) plus
three protocol-level virtuals for transportless overrides:
InProcessApiClient (apiproxy; dsc -p uses new InProcessApiClient(
host.handler)), WebApiClient (web-runtime), FixtureApiClient (fixture
now subclasses instead of wrapping). Naming per decree: AbstractApiClient
/ IApiClient; ApiProxy stays the impl-side narrow-form contract.

headless.ts call sites drop rpcRequest wrappers (payload-direct);
split-design archive updated with the naming-rule ledger.

tsc green; verify-session 31/31, verify-rpclog-panel 10/10,
verify-session-real 7/7 (12s connection sentinel count=4); dsc -p smoke
CALLER-OK.

feat(gui): InputBar final form — bug batch, deepseekchat layout, single primary button, running locks input

Squashes the whole InputBar iteration batch: IME/caret/auto-grow/focus/dedup
bug fixes, layout aligned to the deepseekchat baseline, single primary button
with hover flyout, finalized button semantics with the Codex-style icon
circle, and running-state locking where stop is the only mid-turn action.
The same batch carried the Chinese-to-English code comment sweep
(density pruned), folded in here.

docs(gui): purge work-log references from code comments

76 design-doc references cleared across the GUI packages: section
pointers inlined as self-contained constraint statements, pure pointer
comments dropped, milestone codenames and ruling tags out, and the 14
contract file headers switched to the formal RFC (the only sanctioned
external reference). web-styling.md now cites the styling RFC instead
of the disposable research archive. grep for work-log reference
variants is clean across the GUI packages.

docs(gui): file-header comments self-contained — drop RFC filename references

RFC renames/reorgs must not require a source sweep (the 2026-07-20
two-way merge proved it). 11 headers lose only the '(RFC …)' tail and
stay self-contained; api-proxy.ts keeps its minimal-first note.

fix(gui): session streaming — freeze interrupted partials, sweep stale running calls, send force-scrolls

Aborted turns never emit the finalizing assistant/message, so the
accumulated partial and its running tool cards kept rendering below
later messages — the "new message lands above the stopped reply"
illusion. turn/end side effects now freeze content-bearing partials
into interrupted terminal nodes (fractional seq keeps flow order; the
live freeze and history replay converge through applyEventSideEffects,
so a refresh reconstructs identical frozen nodes) and turn running tool
cards into interrupted terminal cards; only content-free partials are
swept outright. ConversationView gains the send-force-scroll rule (own
words must be visible) alongside the pre-update atBottom follow flag.
Regressions pinned as E2-4a–c (real host) and §E1-11h (fixture).

feat(gui): webserver hardening verify script

feat(gui): dark-mode toggle pinned to the sidebar bottom

Interim home before the Settings page exists (the button re-homes with
zero logic change — mechanics live in utils/theme.ts): html[data-theme]
flip + dsc.theme localStorage, stored choice wins over the OS
prefers-color-scheme default, applied in mount() before first paint so
a dark reload never flashes light. Moon/sun inline SVG icon button at
the sidebar's pinned bottom row. Pure front-end local concern: no RPC,
no Session/store involvement. Dark sweep of list/conversation/input
card/RPC panel found no unreadable pairs — no token changes needed.

docs(gui): GUI RFCs and web styling handbook

Layering+RPC protocol and web client architecture RFCs (post-reorg,
developer-facing polish folded in) plus the styling engineering
handbook. Mission work logs live in the commit above; PRs can be cut
from this commit to include formal docs only.

fix(gui): client object-layer hardening — audit timing/reference/resilience batches (S3-S5,C1-C3,C5-C8)

fix(gui): carrier error channel + webserver backpressure (audit A1-A5,A7-A10,R2,R5)

feat(gui): session persistence surface — cold list, project cwd, legacy no-cwd retirement

refactor: rename dsc CLI to dsh — apps/cli, bin name, package scope

Includes the root tsconfig project-references fix for host/* and
client/web-runtime (originally a separate build fix commit).

test(gui): three-tier suite — protocol/object/browser lanes, tier-a fill to per-file 100%

test(gui): jsdom lane for web-ui + web-runtime coverage gate entry

docs(gui): GUI testing system RFC (zh)

feat(gui): tool-card views — contract slot, host-computed delivery, three-level card fallback

fix(gui): lint clean across GUI packages — wrap long doc comments, drop dead type args, sync-return methods without awaits

docs(gui): doc-sync mechanical fixes — JSDoc on apiproxy/host exports, RFC sketch fences ignore-check, md-wrap paragraphs, drop missions links, web-ui plain-ts entry

chore(gui): module-graph regen + knip clean — drop dead re-exports, internalize createFixtureApi, scan web-ui tsx and verify mjs scripts

build(gui): wire client/host packages into the lib build shape — tsc references + tsdown (web-ui css-external), lib manifests, cordis peer, apiproxy typed subpaths, vite src aliases

test(gui): host-side per-file 100% coverage — apiproxy schema/carrier suites, webserver http-bridge suite, host-runtime composition suite; client/* coverage excluded pending the browser-side testing work item

docs(gui): package READMEs for the five GUI packages — model-experience audit entries, limitations sections

docs(gui): bilingual RFC pairs + client JSDoc completion — translate the three GUI RFCs to English with i18n records and manifest ratchet, Consequences sections both sides, full client/* export JSDoc, regen doc graphs and RFC index

fix(scripts): doc-typecheck built-declarations mode maps /src/* subpath wildcards (apiproxy browser-safe channels)

docs(gui): apply dsh rename across pr-gates docs — READMEs, layering RFC en, web-ui entry comment, i18n re-record

fix(gui): post-rebase lint reconciliation — wrap main-tree long doc comments, read-through narrowing guards, abortError Error normalization, handleUnary generic justification

fix(gui): post-rebase doc/test reconciliation — align host specs with evolved carrier contracts (sentinel rpcId, stream/error surfacing, url-path transport messages, defaults.cwd), Agent Note titles and relocated links, KV Cache effect sections, JSDoc on evolved exports

fix(gui): second-rebase reconciliation to 509db0cb3 — restore api panel exports the baseline suites consume, knip workspace entries for jsdom lane and apps/web smokes, hoist result narrowing, align testing.md to the narrowed web-ui exclusion

fix(test): vitest-scoped tsconfig maps bare imports for tsx specs — with GUI manifests now pointing at lib, an unmapped importer loaded a second copy of the web-runtime singletons

fix(gui): typecheck + lint clean over the tool-card batch — brand callIds and object-form turn/end reason in the view spec, narrow fixture arg stringification, wrap long v8-ignore comments

docs(gui): export JSDoc for tool-card surfaces + testing-note pairing header

docs: rfc for web testing

feat: add tools to host-runtime

fix(gui): dispatch agent/error via agentEvents in host-runtime spec — mounted invariants plugin rejects raw ctx.emit without the scope carrier

fix(gui): restore GUI knip workspaces + scripts/mjs entries and regenerate lockfile after master rebase

fix(gui): post-rebase gate repairs — drop context-node envelope (master unwrapped injected content envelopes), regen event matrix, condense testing.md web-ui exclusion within budget

fix(session): browser-safe deep-equal in surface — node:util import broke the vite bundle

ci(gates): frontend vite build joins pre-push — node: imports in the client closure pass tsc but break the browser bundle

test(tui): drop the checkout-dependent process.cwd() harness default — a long worktree path pushes the footer token counters past the 88-column fake terminal

test(gui): jsdom behavior E2E — conversation main path over fixture runtime, reconnect banner lifecycle

test(gui): jsdom RPC panel behavior — ledger rows, expand, pairing, pause/clear, follow-pause, payload truncation

test(gui): jsdom tier-2 — InputBar guards, reasoning fold, JSON blocks, message variants, theme, create-then-select; act-harden banner case

test(gui): jsdom tier-3 — ConversationView states/paging/force-bottom, ToolCallCard arms, PendingCard, list rows

test(gui): jsdom tails — view-card variants, LogRow directions, registry hygiene, badge overflow, hook ops, mount glue

test(gui): jsdom tails round 2 — call-ref blocks, resume follow, view precedence, failed create, empty-diff arm

test(gui): jsdom final arms — anchor compensation, follow-off, interval ticks, view halves, node-over-running precedence

test(gui): web-ui joins the per-file 100% coverage gate

Annotation-only src changes plus the config swap. The web-ui exclusion is
replaced by a single index.tsx entry (stale byte-identical duplicate of
mount.tsx, nothing imports it; same entry-glue treatment as bin.ts) and the
coverage include gains .tsx.

v8-ignore sites (each with its reason inline):
- ConversationView 3x ref-null guards; InputBar disabled-click guard
- ToolCallCard both-null arms + windowless-custom argsRaw arm
- LogRow css-module key fallbacks (start/stop block); RpcLogBody 3x ref-null guards
- web-runtime drift from the tool-card batch: fixture presenter catch/str
  typo-guards, dense-array guards (fold-adapter reset, session rebuild,
  fixture backscan), live view-present arm (fixture replays are text-only;
  view vocabulary is covered by the history samples)

test(gui): close the PR #443 host-side coverage gaps — apiproxy client abort arms, api-proxy cold/view paths, webserver drain

- apiproxy fetch/client.ts: 3 new cases (pre-aborted signal short-circuits
  before transport + string reason mapping, non-Error/string reason falls to
  the default AbortError message, signal-less doFetch passthrough)
- runtime/api-proxy.ts: one v8-ignore (summarizeCold cwd arm — list()
  filters cwd-less legacy metas) + api-proxy-cold.spec.ts (cold list merge:
  mtime source, locate-undefined and vanished-log fallbacks, lineage;
  no-persistence/no-factory resume → internal) + 2 view cases (history views
  with meta passthrough and orphan/bad-args/presenterless soft-falls,
  session/disposed open-call cleanup on the mux stream)
- webserver/index.ts: /api/big fixture drives both drain-wait legs (full
  8MiB readback after drain, mid-chunk disconnect wakes via 'close')

feat: app shell

fix: rebase conflicts

fix: coverage

fix(gui): lint clean after rebase — wrap long v8-ignore comments, unconditional v1 detail-block claim

chore(gui): remove browser/probe verify scripts from scripts/

The six GUI acceptance/probe scripts (carrier-errors, rpclog-panel,
session, session-real, webserver-backpressure, webserver-hardening)
leave the repo's scripts/ tree; the three code comments that pointed at
them now describe the coverage lane without naming a script path.

fix(webserver): guard the request callback — one malformed request must not kill the process

The async handle() had no top-level catch, so any throw inside it (a bad
%-escape reaching decodeURIComponent, a client dropping mid-body, a
response stream erroring) became an unhandled rejection and took the whole
process down (audit R1 must-fix). The guard answers 400 when headers are
not out yet, destroys the socket when they are, and reports the failure to
onError (the package never prints). Spec covers all three legs: %-escape
barrage → 400 + server stays alive, non-Error throw wrapped for onError,
mid-stream explosion → socket teardown.

feat: client AGENTS.md

fix: client/AGENTS.md

fix: rebase

feat(gui): T0 cut 1 — 12 client package skeletons with contract stubs, dshClient declarations, tsdown client preset, theme token sheets

feat(gui): T0 cut 2 — pure git mv migration per v3 §11 (connection six, runtime sessions/kernel, ui-conversation chat, ui-primitives markdown family, web shell + e2e)

feat(gui): T0 cuts 3+4 — import rewiring to new package names, .legacy demotion of owner-rewrite files, legacy web-runtime/web-ui/apps-web retired to attic

feat(gui): connection 对账刀——index.ts 精确导出清单替换 export *,intents.legacy 溶解删除

feat(client/ui-slots): SlotCore real implementation — kind semantics, sync version + microtask-batched notify, onMutate bridge

feat(gui): web shell vite alias — retarget to new client packages, shell static surface only

feat(gui): host 侧刀属地半——HostWebPluginRegistry(entries 扫描+internal/plugin 去抖重扫+dshClient 校验+exports./client 解析)、GET /plugins/<id>/client.js 分发端点、GET / 与 SPA fallback 注入 __DSH_BOOT__(webPlugins 可选注入,不传行为不变)

feat(web-react): add use-sync-external-store dep + local shim typings

feat(web-react): bindSnapshotSelector via uSES with-selector shim

feat(gui): ui-layout concession-chain solver — pure computeColumns with contract geometry

feat(gui): ui-layout LayoutService — four persisted stores, clamped actions, list-driven prune

feat(gui): ui-layout AppFrame styles — grid columns, collapse-safe borders, edge drag handles

test(gui): 存量 spec 平移——connection 三件+runtime 六件自 attic 捞回改包名路径全绿;api-helpers 按归属拆分(wire 半留 connection、classifier 半随 conversation.ts 入 runtime);boot-intents/preinit/rpc-log 随 intents/rpc-log 退役不迁(记 v3 §3.2 溶解项)

feat(client/ui-primitives): StateDot/Button/Pill/Input/Menu atoms, ConnectionBanner de-legacied to pure props, JsonBlock CSS on --dsw tokens

feat(web-react): createSnapshotStore engine (rafFlush batch, persist opt-in, dev freeze) + spec

feat(gui): ui-layout AppFrame — grid tracks, pointer-capture drag handles with rAF throttle, frame ResizeObserver

feat(web-react): useInvoke (external pending store, stable invoke, concurrency count) + spec

test(web-react): bind spec — equality bail, custom eq, zero resubscribe, StrictMode, method sources

feat(gui): ui-layout index rewiring — real exports, client apply provides ctx.layout and defines three slots

feat(web-react): SessionProvider (renderBody deps) + RootBindingProvider + binding contexts + spec

feat(gui): web shell AppRoot boot-page styles — self-contained with neutral token fallbacks

feat(gui): web shell AppRoot — boot gate over loader status, fail-loud plugin failure list

fix(gui): AppRoot gates on explicit settled signal — status-derived readiness races the incrementally filled table

feat(client/ui-theme): ThemeService real implementation — registry with built-in light/dark, apply toggles body[data-ds-dark-theme], third-party token overrides as body inline vars

feat(web-react): scopedSlots outlet (kind matrix, inject WeakMap caches, per-entry error boundary) + spec

feat(gui): web shell module-table seed — pure-library entities for the loader require surface

feat(client/i18n): I18nService real implementation — ns×locale registry, stable bind(ns) reference, zh fallback chain, zh/en skeleton dictionaries

feat(gui): web shell assembly closure — layout exports via module table, SessionProvider + scopedSlots + RootBindingProvider

feat: client/ui-conversation

feat: code

codedoc

build(gui): root bundle green — web shell excluded from the lib workspace (vite app), ui-primitives lib externalizes css side-effect imports (web-ui precedent)

gates(gui): verify-cordis-config follows aggregate tsconfig references (root is a shell over host/client programs); module graph regenerated for the twelve client packages

chore(gui): retire legacy migration sources — every owner rewrite landed (t0-checklist §7 ledger honored); orphan css of retired components removed

gates(gui): knip green groundwork — e2e/tsx entries for the new packages, loader-runtime deps ignored where loading is by specifier string, fake plugin ids un-bare-named, dead test export dropped

chore(client): manifest shape batch A — ui-slots/web-react/ui-primitives invariant companions, files whitelist, cordis+invariants peer/dev, tsconfig refs

chore(client): manifest shape batch B — connection/runtime/ui-conversation/ui-trajectory files whitelist, cordis peer+dev, explicit invariant lib entries (clientBundle signature)

chore(client): manifest shape batch C — i18n/ui-layout/ui-sidebar/ui-theme invariant companions, files whitelist, invariants peer/dev, tsconfig refs

chore(client): manifest shape batch D — web shell gains node-half lib entry + invariant companion + uniform files whitelist

chore(client): drop verified-unused deps — dsh-tools from runtime/ui-conversation (types ride /presentation), ui-primitives+clsx from ui-layout

gates(gui): doc-gate fixes — theme JSDoc prose, three client type-link exemptions, agent-note paths follow the migration, config catalog regenerated

gates(gui): type-equiv manifest follows the types.ts extraction, approval JSDoc keeps its link form, persistence catalog regenerated

docs(gui): per-constant JSDoc on the contract geometry exports (export-jsdoc gate)

test(gates): loader-composition budget covers cold tsx resolution after the program split (was flaking at the default 5s)

docs(gui): README substantiation batch 1 — ui-slots/ui-primitives/web-react/connection: Model Experience short form, real deferred-work ledgers, description accuracy pass

fix(client): theme/i18n dual-entry split — service classes + cordis merges move to src/client (host catalog scanner no longer misclassifies client services), node halves keep types + empty apply; catalogs regenerated

docs(gui): README substantiation batch 2 — runtime/ui-layout/ui-sidebar/ui-conversation: Model Experience short form, package-owned deferred-work ledgers (unload stub, watch approximation, /client value-import rule, global details state, two-state dots, stats duration gap, single-bundle caches)

docs(gui): README substantiation batch 3 — ui-trajectory/ui-theme/i18n/web: Model Experience short form, deferred-work ledgers (placeholder charter, no theme toggle owner, empty locale dictionaries, one-shot rendering); both README gates green

test(scripts): purity spec adopts clientBundle two-arg signature (explicit libEntry, no default)

gates(gui): knip green — declaration-merge dep ignored, fake plugin id assembled at runtime, invariants dep de-duplicated to peer+dev, stale apps/web section dropped

feat(gui): 门禁波次 host 三包 invariant 形状——apiproxy explained-empty 伴生(wire 契约层零事件面)、webserver 真关系伴生(manifest 行必解析出 clientPath,防 __DSH_BOOT__ 广告 404 bundle;apps/cli 发布 webPlugins 键供审计)、runtime 补 files 白名单;三包 exports/files/peer+dev/tsconfig refs 齐 fw-react 形状;constraints+invariants 双 gate 零违规

build(client): ui-layout/ui-sidebar tsdown configs adopt the explicit two-arg clientBundle signature (orphaned follow-up of the manifest shape batch)

refactor(gui): shell boot becomes a library face — bootWebShell(el) exported for the apps/web entry; main.ts retired

refactor(gui): exports 纪律刀1——ui-theme/i18n node index 收敛为只空 apply(Translate/LocaleDict/ThemeTokens 类型下沉 src/client/),ui-conversation 的 I18nService import 改 /client 子路径

build(typecheck): converge to root host aggregate + tsconfig.client.json — delete tsconfig.host.json, verify-cordis-config seeds both aggregates

feat(gui): apps/web restored as the vite application — thin main over bootWebShell; dsh-client-web becomes a plain lib (index exports shell surface, vite files and e2e moved out)

chore(gates): knip.json rewritten on the master base — same semantics, minimal diff (formatting churn dropped)

docs(gui): 时效清扫②——testing.md 删 web-ui 覆盖豁免残句;web-styling.md 加 token 换代头注(--dsw-* 现行、工程约束条款仍有效并注明收编处)

docs(gui): 时效清扫③——四对 GUI Agent Note 加路径更新头注(web-runtime/web-ui/dsh-frontend→现行 12 包结构;设计结论存续声明;双语对同步)

docs(gui): 时效清扫③b——四对 note 头注的 i18n 配对哈希重录

build(typecheck): minimal-diff tsconfig shape — drop root files entry (purity spec + preset move to client program), compress comments, drop redundant util/home root ref

feat(gui): apps/web restoration follow-through — dsh-frontend package name, cli dist resolve, root build:web filter, tsdown exemption dropped, vitest web lane + knip + client aggregate retargeted, e2e paths rebased

refactor(gui): exports 纪律刀2——connection wire 六件 git mv 进 src/client/(wire 即该 dshClient 插件的 client 半),node index=只空 apply,/client 半边整面导出(v3 §3.2 清单原样),包内 tests 改 src/client 直取

refactor(gui): exports 纪律刀3——runtime 实现整体下沉 src/client/(sessions/slots/loader;契约类型与 cordis merge 随迁 client/index),node index=只空 apply;./loader exports 指 client/loader;全消费面(web 壳/ui-sidebar/ui-trajectory/tests)bare→/client 机械跟改;vitest.e2e 换 tsconfig.vitest paths(root tsconfig 排除 client 会把 /client import 掉到 exports 的浏览器 dist bundle)

refactor(gui): exports 纪律刀3 补遗——ui-layout 三处 bare runtime import 改 /client(刀3 消费面机械跟改漏提交件;跨属地机械一行×3 报备 ui-shell)

test(gui): drop the getSessionManager singleton case — the init/get pair is a dead legacy-boot surface with zero live consumers (SessionsService constructs and holds the manager under the plugin architecture); source removal tracked with rt-core

refactor(gui): 删 manager.ts 尾部 initSessionManager/getSessionManager 单例对——旧 boot 直连遗物,插件化下 SessionsService 构造持有 manager,全仓零活消费者(convo-b 测试清扫对表,其测试用例已先行退役 7e2c51898);头注释同步去单例措辞

code

refactor
This commit is contained in:
imccyu
2026-07-19 21:17:57 +08:00
parent 4267076407
commit a6a3807a07
379 changed files with 33246 additions and 411 deletions

View File

@@ -0,0 +1,27 @@
# @deepseek-ai/dsh-host-apiproxy
The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`.
## Contract layer (`/api`)
Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier.
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
## Model Experience
None, as the package defines the client↔host wire contract and carriers; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.

View File

@@ -0,0 +1,59 @@
{
"name": "@deepseek-ai/dsh-host-apiproxy",
"description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime",
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./api": {
"types": "./lib/types/api/index.d.ts",
"default": "./lib/types/api/index.js"
},
"./api/*": {
"types": "./lib/types/api/*.d.ts",
"default": "./lib/types/api/*.js"
},
"./client": {
"types": "./lib/types/fetch/client.d.ts",
"default": "./lib/types/fetch/client.js"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.0.1"
},
"devDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}

View File

@@ -0,0 +1,21 @@
/**
* approvals domain zod schemas (respond is a client-response; the payload schema serves
* the /api/respond endpoint's second parse after routing via the pending table).
* ApprovalRequestId brand cast point: one.
*/
import { z } from 'zod'
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { ApprovalResponsePayload } from './approvals.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
/** ApprovalRequestId: one brand cast after shape validation (the only cast point in this domain). */
export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType<ApprovalRequestId>
/** Approval answer payload (the result.value slot of a client-response). */
export const approvalResponsePayloadSchema = z.object({
sessionId: sessionIdSchema,
approvalId: approvalRequestIdSchema,
outcome: z.union([z.literal('allowed-once'), z.literal('rejected')]),
}) satisfies z.ZodType<Wire<ApprovalResponsePayload>>

View File

@@ -0,0 +1,21 @@
/**
* approvals domain contract. The approval requested frame is a
* server-request (stable rpcId); the answer is a client-response echoing that rpcId (not a
* unary method, not in RpcMethodMap, mints no new id), carried on POST /api/respond with an
* RpcReceipt carrier receipt as the HTTP response body; the final outcome arrives in the resolved frame.
*/
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**
* Approval answer payload (the result.value slot of a client-response). outcome accepts only
* the two values a client can give (cancelled/unavailable are host-side outcomes). approvalId
* is the core audit correlation (used by the impl to reconcile `approval/asked`/`decided`;
* passes through core's existing brand); wire correlation is governed by the echoed rpcId.
*/
export interface ApprovalResponsePayload {
sessionId: SessionId
approvalId: ApprovalRequestId
outcome: 'allowed-once' | 'rejected'
}

View File

@@ -0,0 +1,42 @@
/**
* events domain zod schemas: MuxFrame / HostFrame unions (discriminatedUnion('type')).
* A frame is the payload slot of the ServerRequest full form; the SessionEvent inside
* a session/event frame reuses sessions.schema's strict-envelope + wide-data passthrough branch.
*/
import { z } from 'zod'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { HostFrame, MuxFrame } from './events.ts'
import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
export const askUserQuestionItemSchema = z.object({
id: z.string(),
question: z.string(),
header: z.string().optional(),
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
multiSelect: z.boolean().optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>
/** HostFrame union (payload slot of a host-stream ServerRequest). */
export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }),
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>

View File

@@ -0,0 +1,69 @@
/**
* events domain contract: signatures and frame unions for the two SSE
* streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request
* view) — rpcId must be exposed to the business layer, because responses to answerable frames
* (approval/question requested) echo it; for pure pushes it identifies that one push.
* signal is a local stream-control parameter, independent of the request (never on the wire).
*/
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
// dsh-tools remains its owner.
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
/**
* Host-computed render intent accompanying a `tool/call` or `tool/result`
* event. A pure derivation of args/result through the presenter registered at
* emission time — never persisted (the session log carries only the event), so
* the same event may carry a different view (or none) on a later delivery.
* `for` names which vocabulary applies without re-inspecting the event type.
* An absent view means the client's documented default (generic JSON card).
*/
export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
* attached session and replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
mux(request: RpcRequest<{ since?: Record<SessionId, number> }>, signal: AbortSignal): AsyncIterable<RpcRequest<MuxFrame>>
/**
* Host-level info stream: session create/destroy, running-status flips, and
* agent failures with no turn position. Empty payload uses `{}`.
*/
host(request: RpcRequest<{}>, signal: AbortSignal): AsyncIterable<RpcRequest<HostFrame>>
}
/**
* Mux stream frames: raw session-event passthrough + control frames +
* approval/question frames (requested = answerable server-request, the rest are pure pushes).
*/
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
| { type: 'stream/error'; error: RpcError }
/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId }
| { type: 'host/session-removed'; sessionId: SessionId }
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'stream/error'; error: RpcError }

View File

@@ -0,0 +1,19 @@
/**
* host domain zod schemas (names derived from map keys).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
/** host.describe request payload (empty object literal). */
export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.describe'>>>
/** host.describe response value. */
export const hostDescribeValueSchema = z.object({
version: z.string(),
cwd: z.string(),
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>

View File

@@ -0,0 +1,25 @@
/**
* host domain contract. No protocol version: client and host ship
* together; introduce protocolVersion only when an independently released client appears.
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** Host-level unary methods. */
export interface HostApi {
/**
* One-shot host snapshot. Empty payload uses the literal `{}` (extend in place when fields arrive).
* version = the host app's (apps/cli) package.json version; cwd = the host process working
* directory (root for session persistence and tool execution); provider/model = the defaults
* applied when a new agent doesn't specify them explicitly, absent when the host configures
* no explicit default (the adapter falls back internally);
* attachedSessions = count of currently attached sessions (those with a live agent).
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
version: string
cwd: string
provider?: string
model?: string
attachedSessions: number
}>>
}

View File

@@ -0,0 +1,46 @@
/**
* apiproxy contract-layer barrel. api/ has zero Node dependencies and is
* importable from the browser; the TS interfaces are the authoritative contract, HTTP/SSE are
* merely physical channels (four-quadrant message model).
*/
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { EventsApi } from './events.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
export interface ApiProxy {
sessions: SessionsApi
host: HostApi
events: EventsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
}
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'
// ---- Message layer: narrow forms (domain-signature view) ----
export type { RpcRequest, RpcResponse } from './rpc.ts'
// ---- Message layer: the four wire full forms + carrier receipt ----
export type {
ClientRequest,
ClientResponse,
RpcMessage,
RpcReceipt,
ServerRequest,
ServerResponse,
} from './rpc.ts'
// ---- Errors and ids ----
export { RpcId } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
// ---- Method registry and derived generics ----
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'

View File

@@ -0,0 +1,26 @@
/**
* questions domain zod schemas (respond is a client-response; the payload schema serves
* the /api/respond endpoint's second parse after routing via the pending table). The question
* identifier is the echoed rpcId; the payload carries no resource id.
*/
import { z } from 'zod'
import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-interaction/types'
import type { QuestionResponsePayload } from './questions.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
/** AskUserQuestionAnswer validated strictly against core dsh-user-interaction. */
export const askUserQuestionAnswerSchema = z.object({
answers: z.array(z.object({
id: z.string(),
selected: z.array(z.string()),
custom: z.string().optional(),
})),
}) satisfies z.ZodType<Wire<AskUserQuestionAnswer>>
/** Question answer payload (the result.value slot of a client-response). */
export const questionResponsePayloadSchema = z.object({
sessionId: sessionIdSchema,
answer: askUserQuestionAnswerSchema,
}) satisfies z.ZodType<Wire<QuestionResponsePayload>>

View File

@@ -0,0 +1,19 @@
/**
* questions domain contract. The question requested frame is a
* server-request whose rpcId is the question's stable logical id (minted when the host accepts
* ask(); core user-interaction has no request-level id); the answer is a client-response
* echoing that rpcId, with no resource id in the payload (rpcId suffices).
*/
import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-interaction/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**
* Question answer payload (the result.value slot of a client-response):
* answers one ask() as a whole batch (core: one ask, many questions, one
* answer — never split per question).
*/
export interface QuestionResponsePayload {
sessionId: SessionId
answer: AskUserQuestionAnswer
}

View File

@@ -0,0 +1,26 @@
/**
* RPC method registry and signature-derived generics. The map
* registers only client-request methods (respond is a client-response, so it is absent);
* map keys are the wire path segments (POST /api/session.list).
*/
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { RpcResponse } from './rpc.ts'
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.create': SessionsApi['create']
'session.history': SessionsApi['history']
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
export type RequestPayload<K extends keyof RpcMethodMap> = Parameters<RpcMethodMap[K]>[0]['payload']
/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */
export type ResponseValue<K extends keyof RpcMethodMap> =
Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never

View File

@@ -0,0 +1,97 @@
/**
* Message-layer zod schemas: the four wire full forms + error body +
* carrier receipt. The payload slot is unknown in the full-form schemas — business payloads
* get a second parse dispatched by method (two-level parse discipline).
* Brand cast point: rpcIdSchema, and only there.
*/
import { z } from 'zod'
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { ClientRequest, ClientResponse, RpcError, RpcId, RpcReceipt, ServerRequest, ServerResponse } from './rpc.ts'
/**
* Wire widening of a contract type: widens every property (deeply) to `original | undefined`.
* The repo enables exactOptionalPropertyTypes while zod `.optional()` outputs `T | undefined`,
* so `satisfies z.ZodType<ContractType>` is unusable across the board; anchoring is always
* written `satisfies z.ZodType<Wire<ContractType>>` — the widening only adds undefined, so
* missing fields / wrong types still fail to compile. On the JSON wire, "absent" and
* "value undefined" serialize identically, so the widening loses no validation semantics.
*/
export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[]
: T extends object ? { [K in keyof T]: Wire<T[K]> | undefined }
: T
/**
* RpcId: one brand cast after shape validation (the only cast point in this
* file). No min-length: the id is an opaque echo token, and rejecting shapes
* here would only turn a correlatable error report into a client-side parse
* failure (the handler substitutes a sentinel when a request's id is unreadable).
*/
export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>
/**
* Business success/failure result schema (generic, reusable).
* @param value - Schema for the business value.
* @returns Schema for RpcResult<T>.
*/
export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.ZodType, z.ZodType]> {
return z.union([
z.object({ ok: z.literal(true), value }),
z.object({ ok: z.literal(false), error: rpcErrorSchema }),
])
}
// ---- The four wire full-form schemas (payload/result.value slots stay wide — business layer does the second parse) ----
/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
export const clientRequestSchema = z.object({
type: z.literal('client-request'),
rpcId: rpcIdSchema,
method: z.string(),
payload: z.unknown(),
}) as unknown as z.ZodType<ClientRequest>
/** ServerResponse full form (result.value stays wide). */
export const serverResponseSchema = z.object({
type: z.literal('server-response'),
rpcId: rpcIdSchema,
result: rpcResultSchema(z.unknown()),
}) as unknown as z.ZodType<ServerResponse>
/** ServerRequest full form (payload stays wide). */
export const serverRequestSchema = z.object({
type: z.literal('server-request'),
rpcId: rpcIdSchema,
method: z.string(),
payload: z.unknown(),
}) as unknown as z.ZodType<ServerRequest>
/** ClientResponse full form (result.value stays wide). */
export const clientResponseSchema = z.object({
type: z.literal('client-response'),
rpcId: rpcIdSchema,
result: rpcResultSchema(z.unknown()),
}) as unknown as z.ZodType<ClientResponse>
/** Wire full-form union (discriminated by type). */
export const rpcMessageSchema = z.discriminatedUnion('type', [
clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
serverRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
clientResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
])
/** Carrier receipt schema. */
export const rpcReceiptSchema = z.union([
z.object({ accepted: z.literal(true) }),
z.object({ accepted: z.literal(false), reason: z.union([z.literal('not-pending'), z.literal('bad-response')]) }),
]) satisfies z.ZodType<Wire<RpcReceipt>>

View File

@@ -0,0 +1,113 @@
/**
* Four-quadrant RPC message model. Channels and messages are
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
* messages are channel-independent, and the wire full form is a four-member discriminated union.
* api/ contract layer: zero Node dependencies, importable from the browser.
*/
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**
* Message correlation id: the initiator mints it on a request; a response
* echoes the matching request's rpcId and never mints a new one.
*/
export type RpcId = Branded<'rpc-id'>
/**
* Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator:
* client-request → client mints; server-request → host mints (answerable frames get a stable
* logical id, pure pushes mint a fresh one each time).
* @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
* @returns The same string, branded (compile-time cast, zero runtime cost).
*/
export function RpcId(id: string): RpcId {
return id as RpcId
}
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'session-not-found': { sessionId: SessionId }
'agent-busy': { reason: string }
'internal': {}
}
/** Closed error-code union (the keys of RpcErrorDetailsMap). */
export type RpcErrorCode = keyof RpcErrorDetailsMap
/**
* Distributive union expanded from the map: code is the discriminant, so
* `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
*/
export type RpcError = {
[C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
}[RpcErrorCode]
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
/**
* Signature-layer narrow form, request side (domain-interface view, shared by
* both directions): rpcId is explicit in the signature, never mixed into the
* business payload; the type tag and method are filled in by the carrier layer.
*/
export interface RpcRequest<P> {
rpcId: RpcId
payload: P
}
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
export interface RpcResponse<T> {
rpcId: RpcId
result: RpcResult<T>
}
// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ----
/** Call initiated by the client (wire carrier: POST /api/<method> body). */
export interface ClientRequest {
type: 'client-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
export interface ServerResponse {
type: 'server-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/**
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
* response is expected is determined statically by method (a strict dichotomy, no third kind).
*/
export interface ServerRequest {
type: 'server-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */
export interface ClientResponse {
type: 'client-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
/**
* Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same
* discipline as "HTTP status describes only the carrier"): the HTTP response
* body of the POST carrying a client-response. Late/duplicate responses yield not-pending.
*/
export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }

View File

@@ -0,0 +1,110 @@
/**
* sessions domain zod schemas (names derived from map keys: sessionListRequestSchema /
* sessionListValueSchema). SessionEvent passthrough = strict envelope (type/seq/time) + wide
* data: the merge-extensible event surface keeps an unknown-type branch at the union level,
* with no field-level passthrough. SessionId brand cast point: sessionIdSchema, and only there.
*/
import { z } from 'zod'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type { ToolEventView } from './events.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */
export const sessionEventSchema = z.object({
type: z.string(),
seq: z.number().int().nonnegative(),
time: z.number(),
data: z.unknown(),
sourceEventSeqs: z.array(z.number()).optional(),
surfaceOp: z.unknown().optional(),
}) as unknown as z.ZodType<SessionEvent>
/** SessionSummary row of session.list. */
export const sessionSummarySchema = z.object({
sessionId: sessionIdSchema,
updatedAt: z.number(),
running: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
cwd: z.string().optional(),
}) satisfies z.ZodType<Wire<SessionSummary>>
/** session.list request payload (cursor is a reserved seat, unimplemented in v1). */
export const sessionListRequestSchema = z.object({
cursor: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.list'>>>
/** session.list response value. */
export const sessionListValueSchema = z.object({
items: z.array(sessionSummarySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
/** session.create request payload. */
export const sessionCreateRequestSchema = z.object({
cwd: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
/** session.create response value. */
export const sessionCreateValueSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,
beforeSeq: z.number().int().nonnegative().optional(),
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
/**
* ToolEventView passthrough: lock only the `for` discriminant and the presence
* of a card-tagged `view` object. The view interior is a host-computed product
* the client reads without echoing back; deep-validating it would hand-copy
* the dsh-tools vocabulary into this schema and drift with it.
*/
export const toolEventViewSchema = z.discriminatedUnion('for', [
z.object({ for: z.literal('call'), view: z.looseObject({ card: z.string() }) }),
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
export const historyEntrySchema = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
}) satisfies z.ZodType<Wire<HistoryEntry>>
/** session.history response value. */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })
/** session.prompt request payload. */
export const sessionPromptRequestSchema = z.object({
sessionId: sessionIdSchema,
mode: z.union([z.literal('queue'), z.literal('steer')]),
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
/** session.prompt response value. */
export const sessionPromptValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
/** session.cancel request payload. */
export const sessionCancelRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.cancel'>>>
/** session.cancel response value. */
export const sessionCancelValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>

View File

@@ -0,0 +1,73 @@
/**
* sessions domain contract. Method signatures are the source of truth:
* unary methods take the RpcRequest<P> narrow form and the impl echoes rpcId; everything
* else references RequestPayload<'session.*'> / ResponseValue<'session.*'>.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/**
* The prompt's rpcId is passed through MessageSource into the `user/message` event
* (the client uses it to reconcile the optimistically
* echoed provisional message with the event stream). kind stays `'user'` — the model face
* carries no transport vocabulary; rpcId is an extra durable-JSON field passed back to the client with the event.
*/
'user-rpc': { kind: 'user'; rpcId: RpcId }
}
}
/**
* One history page entry: the raw event plus the optional host-computed render
* intent (same semantics as the mux frame's `view` slot — a pagination-time
* derivation, never persisted).
*/
export interface HistoryEntry {
event: SessionEvent
view?: ToolEventView
}
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
/** Persisted file mtime. */
updatedAt: number
/** Status of the attached agent; always false for cold (unattached) sessions. */
running: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
parentSessionId?: SessionId
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
cwd?: string
}
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
export interface SessionsApi {
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
/** Creates a new session (and its agent, idle and standing by). */
create(request: RpcRequest<{ cwd?: string }>): Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Reads a window of history events; page boundaries align to message boundaries: one page =
* all raw events owned by a whole number of messages (including their chunk / tool events),
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
* partial — chunk events already emitted for the last unfinalized message.
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
* presenter produced one, evaluated against the registry at pagination time); the client
* rebuilds the surface from the events with the shared fold.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
}

View File

@@ -0,0 +1,302 @@
/**
* Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
* four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
* IApiClient domain methods (business code never mints). Platform differences ride two aspects:
* abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
*/
import type { z } from 'zod'
import type { ApiProxy, HostFrame, MuxFrame } from '../api/index.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
import type { ClientRequest, ClientResponse, RpcMessage, RpcReceipt, RpcRequest, RpcResponse, ServerRequest } from '../api/rpc.ts'
import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import { hostDescribeValueSchema } from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionCreateValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionPromptValueSchema,
} from '../api/sessions.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
* methods take the business payload directly — the carrier mints the rpcId and wraps the
* envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
* Unary methods and respond accept an optional external AbortSignal as the last parameter
* (merged with the instance timeout via AbortSignal.any; same "signal rides beside the
* request, never on the wire" discipline as the stream signatures).
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
* readable (response headers received, before any frame) — the "stream established" signal
* connection controllers need for the readiness handshake. Generators are lazy, so the
* underlying fetch (and therefore onOpen) only happens once iteration starts.
* Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
* IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two.
* Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
*/
export interface IApiClient {
sessions: {
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
}
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
}
/**
* S→C second-level parse table: value schema by method (the response-path
* mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
*/
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
'session.list': sessionListValueSchema,
'session.create': sessionCreateValueSchema,
'session.history': sessionHistoryValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
const DEFAULT_TIMEOUT_MS = 30_000
/** URL base for in-process handler injection (fake authority, opencode precedent). */
const INTERNAL_BASE = 'http://dsh.internal'
/**
* Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the
* per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay
* here. Envelope observation is a first-class aspect of this data middle layer: the instance
* owns a microtask-batched buffer (frame storms must not cost one consumer update per frame),
* and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process
* subclass whose doFetch is toFetchHandler(api).fetch never touches the network.
*/
export abstract class AbstractApiClient implements IApiClient {
/** Instance-owned observation buffer (module-level state would leak across instances/tests). */
private envelopeBatch: RpcMessage[] = []
private flushScheduled = false
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
/** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */
constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
/** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
protected abstract doFetch(input: URL, init?: RequestInit): Promise<Response>
/**
* Subscribe to batched envelope observation (diagnostics/logging consumers).
* Batches follow microtask boundaries; a listener throw is isolated (observation
* must never break the carrier).
* @param listener - receives each flushed batch in arrival order.
* @returns unsubscribe function.
*/
subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void {
this.envelopeListeners.add(listener)
return () => {
this.envelopeListeners.delete(listener)
}
}
/** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */
protected onEnvelope(message: RpcMessage): void {
if (this.envelopeListeners.size === 0) return
this.envelopeBatch.push(message)
if (this.flushScheduled) return
this.flushScheduled = true
queueMicrotask(() => {
this.flushScheduled = false
// Never empty here: a flush is only ever scheduled by the push above,
// and this callback is the sole drain point.
const batch = this.envelopeBatch
this.envelopeBatch = []
for (const notify of this.envelopeListeners) {
try {
notify(batch)
} catch (error) {
console.error('[apiproxy] envelope listener threw:', error)
}
}
})
}
/** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */
protected resolveBase(): string {
const loc = (globalThis as { location?: { origin?: string } }).location
return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE
}
protected mintRpcId(): RpcId {
// crypto.randomUUID is a Web API (browser + Node ≥19): keeps this base platform-neutral.
return RpcId(crypto.randomUUID())
}
/**
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
*/
private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
const timeout = AbortSignal.timeout(this.timeoutMs)
const response = await this.doFetch(new URL(path, this.resolveBase()), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
})
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
return response
}
/**
* Unary protocol path: mint → tap → POST full form → envelope parse → verify
* echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can
* override transport at this layer.
*/
protected async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
if (!full.result.ok) return { rpcId: full.rpcId, result: full.result }
// Second-level S→C parse: the ok value must match the method's Value schema (mirror of the
// handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side.
const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue<K>
return { rpcId: full.rpcId, result: { ok: true, value } }
}
/** Mux stream opener; virtual for the same override reason as callUnary. */
protected openMux(_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> {
return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen)
}
/** Host stream opener; virtual. */
protected openHost(_payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>> {
return this.readSse('/api/events.host', signal, hostFrameSchema, onOpen)
}
/**
* SSE protocol path: streaming fetch (not EventSource), '\n\n' framing, ServerRequest envelope +
* frame-schema parse, tap, narrow yield. onOpen fires once the response headers are in and the
* body is readable — the stream-established signal, before any frame arrives. A frame that fails
* either parse level is reported and skipped (one corrupt frame must not kill the stream; the
* client's gap detection covers whatever the frame carried).
*/
protected async *readSse<F extends MuxFrame | HostFrame>(
path: string,
signal: AbortSignal,
frameSchema: z.ZodType<F>,
onOpen?: () => void,
): AsyncGenerator<RpcRequest<F>> {
const response = await this.doFetch(new URL(path, this.resolveBase()), { signal })
if (!response.ok || response.body === null) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
onOpen?.()
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) return
buffer += decoder.decode(value, { stream: true })
let boundary: number
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const chunk = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = chunk.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('')
if (data === '') continue
let full: ServerRequest
let frame: F
try {
full = serverRequestSchema.parse(JSON.parse(data))
frame = frameSchema.parse(full.payload)
} catch (error) {
console.error(`[apiproxy] dropping malformed SSE frame on ${path}:`, error)
continue
}
this.onEnvelope(full)
yield { rpcId: full.rpcId, payload: frame }
}
}
} finally {
await reader.cancel().catch(() => undefined)
}
}
// ---- IApiClient surface (arrow properties so destructured/passed references stay bound) ----
readonly sessions: IApiClient['sessions'] = {
list: (payload, signal) => this.callUnary('session.list', payload, signal),
create: (payload, signal) => this.callUnary('session.create', payload, signal),
history: (payload, signal) => this.callUnary('session.history', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
}
readonly events: IApiClient['events'] = {
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
}
async respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt> {
this.onEnvelope(message)
const response = await this.postJson('/api/respond', message, signal)
return rpcReceiptSchema.parse(await response.json())
}
}
/**
* In-process client over an injected fetch-shaped handler (the isomorphic point:
* `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because
* in-process injection is this package's own capability (handler and client are both local).
*/
export class InProcessApiClient extends AbstractApiClient {
constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) {
super(timeoutMs)
}
/**
* Faithful to real fetch: reject on signal abort even when the in-process
* handler ignores the signal (a hung impl must not defeat timeout/cancel).
*/
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
const signal = init?.signal ?? undefined
if (signal === undefined) return this.handler.fetch(input, init)
if (signal.aborted) return Promise.reject(abortError(signal))
return new Promise((resolve, reject) => {
const onAbort = (): void => { reject(abortError(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
this.handler.fetch(input, init)
.then(resolve, reject)
.finally(() => { signal.removeEventListener('abort', onAbort) })
})
}
}
/** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */
function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason
if (reason instanceof Error) return reason
if (typeof reason === 'string') return new Error(reason)
return new Error('This operation was aborted')
}

View File

@@ -0,0 +1,197 @@
/**
* Server side of the fetch carrier: maps an ApiProxy onto a pure
* WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
* path==method) -> payload dispatched per method. HTTP status expresses only the carrier
* (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always
* 200 + ServerResponse.
*/
import { randomUUID } from 'node:crypto'
import type { z } from 'zod'
import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
import {
sessionCancelRequestSchema,
sessionCreateRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionPromptRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
* route row fails to compile, and each row's schema/invoke pair is checked against that row's
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
* documented on Wire); the dispatch point carries the one Wire→exact cast.
*/
type UnaryRoutes = {
[K in keyof RpcMethodMap]: {
schema: z.ZodType<Wire<RequestPayload<K>>>
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>): Promise<RpcResponse<ResponseValue<K>>>
}
}
const UNARY_ROUTES: UnaryRoutes = {
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
function methodFor(path: string): keyof RpcMethodMap | undefined {
return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined
}
/**
* Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response
* must still be a valid ServerResponse (a self-violating shape would turn the server's explicit
* bad-request report into a client-side parse failure). Fixed value, documented here as wire contract.
*/
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
/** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */
function errorResponse(rpcId: RpcId, error: RpcError): Response {
const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } }
return Response.json(body)
}
/** Complete the impl's narrow form into a ServerResponse full form. */
function fullResponse(narrow: RpcResponse<unknown>): Response {
const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result }
return Response.json(body)
}
/**
* Parse the payload and invoke one unary route. Generic over the map key so
* the row's schema/invoke pairing typechecks; the only cast collapses the
* Wire<> widening back to the exact payload (undefined-valued properties and
* absent ones are indistinguishable after JSON transport).
*/
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
async function handleUnary<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest): Promise<Response> {
const route = UNARY_ROUTES[method]
const payload = route.schema.safeParse(message.payload)
if (!payload.success) {
return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
}
try {
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }))
} catch (error: unknown) {
// The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
return new Response(`handler failure: ${String(error)}`, { status: 500 })
}
}
/** SSE frame: complete the narrow RpcRequest<frame> into a ServerRequest full form (method = frame type). */
function fullFrame(narrow: RpcRequest<MuxFrame | HostFrame>): ServerRequest {
return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload }
}
/**
* Wrap a frame stream as an SSE Response; stops when req.signal aborts. An
* impl throw mid-stream emits one stream/error frame and then closes.
*/
function sseResponse(frames: AsyncIterable<RpcRequest<MuxFrame | HostFrame>>): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
// Send an SSE comment line on open so clients/proxies see a live channel (the host
// stream has no baseline frames and would otherwise emit zero bytes while idle;
// a comment line is not a frame, so client frame parsing skips it naturally).
controller.enqueue(encoder.encode(': connected\n\n'))
for await (const narrow of frames) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`))
}
} catch (error: unknown) {
// Mid-stream impl failure → one stream/error frame, then close: the client must see
// the failure instead of a silent end (which reads as a normal disconnect). A fresh
// rpcId is minted — this is a server-initiated push like any other frame.
const failure: MuxFrame | HostFrame = { type: 'stream/error', error: { code: 'internal', message: String(error), details: {} } }
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame({ rpcId: RpcId(randomUUID()), payload: failure }))}\n\n`))
} catch {
// Consumer already cancelled the stream: enqueue-after-cancel is the
// only reachable error, and there is no one left to tell.
}
} finally {
try {
controller.close()
} catch { /* already cancelled by the consumer: a double close is the only reachable error */ }
}
},
})
return new Response(stream, {
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
})
}
/**
* Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
* @param api - the host-side ApiProxy implementation.
* @returns an object holding `fetch(Request)`; paths outside /api/ return 404.
*/
export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
return {
// Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect,
// Clients call in (url, init) form — normalize to Request before handling.
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const req = input instanceof Request ? input : new Request(input, init)
const url = new URL(req.url)
const path = url.pathname
if (path === '/api/events.mux' && req.method === 'GET') {
return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
}
if (path === '/api/events.host' && req.method === 'GET') {
return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
}
if (req.method !== 'POST' || !path.startsWith('/api/')) {
return new Response('not found', { status: 404 })
}
let body: unknown
try {
body = await req.json()
} catch {
// 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request.
return new Response('body is not JSON', { status: 400 })
}
if (path === '/api/respond') {
const parsed = clientResponseSchema.safeParse(body)
if (!parsed.success) return Response.json({ accepted: false, reason: 'bad-response' })
return Response.json(await api.respond(parsed.data))
}
const method = methodFor(path.slice('/api/'.length))
if (method === undefined) return new Response('not found', { status: 404 })
const envelope = clientRequestSchema.safeParse(body)
if (!envelope.success) {
// Best effort at correlation: salvage a string rpcId from the raw body;
// otherwise the fixed sentinel keeps the response a valid ServerResponse.
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } })
}
const message: ClientRequest = envelope.data
if (message.method !== method) {
return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
}
return handleUnary(api, method, message)
},
}
}

View File

@@ -0,0 +1,13 @@
/**
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
* lives in @deepseek-ai/dsh-host-runtime.
*/
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'

View File

@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-host-apiproxy`.
* @module @deepseek-ai/dsh-host-apiproxy/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy'
/** Cordis companion plugin name. */
export const name = 'host-apiproxy-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package is the wire contract layer (types,
* schemas, fetch carrier glue) — it emits no cordis events and owns no
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
* enforced at the carrier boundary and exercised by the protocol-isomorphism
* suite; the live implementation relations belong to dsh-host-runtime.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,407 @@
/**
* Wire-protocol coverage over the isomorphic point: InProcessApiClient →
* toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod
* two-level parse, rpcId discipline, and SSE framing with no network and no
* browser. Each case scripts its own minimal ApiProxy.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { ApiProxy, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
}
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
host?: Partial<ApiProxy['host']>
events?: Partial<ApiProxy['events']>
respond?: ApiProxy['respond']
} = {}): ApiProxy {
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
return {
sessions: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { sessionId: sid('s-new') }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
}
}
function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
}
describe('unary round trip', () => {
it('carries payload out and value back through the full wire form', async () => {
let seen: RpcRequest<{ cursor?: string }> | undefined
const api = scriptedApi({
sessions: {
list: (r) => {
seen = r
return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false }] })
},
},
})
const response = await client(api).sessions.list({ cursor: 'c1' })
// Impl received the narrow form with a minted id; client returned the same id and value.
expect(seen?.payload).toEqual({ cursor: 'c1' })
expect(seen?.rpcId).toBeTruthy()
expect(response.rpcId).toBe(seen?.rpcId)
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
})
it('passes business errors through as 200 + err result, not a throw', async () => {
const api = scriptedApi({
sessions: {
cancel: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: sid('sx') } } } }),
},
})
const response = await client(api).sessions.cancel({ sessionId: sid('sx') })
expect(response.result).toEqual({ ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: 'sx' } } })
})
it('throws on rpcId echo mismatch', async () => {
const api = scriptedApi({
sessions: { list: () => Promise.resolve({ rpcId: RpcId('forged'), result: { ok: true, value: { items: [] } } }) },
})
await expect(client(api).sessions.list({})).rejects.toThrow(/rpcId mismatch/)
})
it('rejects an invalid payload at the handler as 200 + bad-request with issues', async () => {
const api = scriptedApi()
const response = await client(api).sessions.history({ sessionId: 123 as unknown as SessionId })
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('bad-request')
expect((response.result.error.details as { issues: unknown[] }).issues.length).toBeGreaterThan(0)
}
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
expect(response.status).toBe(200)
const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
expect(parsed.result.ok).toBe(false)
expect(parsed.result.error?.code).toBe('bad-request')
expect(parsed.result.error?.message).toMatch(/does not match path/)
})
it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
const handler = toFetchHandler(scriptedApi())
// No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
expect(noId.status).toBe(200)
const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
expect(noIdParsed.result.ok).toBe(false)
expect(noIdParsed.rpcId).toBe('invalid-request')
// A string rpcId in the otherwise-bad body is salvaged for correlation.
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
expect(withIdParsed.result.ok).toBe(false)
expect(withIdParsed.rpcId).toBe('salvage-me')
})
it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
const handler = toFetchHandler(scriptedApi())
// Unknown method → 404.
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
expect(notFound.status).toBe(404)
// Non-JSON body → 400.
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
expect(badBody.status).toBe(400)
// Impl crash → 500, and through the client that is a throw, not an err result.
const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
})
it('rejects when the transport never resolves within timeoutMs', async () => {
// AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
const never = new InProcessApiClient({
fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
}),
}, 25)
await expect(never.sessions.list({})).rejects.toThrow()
})
it('aborts a unary call through the caller-supplied external signal', async () => {
// Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
// works even when the transport ignores the signal entirely (hung impl).
const gate = new AbortController()
const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
const call = hung.sessions.list({}, gate.signal)
gate.abort(new Error('externally aborted'))
await expect(call).rejects.toThrow(/externally aborted/)
})
it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
let touched = false
const c = new InProcessApiClient({
fetch: () => {
touched = true
return Promise.resolve(new Response('{}'))
},
}, 60_000)
const gate = new AbortController()
gate.abort('gone before start')
await expect(c.sessions.list({}, gate.signal)).rejects.toThrow('gone before start')
expect(touched).toBe(false)
})
it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
const gate = new AbortController()
const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
const call = hung.sessions.list({}, gate.signal)
gate.abort(42)
await expect(call).rejects.toThrow('This operation was aborted')
})
it('passes a signal-less doFetch straight through to the handler', async () => {
class Probe extends InProcessApiClient {
direct(url: URL): Promise<Response> {
return this.doFetch(url)
}
}
const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
const response = await probe.direct(new URL('http://dsh.internal/probe'))
expect(await response.text()).toBe('raw')
})
it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
// Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
const api = scriptedApi({
sessions: { list: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) as never },
})
await expect(client(api).sessions.list({})).rejects.toThrow()
})
})
describe('SSE stream path', () => {
it('yields frames in order and skips the comment preamble', async () => {
const frames: MuxFrame[] = [
{ type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 3 },
{ type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } },
]
const api = scriptedApi({
events: {
async *mux(request) {
let n = 0
for (const frame of frames) yield { rpcId: RpcId(`push-${n++}-${request.rpcId}`), payload: frame }
},
},
})
const seen: MuxFrame[] = []
for await (const envelope of client(api).events.mux({}, new AbortController().signal)) {
seen.push(envelope.payload)
}
expect(seen).toEqual(frames)
})
it('reassembles frames across arbitrary chunk boundaries', async () => {
// Two SSE frames split so one frame spans chunks and one chunk carries parts of both.
const f1 = { type: 'server-request', rpcId: 'a', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } }
const f2 = { type: 'server-request', rpcId: 'b', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's2', lastSeq: 2 } }
const wire = `: connected\n\ndata: ${JSON.stringify(f1)}\n\ndata: ${JSON.stringify(f2)}\n\n`
const cuts = [5, 40, wire.indexOf('data: ', 40) + 3]
const encoder = new TextEncoder()
const doFetch = (): Promise<Response> => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
start(controller) {
let prev = 0
for (const cut of [...cuts, wire.length]) {
controller.enqueue(encoder.encode(wire.slice(prev, cut)))
prev = cut
}
controller.close()
},
}), { status: 200 }))
const chopped = new InProcessApiClient({ fetch: doFetch })
const seen: string[] = []
for await (const envelope of chopped.events.mux({}, new AbortController().signal)) {
seen.push((envelope.payload as { sessionId: string }).sessionId)
expect(envelope.rpcId).toBe(seen.length === 1 ? 'a' : 'b')
}
expect(seen).toEqual(['s1', 's2'])
})
it('emits a stream/error frame then closes when the impl throws mid-stream', async () => {
const api = scriptedApi({
events: {
async *host(request): AsyncGenerator<RpcRequest<HostFrame>> {
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1') } }
throw new Error('impl died mid-stream')
},
},
})
const seen: HostFrame[] = []
for await (const envelope of client(api).events.host({}, new AbortController().signal)) {
seen.push(envelope.payload)
}
expect(seen.map(f => f.type)).toEqual(['host/session-added', 'stream/error'])
const last = seen.at(-1)
if (last?.type === 'stream/error') expect(last.error.message).toMatch(/impl died mid-stream/)
})
it('drops a malformed SSE frame and keeps the stream alive (S→C two-level parse)', async () => {
const good = { type: 'server-request', rpcId: 'g1', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } }
const badEnvelope = { type: 'server-response', rpcId: 'x' } // wrong quadrant for a stream
const badFrame = { type: 'server-request', rpcId: 'b1', method: 'nope', payload: { type: 'no/such-frame' } }
const wire = [
'data: {oops', // not JSON
`data: ${JSON.stringify(badEnvelope)}`,
`data: ${JSON.stringify(badFrame)}`,
`data: ${JSON.stringify(good)}`,
].map(l => `${l}\n\n`).join('')
const doFetch = (): Promise<Response> => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(wire))
controller.close()
},
}), { status: 200 }))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const seen: MuxFrame[] = []
for await (const envelope of new InProcessApiClient({ fetch: doFetch }).events.mux({}, new AbortController().signal)) {
seen.push(envelope.payload)
}
// The three corrupt frames are reported and skipped; the good one still arrives.
expect(seen).toEqual([{ type: 'session/subscribed', sessionId: 's1', lastSeq: 1 }])
expect(errorSpy.mock.calls.length).toBe(3)
} finally {
errorSpy.mockRestore()
}
})
it('fires onOpen once headers are in, before the first frame, and not on transport failure', async () => {
const api = scriptedApi({
events: {
async *mux(request): AsyncGenerator<RpcRequest<MuxFrame>> {
yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 0 } }
},
},
})
const order: string[] = []
const iterator = client(api).events.mux({}, new AbortController().signal, () => order.push('open'))
expect(order).toEqual([]) // lazy generator: no fetch (and no onOpen) before iteration
for await (const _ of iterator) order.push('frame')
expect(order).toEqual(['open', 'frame'])
// Transport failure path: onOpen must not fire.
const failing = new InProcessApiClient({ fetch: () => Promise.resolve(new Response('down', { status: 503 })) })
const failOrder: string[] = []
await expect((async () => {
for await (const _ of failing.events.mux({}, new AbortController().signal, () => failOrder.push('open'))) { /* unreachable */ }
})()).rejects.toThrow(/transport failure/)
expect(failOrder).toEqual([])
})
it('stops consuming when the caller aborts', async () => {
let implSawAbort = false
const api = scriptedApi({
events: {
async *mux(_request, signal): AsyncGenerator<RpcRequest<MuxFrame>> {
try {
let n = 0
while (true) {
yield { rpcId: RpcId(`p${n}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: n++ } }
await new Promise(resolve => setTimeout(resolve, 5))
if (signal.aborted) return
}
} finally {
implSawAbort = true
}
},
},
})
const abort = new AbortController()
let count = 0
// In-process abort ends the stream (impl returns on signal.aborted); over a real
// network fetch the same abort surfaces as a rejection — both stop the loop.
await (async () => {
for await (const _ of client(api).events.mux({}, abort.signal)) {
if (++count === 2) abort.abort()
}
})().catch(() => undefined)
expect(count).toBe(2)
// Generator teardown may lag the abort by a microtask; poll briefly.
await vi.waitFor(() => { expect(implSawAbort).toBe(true) })
})
})
describe('respond path', () => {
it('round-trips a client-response to a receipt', async () => {
const seen: unknown[] = []
const api = scriptedApi({
respond: (message) => {
seen.push(message)
return Promise.resolve({ accepted: true as const })
},
})
const receipt = await client(api).respond({ type: 'client-response', rpcId: RpcId('req-1'), result: { ok: true, value: { behavior: 'allow' } } })
expect(receipt).toEqual({ accepted: true })
expect(seen).toEqual([{ type: 'client-response', rpcId: 'req-1', result: { ok: true, value: { behavior: 'allow' } } }])
})
it('returns bad-response for a malformed client-response without reaching the impl', async () => {
const respond = vi.fn()
const handler = toFetchHandler(scriptedApi({ respond }))
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
expect(respond).not.toHaveBeenCalled()
})
})
describe('envelope tap', () => {
it('delivers one microtask batch of full forms per unary call', async () => {
const api = scriptedApi()
const tapped = client(api)
const batches: (readonly RpcMessage[])[] = []
tapped.subscribeEnvelopes(batch => batches.push(batch))
await tapped.sessions.list({})
await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
const all = batches.flat()
expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
})
it('isolates a throwing listener and keeps serving the call', async () => {
const api = scriptedApi()
const tapped = client(api)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const good: string[] = []
tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
const response = await tapped.sessions.list({})
expect(response.result.ok).toBe(true)
await vi.waitFor(() => { expect(good).toContain('server-response') })
} finally {
errorSpy.mockRestore()
}
})
it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
const api = scriptedApi()
const tapped = client(api)
await tapped.sessions.list({}) // no subscribers: must not accumulate
const batches: (readonly RpcMessage[])[] = []
const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
unsubscribe()
await tapped.sessions.list({})
await new Promise(resolve => setTimeout(resolve, 0))
expect(batches).toEqual([])
})
})

View File

@@ -0,0 +1,305 @@
import { describe, expect, it, vi } from 'vitest'
import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts'
import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { toFetchHandler } from '../src/fetch/handler.ts'
import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
/** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */
function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy {
const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }]
const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }]
async function * stream<F>(frames: F[], signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
for (const payload of frames) {
if (signal.aborted) return
yield { rpcId: RpcId(`frame-${String(frames.indexOf(payload))}`), payload }
}
}
return {
sessions: {
async list(request) {
if (overrides.crashOn === 'session.list') throw new Error('impl crashed')
return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
},
async create(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
},
async history(request) {
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
}
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async cancel(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),
host: (_request, signal) => stream(hostFrames, signal),
},
async respond(message: ClientResponse): Promise<RpcReceipt> {
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
},
}
}
function client(api: ApiProxy = fakeApi()): InProcessApiClient {
return new InProcessApiClient(toFetchHandler(api))
}
async function collect<F>(stream: AsyncIterable<RpcRequest<F>>): Promise<RpcRequest<F>[]> {
const out: RpcRequest<F>[] = []
for await (const envelope of stream) out.push(envelope)
return out
}
describe('unary round trip (handler ⇄ client, no network)', () => {
it('carries a success result and echoes the minted rpcId', async () => {
const response = await client().sessions.list({})
expect(response.result).toEqual({ ok: true, value: { items: [] } })
expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
})
it('carries a business error as 200 + error result', async () => {
const response = await client().sessions.history({ sessionId: 'missing' as never })
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('covers create/prompt/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)
})
})
describe('handler carrier-layer statuses', () => {
const handler = toFetchHandler(fakeApi())
it('404s unknown paths and non-POST non-stream methods', async () => {
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
})
it('400s a non-JSON body', async () => {
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' }))
expect(response.status).toBe(400)
})
it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) }))
expect(response.status).toBe(200)
const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
expect(body.rpcId).toBe('invalid-request')
expect(body.result.error?.code).toBe('bad-request')
})
it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
expect(parsed.rpcId).toBe('r-9')
expect(parsed.result.error?.message).toContain('does not match path')
})
it('rejects an invalid payload with the zod issues attached', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body }))
const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
expect(parsed.result.error?.code).toBe('bad-request')
expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
})
it('500s when the impl itself throws', async () => {
const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
expect(response.status).toBe(500)
expect(await response.text()).toContain('impl crashed')
})
it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json()
expect(goodReceipt).toEqual({ accepted: true })
const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json()
expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
})
it('accepts (url, init) form fetch invocation', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body })
expect(response.status).toBe(200)
})
})
describe('SSE streams through the carrier', () => {
it('yields mux frames as ServerRequest narrow forms and completes', async () => {
const ac = new AbortController()
const frames = await collect(client().events.mux({}, ac.signal))
expect(frames).toHaveLength(1)
expect(frames[0]?.payload).toMatchObject({ type: 'session/subscribed' })
expect(frames[0]?.rpcId).toBe('frame-0')
})
it('yields host frames', async () => {
const ac = new AbortController()
const frames = await collect(client().events.host({}, ac.signal))
expect(frames[0]?.payload).toMatchObject({ type: 'host/session-removed' })
})
it('drops frames after the consumer aborts mid-stream', async () => {
const many = Array.from({ length: 50 }, (_, i): MuxFrame => ({ type: 'session/subscribed', sessionId: `s${String(i)}` as never, lastSeq: i }))
const ac = new AbortController()
const received: RpcRequest<MuxFrame>[] = []
for await (const envelope of client(fakeApi({ muxFrames: many })).events.mux({}, ac.signal)) {
received.push(envelope)
if (received.length === 2) break // generator return → reader.cancel path
}
expect(received).toHaveLength(2)
})
it('swallows a reader.cancel rejection on early exit', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
const frame = { type: 'server-request', rpcId: 'f0', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's', lastSeq: -1 } }
controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`))
// stream intentionally left open: the consumer breaks first
},
cancel() {
throw new Error('cancel refused')
},
})
const c = new InProcessApiClient({ fetch: async () => new Response(body, { headers: { 'content-type': 'text/event-stream' } }) })
const received: RpcRequest<MuxFrame>[] = []
for await (const envelope of c.events.mux({}, new AbortController().signal)) {
received.push(envelope)
break
}
expect(received).toHaveLength(1)
})
it('surfaces a mid-stream impl failure as one stream/error frame, then the stream ends', async () => {
const api = fakeApi()
api.events.mux = (_request, _signal) => (async function * (): AsyncGenerator<RpcRequest<MuxFrame>> {
yield { rpcId: RpcId('f0'), payload: { type: 'session/subscribed', sessionId: 's' as never, lastSeq: -1 } }
throw new Error('stream source died')
})()
const frames = await collect(client(api).events.mux({}, new AbortController().signal))
expect(frames).toHaveLength(2)
expect(frames[1]?.payload).toMatchObject({ type: 'stream/error', error: { code: 'internal' } })
})
})
describe('client respond and transport failures', () => {
it('passes a client-response through and parses the receipt', async () => {
const receipt = await client().respond({ type: 'client-response', rpcId: RpcId('known'), result: { ok: true, value: null } })
expect(receipt).toEqual({ accepted: true })
const late = await client().respond({ type: 'client-response', rpcId: RpcId('late'), result: { ok: true, value: null } })
expect(late).toEqual({ accepted: false, reason: 'not-pending' })
})
it('throws on non-OK unary and respond and stream transport', async () => {
const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) })
await expect(broken.sessions.list({})).rejects.toThrow('transport failure for /api/session.list: HTTP 503')
await expect(broken.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } }))
.rejects.toThrow('transport failure for /api/respond')
await expect(collect(broken.events.mux({}, new AbortController().signal))).rejects.toThrow('transport failure for /api/events.mux')
})
it('throws on an rpcId echo mismatch', async () => {
const lying = new InProcessApiClient({
fetch: async () => Response.json({ type: 'server-response', rpcId: 'someone-else', result: { ok: true, value: { items: [] } } }),
})
await expect(lying.sessions.list({})).rejects.toThrow('rpcId mismatch')
})
})
describe('envelope observation', () => {
it('batches envelopes per microtask and isolates a throwing listener', async () => {
const c = client()
const batches: (readonly RpcMessage[])[] = []
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') })
const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) })
await c.sessions.list({})
await new Promise((resolve) => { setTimeout(resolve, 0) })
// request and response tap in separate microtask windows (the await between
// them yields), so both arrive but batch count is timing-defined
expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response'])
expect(errorSpy).toHaveBeenCalled()
unsubscribe()
unsubscribeThrowing()
errorSpy.mockRestore()
})
it('skips buffering entirely with no listeners and after unsubscribe', async () => {
const c = client()
const seen: RpcMessage[] = []
const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) })
unsubscribe()
await c.sessions.list({})
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(seen).toHaveLength(0)
})
it('coalesces multiple calls in one microtask window into one flush', async () => {
const c = client()
const batches: (readonly RpcMessage[])[] = []
c.subscribeEnvelopes((batch) => { batches.push(batch) })
await Promise.all([c.sessions.list({}), c.host.describe({})])
await new Promise((resolve) => { setTimeout(resolve, 0) })
const total = batches.reduce((n, batch) => n + batch.length, 0)
expect(total).toBe(4)
})
})
describe('resolveBase', () => {
it('prefers a real location.origin and falls back to the internal authority', async () => {
class Probe extends AbstractApiClient {
urls: string[] = []
protected async doFetch(input: URL): Promise<Response> {
this.urls.push(input.href)
return Response.json({ type: 'server-response', rpcId: this.lastMinted, result: { ok: true, value: { items: [] } } })
}
lastMinted = ''
protected override mintRpcId(): ReturnType<AbstractApiClient['mintRpcId']> {
const id = super.mintRpcId()
this.lastMinted = id
return id
}
}
const probe = new Probe()
await probe.sessions.list({})
expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
const globalWithLocation = globalThis as { location?: { origin?: string } }
globalWithLocation.location = { origin: 'http://host.example' }
try {
const probe2 = new Probe()
await probe2.sessions.list({})
expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//)
globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape
const probe3 = new Probe()
await probe3.sessions.list({})
expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
} finally {
delete globalWithLocation.location
}
})
})

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import { RpcId } from '../src/api/rpc.ts'
import {
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
} from '../src/api/rpc.schema.ts'
import { z } from 'zod'
import {
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
sessionPromptValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
describe('RpcId', () => {
it('brands a raw string at zero runtime cost', () => {
expect(RpcId('abc')).toBe('abc')
expect(rpcIdSchema.parse('abc')).toBe('abc')
// No min-length: the id is an opaque echo token (see rpcIdSchema's contract).
expect(rpcIdSchema.parse('')).toBe('')
expect(() => rpcIdSchema.parse(42)).toThrow()
})
})
describe('rpcErrorSchema', () => {
it('accepts every code branch with its required details', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})
})
describe('rpcResultSchema', () => {
it('accepts both result branches and rejects hybrids', () => {
const schema = rpcResultSchema(z.object({ n: z.number() }))
expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(err).toMatchObject({ ok: false })
expect(() => schema.parse({ ok: true, error: {} })).toThrow()
})
})
describe('wire full-form schemas', () => {
it('parses the four quadrants and the union discriminates on type', () => {
const cq = { type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }
const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
const rq = { type: 'server-request', rpcId: 'r2', method: 'session/event', payload: { a: 1 } }
const cr = { type: 'client-response', rpcId: 'r2', result: { ok: true, value: null } }
expect(clientRequestSchema.parse(cq).method).toBe('session.list')
expect(serverResponseSchema.parse(sr).rpcId).toBe('r1')
expect(serverRequestSchema.parse(rq).method).toBe('session/event')
expect(clientResponseSchema.parse(cr).rpcId).toBe('r2')
for (const message of [cq, sr, rq, cr]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
})
it('rejects a quadrant missing its members', () => {
expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow()
})
})
describe('rpcReceiptSchema', () => {
it('accepts both receipt branches with the closed reason set', () => {
expect(rpcReceiptSchema.parse({ accepted: true })).toEqual({ accepted: true })
expect(rpcReceiptSchema.parse({ accepted: false, reason: 'not-pending' })).toEqual({ accepted: false, reason: 'not-pending' })
expect(rpcReceiptSchema.parse({ accepted: false, reason: 'bad-response' })).toEqual({ accepted: false, reason: 'bad-response' })
expect(() => rpcReceiptSchema.parse({ accepted: false, reason: 'other' })).toThrow()
})
})
describe('sessions domain schemas', () => {
it('validates ids, summaries, and the event passthrough envelope', () => {
expect(sessionIdSchema.parse('s1')).toBe('s1')
expect(() => sessionIdSchema.parse('')).toThrow()
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toMatchObject({ sessionId: 's1' })
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
const event = sessionEventSchema.parse({ type: 'user/message', seq: 0, time: 1, data: { any: true } })
expect(event).toMatchObject({ type: 'user/message' })
expect(() => sessionEventSchema.parse({ type: 'user/message', seq: -1, time: 1, data: {} })).toThrow()
})
it('validates the per-method request/value pairs', () => {
expect(sessionListRequestSchema.parse({})).toEqual({})
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
})
})
describe('host domain schemas', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
expect(value.attachedSessions).toBe(2)
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
})
})
describe('events frame schemas', () => {
it('accepts every mux frame branch', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
})
it('accepts every host frame branch', () => {
const frames = [
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },
{ type: 'host/session-added', sessionId: 's' },
{ type: 'host/session-removed', sessionId: 's' },
{ type: 'host/session-status', sessionId: 's', running: true },
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
})
})
describe('respond payload schemas', () => {
it('validates approval and question answer payloads', () => {
expect(approvalRequestIdSchema.parse('a1')).toBe('a1')
const approval = approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'rejected' })
expect(approval.outcome).toBe('rejected')
expect(() => approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'cancelled' })).toThrow()
const answer = askUserQuestionAnswerSchema.parse({ answers: [{ id: 'q', selected: ['x'], custom: 'c' }] })
expect(answer.answers[0]?.selected).toEqual(['x'])
const payload = questionResponsePayloadSchema.parse({ sessionId: 's', answer: { answers: [] } })
expect(payload.sessionId).toBe('s')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
## Configuration
| Key | Default | Contract |
|---|---:|---|
| `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
## ApiProxy implementation notes
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
## Model Experience
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents.
#### KV Cache effect
No direct invalidation; the mounted model-facing plugins own their request-prefix changes.
## Known Limitations and Deferred Work
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version.
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

View File

@@ -0,0 +1,82 @@
{
"name": "@deepseek-ai/dsh-host-runtime",
"description": "Host runtime assembly for dsh: bootHost composes the core spine, createApiProxy implements the contract, startHost is the one-step shell seam",
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.0.1"
},
"devDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}

View File

@@ -0,0 +1,429 @@
/**
* Host-side ApiProxy implementation (minimal-first —
* describe/list/create/history/prompt/cancel and both streams are real,
* respond is a stub). Signature discipline: unary takes the narrow
* RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Surface message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
/**
* Message-boundary pagination: count maxMessages surface messages backwards from
* the window tail; the cut is the starting seq of the oldest message group
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
* naturally includes the in-progress partial.
*/
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number,
): { events: SessionEvent[]; hasMore: boolean } {
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
let count = 0
let cut = 0
for (let i = window.length - 1; i >= 0; i--) {
const event = window[i] as SessionEvent
if (!MESSAGE_TYPES.has(event.type)) continue
count++
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
if (count >= maxMessages) {
cut = groupStart
break
}
}
const page = window.filter(event => event.seq >= cut)
return { events: page, hasMore: cut > 0 }
}
/** Wrap an ok result echoing the request's rpcId. */
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: true, value } }
}
/** Wrap an error result echoing the request's rpcId. */
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
class FrameQueue<F> {
private buffer: F[] = []
private waiter: (() => void) | undefined
private done = false
push(item: F): void {
if (this.done) return
this.buffer.push(item)
this.waiter?.()
}
end(): void {
this.done = true
this.waiter?.()
}
async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
const onAbort = (): void => { this.end() }
signal.addEventListener('abort', onAbort, { once: true })
try {
while (true) {
while (this.buffer.length > 0) yield this.buffer.shift() as F
if (this.done || signal.aborted) return
await new Promise<void>((resolve) => { this.waiter = resolve })
this.waiter = undefined
}
} finally {
signal.removeEventListener('abort', onAbort)
cleanup()
}
}
}
/**
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
* for answerable frames belong to the approval/question registry, absent in
* this minimal version).
*/
function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
running,
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}
}
/**
* SessionSummary projection for cold (persisted, unattached) sessions.
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
*/
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
}
return {
sessionId: meta.id,
updatedAt,
running: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
}
}
/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
export interface ApiProxyDefaults {
provider: string
model: string
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
}
/** The tool/call payload fields the presenter path reads. */
interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: unknown }
/**
* Compute the render intent for a tool/call or tool/result event through the
* presenters registered at this moment; every other event type gets none. A
* result's presenter needs its call's parsed args — `argsFor` supplies them
* (live: the per-session call table; history: an in-page backscan), returning
* undefined when the pairing is unavailable (e.g. the call fell off the page),
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
* the client's documented default (generic JSON card) covers every miss.
*/
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
try {
if (event.type === 'tool/call') {
const { name, arguments: raw } = event.data as ToolCallData
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const { callId, content, isError, meta } = event.data as ToolResultData
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
return view === undefined ? undefined : { for: 'result', view }
}
} catch (error: unknown) {
// A throwing presenter (or unparseable arguments) must not break delivery;
// the event still ships, just without a view.
console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
}
return undefined
}
/**
* Resolve a tool/result's call pairing by scanning a window of events backwards
* for the matching tool/call. Used by the history path (the page is the
* window — a cross-page pairing soft-falls to no view) and by live-path table
* misses after a reconnect-eviction.
*/
function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i] as SessionEvent
if (event.type !== 'tool/call') continue
const data = event.data as ToolCallData
if (data.callId !== callId) continue
try {
return { name: data.name, args: JSON.parse(data.arguments) }
} catch {
// Unparseable stored arguments: same soft-fall as a live parse failure.
return undefined
}
}
return undefined
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
*/
class SessionNotFound extends Error {}
/**
* Implement ApiProxy over the ctx composed by bootHost.
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
* @param defaults - host-level default provider/model: injected as
* agentOptions on create/resume, reported by describe from the same source.
* @returns the ApiProxy implementation (minimal-first; stubs noted per method).
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
* not-found before any resume is attempted. With the gate passed, a later
* resume failure is genuinely internal. No persistence configured skips the
* gate — resume itself then fails loud with its own diagnostic.
*/
async function assertServable(sessionId: SessionId): Promise<void> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) return
const meta = (await persistence.list()).find(m => m.id === sessionId)
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const live = ctx.agents.get(sessionId)
if (live !== undefined) return { agent: live }
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
await assertServable(sessionId)
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
return handle.agent
} finally {
resumes.delete(sessionId)
}
})()
resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
// The internal details slot is contractually {}; the reason rides the message.
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
}
}
return {
sessions: {
// Attached sessions summarize from memory; persisted-but-unattached (cold)
// sessions merge in from the persistence store so history survives restarts.
// Legacy logs without a cwd (pre-project stance) are not served — every
// session now records its project at create time.
async list(request) {
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
return summarize(session, agent?.status === 'running')
})
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return ok(request, { items })
},
async create(request) {
const sessionId = `session-${randomUUID()}` as SessionId
// A session's cwd is its project path. When the creator does not choose
// one, the default project is the host-level default (the host process
// working directory unless boot overrides it).
const cwd = request.payload.cwd ?? defaults.cwd
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
return ok(request, { sessionId: handle.agent.id })
},
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
// a call and its result on one page — a cross-page miss soft-falls).
const entries: HistoryEntry[] = page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (mode === 'steer') agent.steer(content, { source })
else agent.send(content, { source })
} catch (error: unknown) {
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
}
return ok(request, { accepted: true as const })
},
cancel(request) {
const { sessionId } = request.payload
const agent = ctx.agents.get(sessionId)
if (agent === undefined) {
return Promise.resolve(err(request, {
code: 'session-not-found',
message: `session "${sessionId}" not found (not attached)`,
details: { sessionId },
}))
}
agent.cancel()
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
host: {
describe(request) {
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
return Promise.resolve(ok(request, {
version: '0.0.1',
cwd: process.cwd(),
provider: defaults.provider,
model: defaults.model,
attachedSessions: ctx.agents.list().length,
}))
},
},
events: {
mux(_request, signal) {
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
for (const session of ctx.sessions.list()) {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
// opened mid-turn) backscans the session's in-memory events instead.
const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
const disposers = [
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type === 'tool/call') {
const data = event.data as ToolCallData
try {
let table = openCalls.get(session.id)
if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
} catch {
// Unparseable model arguments: leave the table unset; the result view soft-falls.
}
} else if (event.type === 'turn/end') {
openCalls.delete(session.id)
}
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
}),
ctx.on('session/created', (session: Session) => {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
},
host(_request, signal) {
const queue = new FrameQueue<RpcRequest<HostFrame>>()
const disposers = [
ctx.on('session/created', (session: Session) => {
queue.push(frame({
type: 'host/session-added',
sessionId: session.id,
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
}))
}),
ctx.on('session/disposed', (session: Session) => {
queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
}),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
if (status === 'disposed') return
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
}),
]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
},
},
// TODO(step2): approval/question pending registry (wire answerer + proxy provider).
respond(_message: ClientResponse): Promise<RpcReceipt> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
},
}
}

View File

@@ -0,0 +1,133 @@
/**
* Core spine composition for the dsh host: mounts the harness core plugins
* one by one (each awaited so a load failure surfaces deterministically at
* boot, unlike bundle plugins whose children mount unawaited).
*/
import { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import TaskService from '@deepseek-ai/dsh-tasks'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as toolTodo from '@deepseek-ai/dsh-tool-todo'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as fsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as toolFs from '@deepseek-ai/dsh-tool-fs'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import CompactBasic from '@deepseek-ai/dsh-compact-basic'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import * as toolSubagent from '@deepseek-ai/dsh-tool-subagent'
import WorkflowWorkerthread from '@deepseek-ai/dsh-workflow-workerthread'
import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import SpillLocal from '@deepseek-ai/dsh-spill-local'
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
/** Options for bootHost — the assembly-layer composition knobs. */
export interface BootHostOptions {
/** Root directory for JSONL session persistence. */
persistenceRoot: string
/** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */
provider?: string
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
model?: string
/**
* Default project directory for sessions created without an explicit cwd
* (defaults to the host process working directory). A session's cwd is its
* project path — a per-session choice, not a host property; this option only
* supplies the value used when the creator does not choose one.
*/
cwd?: string
}
/** Host-level default agent routing: the single source injected on create and reported by host.describe. */
export interface HostDefaults {
provider: string
model: string
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
}
/** Booted host handle: composed root context + resolved defaults + disposer. */
export interface HostHandle {
/** Root context with the full plugin assembly mounted. */
ctx: Context
/** Resolved default agent routing (options ?? built-in fallbacks). */
defaults: HostDefaults
/** Tear down the whole plugin tree. */
dispose(): Promise<void>
}
/**
* Compose the harness host plugin assembly (the one place deciding which plugins mount and
* with what defaults — shells must not alter the assembly).
* @param options - persistence root and optional default provider/model.
* @returns the booted handle (ctx + defaults + dispose).
*/
export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
const defaults: HostDefaults = {
provider: options.provider ?? 'deepseek',
model: options.model ?? 'deepseek-v4-flash',
cwd: options.cwd ?? process.cwd(),
}
const ctx = new Context()
await ctx.plugin(Timer)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, {})
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot, compression: 'none' })
await ctx.plugin(LocalBashExecutor, {})
// Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml +
// the agent-spine bundle) so web sessions get the same coding-agent tool
// face; deviations are noted inline.
await ctx.plugin(toolBash, {})
await ctx.plugin(toolTodo)
await ctx.plugin(toolTasks, {})
// fs paths resolve against the host default project rather than the raw
// process cwd — the same source create() injects into session.cwd.
await ctx.plugin(FsLocal, { cwd: defaults.cwd })
await ctx.plugin(fsPolicy)
await ctx.plugin(toolFs, {})
await ctx.plugin(toolFsSearch, {})
// Skill stack with the demo default dshHome (~/.dsh via resolveDshHome).
await ctx.plugin(SkillService, {})
await ctx.plugin(SkillLocal, {})
await ctx.plugin(toolSkill, {})
// Request pressure + compaction (service-wide defaults, as in repl-agent).
await ctx.plugin(TokenMeter)
await ctx.plugin(CompactBasic)
// Subagent spawn/fork backends and their two model-facing tool instances.
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
await ctx.plugin(toolSubagent, { provider: 'spawn', toolName: 'subagent' })
await ctx.plugin(toolSubagent, { provider: 'fork', toolName: 'subagent_fork' })
await ctx.plugin(WorkflowWorkerthread, { provider: 'spawn' })
await ctx.plugin(toolWorkflow, {})
// Declared per-tool timeouts become enforced deadlines.
await ctx.plugin(timeoutPolicy)
// Oversized tool output spills to session-scoped files (repl-agent budget).
await ctx.plugin(SpillLocal, {})
await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
}

View File

@@ -0,0 +1,14 @@
/**
* @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
* composition (bootHost), the ApiProxy implementation (createApiProxy), and
* the one-step shell seam (startHost). Host-level configuration (defaults,
* persistenceRoot, future user profile) lives here.
*/
export { bootHost } from './boot.ts'
export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
export { startHost } from './start.ts'
export type { StartHostOptions, RunningHost } from './start.ts'
export { mountWebPlugins, WEB_UI_PLUGINS } from './web-plugins.ts'

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-host-runtime`.
* @module @deepseek-ai/dsh-host-runtime/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-runtime'
/** Cordis companion plugin name. */
export const name = 'host-runtime-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this assembly layer only composes plugins owned
* elsewhere; the event/data relations it touches (session events, agent
* lifecycle, wire frames) are asserted by their owning packages' companions.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,58 @@
/**
* One-step host startup seam: boot core → assemble ApiProxy → assemble the
* fetch handler. The returned RunningHost is shell-agnostic — node:http
* (dsh web), in-process injection (dsh -p, tests), an IPC bridge (future
* Electron sidecar), and front-door plugin mounting (future dsh acp) all
* consume the same shape.
*/
import type { Context } from 'cordis'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { bootHost } from './boot.ts'
import type { BootHostOptions, HostDefaults } from './boot.ts'
import { createApiProxy } from './api-proxy.ts'
/** Options for startHost. */
export interface StartHostOptions {
/**
* Passed through to bootHost verbatim (persistenceRoot required +
* provider?/model?). Future host-level knobs (profile, log sink — any
* output added to the assembly MUST be switchable off here) land as
* additive fields.
*/
boot: BootHostOptions
}
/** Running host handle: the contract impl plus its fetch carrier and root ctx. */
export interface RunningHost {
/** Contract implementation (direct calls for in-process consumers; the input of an IPC adapter). */
api: ApiProxy
/** WHATWG-fetch-shaped carrier (web shell bridges it to node:http; host-side endpoint of an IPC bridge). */
handler: { fetch: typeof fetch }
/** Host-level default routing (describe and every shell share this single source). */
defaults: HostDefaults
/**
* Root context — a formal seam, not an escape hatch: (1) the mount point for
* protocol front-door plugins (`dsh acp` = startHost() → ctx.plugin(uiAcp, config));
* (2) headless session-event subscription. Discipline: consuming clients must
* not bypass `api` through ctx; shells must not ctx.plugin to alter the
* assembly (mounting a front door is the shell's own shape, not an assembly change).
*/
ctx: Context
/** Single shutdown exit (ctx.fiber.dispose()). Idempotent: a second call returns the same promise. */
dispose(): Promise<void>
}
/**
* Boot the host and assemble its consumption surfaces in one step.
* @param options - boot passthrough (see StartHostOptions).
* @returns the running host handle shared by every shell shape.
*/
export async function startHost(options: StartHostOptions): Promise<RunningHost> {
const host = await bootHost(options.boot)
const api = createApiProxy(host.ctx, host.defaults)
const handler = toFetchHandler(api)
let disposing: Promise<void> | undefined
return { api, handler, defaults: host.defaults, ctx: host.ctx, dispose: () => (disposing ??= host.dispose()) }
}

View File

@@ -0,0 +1,63 @@
/**
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
* entry tree listing the eight UI plugin packages (the P-I config-source bar —
* a cordis.yml file form comes later; install/remove currently means editing
* this list and restarting). The web plugin registry discovers the entries by
* their package.json dshClient declarations; node halves are empty applies,
* so mounting them here costs nothing beyond Loader governance.
*/
import { createRequire } from 'node:module'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/** The eight UI plugin packages served to the browser (order = manifest order). */
export const WEB_UI_PLUGINS = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
export interface MountedWebPlugins {
/** Entry enumeration surface of the mounted Loader (registry scan source). */
loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> }
/** Resolve a plugin package's package.json absolute path. */
resolvePkgJson: (name: string) => string
}
/**
* Mount the Loader (when absent) and create one in-memory entry per UI
* plugin, then wait for the tree to settle. A plugin whose import fails
* leaves its entry fiber-less — surfaced here as a loud throw listing the
* failures (misconfiguration must not silently drop a UI plugin).
* @param ctx - host root context (bootHost product).
* @returns the loader view and package.json resolver the registry consumes.
*/
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
// import silently fails and every entry stays fiber-less. This package
// depends on all eight UI plugins, so its own URL is the right anchor.
ctx.baseUrl ??= import.meta.url
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
for (const name of WEB_UI_PLUGINS) {
if (!existing.has(name)) await ctx.loader.create({ name })
}
await ctx.loader.await()
const dead = [...ctx.loader.entries()]
.filter(entry => (WEB_UI_PLUGINS as readonly string[]).includes(entry.options.name))
.filter(entry => entry.fiber === undefined && !entry.disabled)
if (dead.length > 0) {
throw new Error(`web-plugins: UI plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
}
const require = createRequire(import.meta.url)
return {
loader: ctx.loader,
resolvePkgJson: name => require.resolve(`${name}/package.json`),
}
}

View File

@@ -0,0 +1,94 @@
/**
* Cold-session and degenerate-composition paths of the host ApiProxy:
* sessions.list merging persisted-but-unattached summaries (mtime source,
* createdAt fallbacks, lineage projection) and the resume error split when
* the composition has no persistence gate and no agent factory.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
}
describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent') }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
// locate: a real per-session file (mtime wins), a backend without one
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
// → createdAt).
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return undefined
},
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const items = response.result.value.items
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
const [a, b, c] = items
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
expect(a?.running).toBe(false)
expect(a?.cwd).toBe('/proj')
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(c?.updatedAt).toBe(1500)
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
// No persistence → the servable gate passes silently; the factory-less
// registry then rejects resume, which is NOT a SessionNotFound.
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
}
})
})

View File

@@ -0,0 +1,179 @@
/**
* Tool-card view computation over the mux live path: three standard card types
* arrive on the frame, a presenterless tool ships no view field, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing works
* both through the live open-call table and the backscan fallback after
* turn/end cleared it.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
return {
name,
description: `tool ${name}`,
parameters: { type: 'object', properties: {} },
execute: () => reply(`ran:${name}`),
...presenters,
}
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
}))
ctx.tools.register(tool('term', {
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
presentResult: () => ({ card: 'terminal', output: 'done' }),
}))
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
}))
return { ctx }
}
/** Drain frames from an open mux stream until `count` session/event frames arrived. */
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
const frames: MuxFrame[] = []
for await (const frame of iterable) {
frames.push(frame.payload)
if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
}
return frames
}
describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 7, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'session/event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.
expect(byCall.get('tool/call:c-boom')).toBeDefined()
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
// Result pairing through the live table: presentResult saw the call's args.
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
})
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-term'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { n: 1 } }, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-orphan'), content: [{ type: 'text', text: 'x' }], isError: false }, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-bad'), content: [{ type: 'text', text: 'y' }], isError: false }, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-plain'), content: [{ type: 'text', text: 'z' }], isError: false }, { surfaceOp: 'append' })
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const entries = response.result.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
let session: Session | undefined
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('session-doomed' as SessionId)
}, { inject: ['sessions'] }))
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
// Disposing the owning fiber detaches the session mid-stream; the
// session/disposed listener must clear its open-call table entry.
await fiber.dispose()
const frames = await collect(stream, 2, abort)
const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
expect(call?.type === 'session/event' && call.view?.for).toBe('call')
})
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The turn/end above cleared the live table; pairing must fall back to
// scanning the session's in-memory events.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
})
})

View File

@@ -0,0 +1,367 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts'
/** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
class ScriptedAdapter extends LlmAdapter {
constructor(private script: (StreamChunk[] | 'hang')[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const entry = this.script.shift()
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
if (entry === 'hang') {
yield { type: 'block-start', index: 0, blockType: 'text' }
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
yield * entry
}
}
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
}
let nextRpc = 1
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function expectOk<T>(response: RpcResponse<T>): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
let host: RunningHost | undefined
beforeEach(() => {
vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
})
afterEach(async () => {
await host?.dispose()
host = undefined
vi.unstubAllEnvs()
})
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
host = await startHost({
boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' },
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
return host
}
describe('bootHost / startHost', () => {
it('falls back to the deepseek defaults and disposes idempotently', async () => {
const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')) })
expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
expect(typeof handle.defaults.cwd).toBe('string')
await handle.dispose()
})
it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
const running = await boot()
expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
expect(parsed.result.value.provider).toBe('scripted')
const first = running.dispose()
expect(running.dispose()).toBe(first)
await first
host = undefined
})
})
describe('host.describe', () => {
it('reports version, cwd, defaults, and the attached count', async () => {
const { api } = await boot()
const value = expectOk(await api.host.describe(request({})))
expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
})
})
describe('sessions.create / list', () => {
it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
const { api } = await boot()
const created = await api.sessions.create(request({ cwd: '/tmp' }))
const { sessionId } = expectOk(created)
expect(created.rpcId).toMatch(/^req-/)
const second = expectOk(await api.sessions.create(request({}))).sessionId
const { items } = expectOk(await api.sessions.list(request({})))
expect(items.map(item => item.sessionId)).toContain(sessionId)
expect(items.map(item => item.sessionId)).toContain(second)
const first = items.find(item => item.sessionId === sessionId)
expect(first?.cwd).toBe('/tmp')
expect(first?.running).toBe(false)
expect(first?.parentSessionId).toBeUndefined()
})
})
describe('sessions.prompt / cancel', () => {
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
const running = await boot([textResponse('pong')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId)
expect(agent).toBeDefined()
const idle = waitForIdle(ctx, agent as Agent)
const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
expectOk(await api.sessions.prompt(promptRequest))
await idle
const value = expectOk(await api.sessions.history(request({ sessionId })))
const events = value.events.map(entry => entry.event)
const userEvent = events.find(event => event.type === 'user/message') as
| { data: { source?: { rpcId?: string } } } | undefined
expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
const reply = events.find(event => event.type === 'assistant/message')
expect(reply).toBeDefined()
})
it('steer on an idle agent falls through to send', async () => {
const running = await boot([textResponse('steered')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
await idle
})
it('errors session-not-found on a ghost session', async () => {
const { api } = await boot()
const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('maps a synchronous send throw to agent-busy', async () => {
const { api } = await boot()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
})
it('cancels an attached agent and rejects an unattached one', async () => {
const running = await boot(['hang'])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
agent.send([{ type: 'text', text: 'run forever' }])
expectOk(await api.sessions.cancel(request({ sessionId })))
const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
expect(missing.result.ok).toBe(false)
if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
})
})
describe('sessions.history', () => {
it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
const first = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
const { sessionId } = expectOk(await first.api.sessions.create(request({})))
const agent = first.ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }])
await idle
await first.dispose()
host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
const [a, b] = await Promise.all([
host.api.sessions.history(request({ sessionId })),
host.api.sessions.history(request({ sessionId })),
])
for (const response of [a, b]) {
const value = expectOk(response)
expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
}
expect(host.ctx.agents.get(sessionId)).toBeDefined()
expect(host.ctx.agents.list()).toHaveLength(1)
})
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
const { api } = await boot()
const ghost = 'session-ghost' as SessionId
const [first, second] = await Promise.all([
api.sessions.history(request({ sessionId: ghost })),
api.sessions.history(request({ sessionId: ghost })),
])
for (const response of [first, second]) {
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
}
})
it('paginates backwards on message boundaries with hasMore', async () => {
const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
for (const text of ['q1', 'q2', 'q3']) {
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text }])
await idle
}
const all = expectOk(await api.sessions.history(request({ sessionId })))
expect(all.hasMore).toBe(false)
const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
expect(messageCount).toBe(6)
const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
expect(lastPage.hasMore).toBe(true)
expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
const firstSeq = lastPage.events[0]?.event.seq as number
const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
expect(olderPage.hasMore).toBe(true)
expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
})
})
describe('events streams', () => {
it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
const running = await boot()
const { api } = running
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
// no sessions yet: next() must pend on the queue's waiter, not the buffer
const pending = stream.next()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const frame = (await pending).value as RpcRequest<MuxFrame>
expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
ac.abort()
expect((await stream.next()).done).toBe(true)
})
it('lists fork lineage and announces it on the host stream', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
const ac = new AbortController()
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
const child = `session-child-${String(Date.now())}` as SessionId
const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
expect(handle.agent.id).toBe(child)
const added = (await stream.next()).value as RpcRequest<HostFrame>
expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
const { items } = expectOk(await api.sessions.list(request({})))
expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
await handle.dispose()
let frame: RpcRequest<HostFrame>
do frame = (await stream.next()).value as RpcRequest<HostFrame>
while (frame.payload.type !== 'host/session-removed')
expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
ac.abort()
})
it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
const running = await boot([textResponse('live')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
const baseline = await stream.next()
expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'go' }])
await idle
const live = await stream.next()
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
const other = expectOk(await api.sessions.create(request({}))).sessionId
let frame: RpcRequest<MuxFrame>
do frame = (await stream.next()).value as RpcRequest<MuxFrame>
while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
ac.abort()
expect((await stream.next()).done).toBe(true)
})
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
const running = await boot([textResponse('x')])
const { api, ctx } = running
const ac = new AbortController()
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const added = await stream.next()
expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'run' }])
await idle
const runningFrame = await stream.next()
expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
const idleFrame = await stream.next()
expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
// Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
// enforces; dispatch the way the loop does.
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
const errorFrame = await stream.next()
expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
ac.abort()
// Push-after-done: an event landing between abort and generator wind-down
// must be dropped silently, not crash the queue.
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
expect((await stream.next()).done).toBe(true)
})
})
describe('respond stub', () => {
it('always reports not-pending (step2 registry pending)', async () => {
const { api } = await boot()
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
})
})

View File

@@ -0,0 +1,71 @@
/**
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
* packages (node halves), and the webserver registry built over it yields the
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
*
* The Loader imports plugin packages through their exports maps (lib/), so
* this is a built-artifact e2e: it skips until the workspace build has run
* (`pnpm run build`), like the other built-* e2e suites.
*/
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
const nodeRequire = createRequire(import.meta.url)
const built = WEB_UI_PLUGINS.every((name) => {
try {
return existsSync(nodeRequire.resolve(name))
} catch {
return false
}
})
let root: Context | undefined
afterEach(async () => {
await root?.fiber.dispose()
root = undefined
})
describe.skipIf(!built)('mountWebPlugins + registry', () => {
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
root = new Context()
const mounted = await mountWebPlugins(root)
const registry = createHostWebPluginRegistry({
ctx: root,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err) => { throw err },
})
const rows = registry.snapshot()
expect(rows.map(r => r.id)).toEqual([...WEB_UI_PLUGINS])
// The infra four are the early-load group; the UI four are not.
const immediate = rows.filter(r => r.immediately === true).map(r => r.id)
expect(immediate).toEqual([
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
])
// Every row resolves a client path under its own package dist/.
for (const row of rows) {
expect(registry.clientPath(row.id)).toMatch(/dist[/\\]client\.js$/)
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
}
registry.dispose()
})
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
root = new Context()
await mountWebPlugins(root)
const second = await mountWebPlugins(root)
// ctx.loader hands out a fresh traced proxy per access, so loader identity
// is not assertable; the observable contract is a single entry per package.
const names = [...second.loader.entries()].map(e => e.options.name)
.filter(n => (WEB_UI_PLUGINS as readonly string[]).includes(n))
expect(names.length).toBe(WEB_UI_PLUGINS.length)
})
})

View File

@@ -0,0 +1,114 @@
/**
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
* resolver seam — is exercised against a stubbed loader service so it runs
* without built lib/ artifacts.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
interface FakeEntry {
options: { name: string }
fiber?: unknown
disabled: boolean
}
/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
class FakeLoader {
readonly created: string[] = []
awaited = 0
constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
entries(): Iterable<FakeEntry> {
return this.entriesList
}
async create(options: { name: string }): Promise<void> {
this.created.push(options.name)
this.onCreate?.(options.name)
}
async await(): Promise<void> {
this.awaited += 1
}
}
let root: Context | undefined
afterEach(async () => {
await root?.fiber.dispose()
root = undefined
})
function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
root = new Context()
const loader = new FakeLoader(entriesList, onCreate)
root.reflect.provide('loader', loader)
return { ctx: root, loader }
}
describe('mountWebPlugins (stubbed loader)', () => {
it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => {
const entriesList: FakeEntry[] = []
const { ctx, loader } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
const mounted = await mountWebPlugins(ctx)
expect(loader.created).toEqual([...WEB_UI_PLUGINS])
expect(loader.awaited).toBe(1)
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS])
// The resolver resolves this package's own manifest through real module resolution.
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
expect(ctx.baseUrl).toBeDefined()
})
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false }))
const { ctx, loader } = withLoader(preexisting)
await mountWebPlugins(ctx)
expect(loader.created).toEqual([])
})
it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
// First two load; the rest stay fiber-less (import failed silently).
entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false })
})
await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/)
})
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
const { ctx } = withLoader(entriesList)
await expect(mountWebPlugins(ctx)).resolves.toBeDefined()
})
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
root = new Context()
// Environment-dependent outcome: with built lib/ the eight imports load
// and the mount resolves; without them every entry stays fiber-less and
// the sweep throws its loud list. Either way the branch under test is the
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
// expect()'s formatting path (pretty-format probes throw on them).
// Plain string: the success sentinel and error text share one channel.
let outcome: string
try {
await mountWebPlugins(root)
outcome = 'resolved'
} catch (error) {
outcome = error instanceof Error ? error.message : String(error)
}
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
expect(root.get('loader') !== undefined).toBe(true)
}, 30_000) // built-env run imports eight real plugin packages through the Loader
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
ctx.baseUrl = 'file:///caller/anchor/'
await mountWebPlugins(ctx)
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
})
})

View File

@@ -0,0 +1,144 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/timer"
},
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-deepseek"
},
{
"path": "../../core/session"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
},
{
"path": "../../tasks/tasks"
},
{
"path": "../../core/agent-loop"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../bash/bash-local"
},
{
"path": "../../bash/tool-bash"
},
{
"path": "../../compact/compact-basic"
},
{
"path": "../../fs/fs-local"
},
{
"path": "../../fs/fs-policy"
},
{
"path": "../../fs/tool-fs"
},
{
"path": "../../fs/tool-fs-search"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../skill/skill"
},
{
"path": "../../skill/skill-local"
},
{
"path": "../../skill/tool-skill"
},
{
"path": "../../spill/spill-local"
},
{
"path": "../../spill/spill-policy"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../subagent/subagent-fork"
},
{
"path": "../../subagent/subagent-spawn"
},
{
"path": "../../subagent/tool-subagent"
},
{
"path": "../../support/invariants"
},
{
"path": "../../tasks/tool-tasks"
},
{
"path": "../../timeout/timeout-policy"
},
{
"path": "../../todo/tool-todo"
},
{
"path": "../../workflow/tool-workflow"
},
{
"path": "../../workflow/workflow-workerthread"
},
{
"path": "../apiproxy"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../client/connection"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-theme"
},
{
"path": "../../client/i18n"
},
{
"path": "../../client/ui-layout"
},
{
"path": "../../client/ui-sidebar"
},
{
"path": "../../client/ui-conversation"
},
{
"path": "../../client/ui-trajectory"
}
]
}

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-host-webserver
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection.
## Model Experience
None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No TLS, auth, or origin policy** — the server binds `0.0.0.0` and trusts its network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
- **`port` is the only listen knob** — bind address and socket options are fixed until a deployment needs them.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-host-webserver",
"description": "Web-shape HTTP carrier: static file serving plus the /api/* bridge to an injected fetch-shaped handler (SSE streamed through)",
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.0.1"
},
"devDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}

View File

@@ -0,0 +1,208 @@
/**
* @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server
* routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG
* bridge with SSE streamed out chunk by chunk) and everything else to static
* file serving. Web (browser) shape only — Electron loads dist over file://
* and carries fetch over an IPC bridge, not this server. This package never
* prints: the URL line belongs to the shell.
*/
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { readFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { serveStatic } from './static.ts'
import type { HostWebPluginRegistry } from './web-plugins.ts'
export { createHostWebPluginRegistry } from './web-plugins.ts'
export type {
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebPluginBootEntry, WebPluginRegistryDeps,
} from './web-plugins.ts'
/** Options for startWebServer. */
export interface WebServerOptions {
/** Port to listen on (0.0.0.0). */
port: number
/**
* Absolute path of index.html inside the static root — the caller resolves
* it (dist location is workspace knowledge of the shell, not this package's).
*/
distIndex: string
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
apiHandler: { fetch: typeof fetch }
/**
* Web plugin table. When present, every index.html response carries a
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
* each plugin's client bundle. Absent = both surfaces off (carrier-only use).
*/
webPlugins?: Pick<HostWebPluginRegistry, 'snapshot' | 'clientPath'>
}
/** Listening web server handle. */
export interface RunningWebServer {
/** The listening port (for the shell's URL line; equals options.port). */
port: number
/**
* Shutdown: close + closeAllConnections (SSE connections never end on their
* own; without the force-close, close() would hang). Idempotent.
*/
close(): Promise<void>
}
/**
* Start the web-shape HTTP server: listen(port, '0.0.0.0').
* Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
* static with the step1-locked semantics (403 traversal, SPA fallback 200).
* A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
* server error after listen goes to onError. A request whose handling throws
* (malformed %-escapes, a client dropping mid-body) is answered 400 — or the
* socket destroyed when headers are already out — and reported to onError;
* it never becomes an unhandled rejection.
* @param options - port, static root anchor, and the API carrier.
* @param onError - sink for post-listen server errors and per-request handling failures.
* @returns the running server handle once listening.
*/
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
const { port, distIndex, apiHandler, webPlugins } = options
const distRoot = dirname(distIndex)
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
const html = await readFile(distIndex, 'utf8')
return injectBootManifest(html, webPlugins.snapshot())
}
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
requests; the field is only optional on the client-side IncomingMessage type */
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
if (rawPath.startsWith('/api/')) {
await bridge(req, res, apiHandler)
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
return
}
await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
}
// Last-resort guard: handle() rejecting would otherwise be an unhandled
// rejection, and one malformed request (a bad %-escape hitting
// decodeURIComponent, a client dropping mid-body) would kill the whole
// process. Nothing after this catch can throw again on the same response.
const server = createServer((req, res) => {
handle(req, res).catch((err: unknown) => {
onError(err instanceof Error ? err : new Error(String(err)))
if (res.headersSent) {
res.destroy()
return
}
res.writeHead(400)
res.end()
})
})
let closing: Promise<void> | undefined
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
server.close(() => { resolveClose() })
server.closeAllConnections()
}))
return new Promise((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(port, '0.0.0.0', () => {
server.off('error', rejectListen)
server.on('error', onError)
resolveListen({ port, close })
})
})
}
/**
* Inject the boot manifest into index.html: `window.__DSH_BOOT__` as the first
* script in <head> (before the shell bundle reads it). `<` is escaped in the
* JSON so plugin-controlled strings cannot break out of the script element.
* @param html - the index.html source.
* @param plugins - the manifest rows from the registry snapshot.
* @returns the html with the manifest script injected.
*/
export function injectBootManifest(html: string, plugins: readonly unknown[]): string {
const json = JSON.stringify({ plugins }).replaceAll('<', '\\u003c')
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
return `${script}${html}`
}
/** Serve one plugin client bundle from the registry table (unknown id = 404; the id may contain a scope slash). */
async function servePluginBundle(
pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
): Promise<void> {
const id = pathname.slice('/plugins/'.length, -'/client.js'.length)
const path = webPlugins.clientPath(id)
if (path === undefined) {
res.writeHead(404)
res.end()
return
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
res.writeHead(404)
res.end()
}
}
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
// fully consumed (immediately for a bodyless GET), which would abort every SSE
// stream right after open. ServerResponse 'close' fires on connection teardown;
// writableEnded distinguishes a normal end() from the client going away.
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
method: req.method ?? 'GET',
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
const response = await apiHandler.fetch(request)
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
if (response.body === null) {
res.end()
return
}
for await (const chunk of response.body) {
// Backpressure: a false return means the socket buffer is full — wait for drain
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
// resolves so a mid-wait disconnect can't park this loop forever; the close
// handler above aborts the handler stream, which then ends the iteration.
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}

View File

@@ -0,0 +1,49 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-host-webserver`.
* @module @deepseek-ai/dsh-host-webserver/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-webserver'
/** Cordis companion plugin name. */
export const name = 'host-webserver-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']
/**
* Owned relation: the web plugin registry's boot manifest must stay
* self-consistent — every snapshot() row must resolve a clientPath under the
* same id (the /plugins/<id>/client.js URL it advertises would otherwise 404
* on a browser that just received the manifest). Checked synchronously on
* every rescan trigger (cordis 'internal/plugin'): snapshot() and
* clientPath() read the same table object, so the relation is
* self-consistent at any instant — no need to wait out the registry's own
* debounced rescan. The registry arrives through the context key the
* assembly publishes it under.
*/
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const registry = ctx.get('webPlugins') as
| { snapshot(): { id: string; url: string }[]; clientPath(id: string): string | undefined }
| undefined
if (registry === undefined) return // carrier-only deployments never publish the registry
for (const row of registry.snapshot()) {
if (registry.clientPath(row.id) === undefined) {
fail(`web plugin manifest row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
}
}
}, { global: true })
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,58 @@
/**
* Static file serving for the web shell: the starter MIME table and the
* request handler with the semantics locked by the step1 acceptance list —
* traversal outside the dist root is 403, any miss falls back to index.html
* with HTTP 200 (SPA routing), unknown extensions ship as octet-stream.
*/
import type { ServerResponse } from 'node:http'
import { extname, join, normalize, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.map': 'application/json',
}
/**
* Serve one GET/HEAD static request from the dist root.
* @param pathname - decoded URL pathname of the request.
* @param res - the node:http response to write.
* @param distRoot - absolute dist root directory (resolved by the caller).
* @param distIndex - absolute path of index.html inside distRoot.
* @param renderIndex - when set, produces the index.html body (boot-manifest
* injection) for `/` and every SPA fallback; undefined serves the file verbatim.
*/
export async function serveStatic(
pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
renderIndex?: () => Promise<string>,
): Promise<void> {
const target = resolve(normalize(join(distRoot, pathname)))
// Traversal rejection: the target must be distRoot itself (`/`) or stay under it.
if (target !== distRoot && !target.startsWith(distRoot + '/')) {
res.writeHead(403)
res.end()
return
}
const serveIndex = async (): Promise<void> => {
const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex()
res.writeHead(200, { 'content-type': MIME['.html'] })
res.end(body)
}
if (target === distRoot || target === distIndex) {
await serveIndex()
return
}
try {
const body = await readFile(target)
res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
res.end(body)
} catch {
// Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
await serveIndex()
}
}

View File

@@ -0,0 +1,184 @@
/**
* HostWebPluginRegistry: discovers web-client plugins among the host Loader's
* loaded entries by their package.json `dshClient` declaration and resolves
* each one's client bundle path from `exports["./client"]`. The webserver
* consumes the table to emit `window.__DSH_BOOT__` and to serve
* `GET /plugins/<id>/client.js`. Discovery is declaration-only: plugin authors
* write package.json; no serve() call surface exists.
*
* The vendored loader emits no "entry loaded" event (only `loader/entry-init`,
* which fires at Entry construction before import/apply), so the registry
* scans `loader.entries()` and rescans on cordis `internal/plugin` (fiber
* create/dispose), microtask-debounced. Plugin-set changes take effect on
* restart per the config-source ruling; the subscription only keeps the table
* fresh within a process lifetime.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
/** One `window.__DSH_BOOT__.plugins` row (wire shape of api-contracts v3 §9.2). */
export interface WebPluginBootEntry {
/** Plugin id = package name (may contain a scope slash). */
id: string
/** Bundle URL served by this webserver (`/plugins/<id>/client.js`). */
url: string
/** Client-half load dependencies (plugin ids), topologically ordered by the client loader. */
inject: string[]
/** Marks the early-load group: fetched in parallel and applied before all other plugins. */
immediately?: boolean
}
/** The web plugin table consumed by the boot injection and the bundle endpoint. */
export interface HostWebPluginRegistry {
/** Current manifest rows (stable order: loader entry order). */
snapshot(): WebPluginBootEntry[]
/**
* Absolute path of a plugin's client bundle.
* @param id - plugin id (package name).
* @returns the path, or undefined for an unknown id.
*/
clientPath(id: string): string | undefined
/** Remove the loader subscription. */
dispose(): void
}
/** Structural view of a loader entry (webserver keeps zero workspace dependencies; cordis stays a type-only peer). */
export interface LoaderEntryView {
options: { name: string }
/** Present once the entry's plugin fiber exists (import succeeded and apply ran/started). */
fiber?: unknown
/** True when the entry or an owning group is disabled. */
disabled: boolean
}
/** Structural view of the host Loader (entry enumeration is all the registry needs). */
export interface LoaderView {
entries(): Iterable<LoaderEntryView>
}
/** Dependencies injected by the assembly layer. */
export interface WebPluginRegistryDeps {
/** Host root context; used only to subscribe `internal/plugin` for rescans. */
ctx: Context
/** The host Loader owning the plugin entries. */
loader: LoaderView
/**
* Resolve a package specifier to its package.json absolute path (assembly
* passes `createRequire(...).resolve(`${name}/package.json`)`); injected so
* the registry makes no module-resolution assumptions of its own.
*/
resolvePkgJson: (name: string) => string
/** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */
onError: (err: Error) => void
}
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
interface DshClientDeclaration {
inject?: string[]
platform: string
immediately?: boolean
}
interface WebPluginRecord {
entry: WebPluginBootEntry
clientPath: string
}
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
if (typeof value !== 'object' || value === null) {
throw new Error(`web-plugins: ${name} has a non-object dshClient declaration`)
}
const decl = value as Record<string, unknown>
if (typeof decl.platform !== 'string') {
throw new Error(`web-plugins: ${name} dshClient.platform must be a string`)
}
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
throw new Error(`web-plugins: ${name} dshClient.inject must be a string array`)
}
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
throw new Error(`web-plugins: ${name} dshClient.immediately must be a boolean`)
}
return {
platform: decl.platform,
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
}
}
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
function clientExportOf(name: string, exportsField: unknown): string | undefined {
if (typeof exportsField !== 'object' || exportsField === null) return undefined
const client = (exportsField as Record<string, unknown>)['./client']
if (client === undefined) return undefined
if (typeof client === 'string') return client
if (typeof client === 'object' && client !== null) {
const fallback = (client as Record<string, unknown>).default
if (typeof fallback === 'string') return fallback
}
throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`)
}
/**
* Build the web plugin registry: scan once synchronously (a malformed
* declaration throws here — load-time fail loud), then rescan on
* `internal/plugin`, microtask-debounced (failures go to `deps.onError`).
* @param deps - loader view, resolution hook, and error sink (see {@link WebPluginRegistryDeps}).
* @returns the registry handle.
*/
export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry {
let table = scan(deps)
let pending = false
const unsubscribe = deps.ctx.on('internal/plugin', () => {
if (pending) return
pending = true
queueMicrotask(() => {
pending = false
try {
table = scan(deps)
} catch (error) {
// Keep serving the previous table: a mid-flight rescan failure must not
// take down the boot manifest for plugins that were fine.
deps.onError(error instanceof Error ? error : new Error(String(error)))
}
})
})
return {
snapshot: () => [...table.values()].map(record => record.entry),
clientPath: id => table.get(id)?.clientPath,
dispose: () => { unsubscribe() },
}
}
/** One full table build from the loader's current entries. */
function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
const table = new Map<string, WebPluginRecord>()
for (const entry of deps.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
const name = entry.options.name
if (table.has(name)) continue
const pkgPath = deps.resolvePkgJson(name)
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const decl = parseDshClient(name, pkg.dshClient)
if (decl === undefined || decl.platform !== 'web') continue
const clientRel = clientExportOf(name, pkg.exports)
if (clientRel === undefined) {
throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`)
}
table.set(name, {
entry: {
id: name,
url: `/plugins/${name}/client.js`,
inject: decl.inject ?? [],
...(decl.immediately === true ? { immediately: true } : {}),
},
clientPath: join(dirname(pkgPath), clientRel),
})
}
return table
}

View File

@@ -0,0 +1,50 @@
/**
* Webserver invariant companion: the boot-manifest consistency audit — every
* registry snapshot row must resolve a clientPath, checked on fiber lifecycle
* events against the assembly-published 'webPlugins' context key.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as WebserverInvariant from '../src/invariant.ts'
interface RegistryStub {
snapshot(): { id: string; url: string }[]
clientPath(id: string): string | undefined
}
async function setup(registry?: RegistryStub): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(WebserverInvariant).await()
if (registry !== undefined) ctx.reflect.provide('webPlugins', registry)
return ctx
}
/** Fire the audit trigger directly (same technique as the scope invariant
* spec): a synchronous emit propagates the fail() throw to the caller. */
function trigger(ctx: Context): void {
;(ctx.emit as (event: string, ...args: unknown[]) => void)('internal/plugin', ctx.fiber)
}
describe('webserver manifest invariant', () => {
it('stays silent without a registry (carrier-only deployment) and with a consistent table', async () => {
const bare = await setup()
expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published
const consistent = await setup({
snapshot: () => [{ id: 'p1', url: '/plugins/p1/client.js' }],
clientPath: () => '/tmp/p1/lib/client.js',
})
expect(() => { trigger(consistent) }).not.toThrow()
})
it('throws on a manifest row whose bundle path no longer resolves', async () => {
const ctx = await setup({
snapshot: () => [{ id: 'ghost', url: '/plugins/ghost/client.js' }],
clientPath: () => undefined,
})
expect(() => { trigger(ctx) })
.toThrow(/manifest row "ghost".*resolves no client bundle path/)
})
})

View File

@@ -0,0 +1,210 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */
function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string {
const dir = join(root, name.replaceAll('/', '__'))
mkdirSync(join(dir, 'lib'), { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg }))
if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`)
return join(dir, 'package.json')
}
const webDecl = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
dshClient: { inject: [], platform: 'web', ...extra },
exports: { '.': './lib/index.js', './client': './lib/client.js' },
})
interface Fixture {
deps: WebPluginRegistryDeps
entries: LoaderEntryView[]
errors: Error[]
ctx: Context
}
function makeDeps(
specs: { name: string; pkg: Record<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
): Fixture {
const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
const paths = new Map<string, string>()
const entries: LoaderEntryView[] = specs.map((spec) => {
paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true))
return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false }
})
const ctx = new Context()
const errors: Error[] = []
const deps: WebPluginRegistryDeps = {
ctx,
loader: { entries: () => entries },
resolvePkgJson: (name) => {
const path = paths.get(name)
if (path === undefined) throw new Error(`unresolvable ${name}`)
return path
},
onError: err => void errors.push(err),
}
return { deps, entries, errors, ctx }
}
describe('createHostWebPluginRegistry', () => {
it('collects loaded web-declared plugins with url/inject/immediately and client paths', () => {
const { deps } = makeDeps([
{ name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) },
{ name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) },
{ name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped
])
const registry = createHostWebPluginRegistry(deps)
const rows = registry.snapshot()
expect(rows).toEqual([
{
id: '@deepseek-ai/dsh-client-connection',
url: '/plugins/@deepseek-ai/dsh-client-connection/client.js',
inject: [],
immediately: true,
},
{
id: '@deepseek-ai/dsh-client-ui-layout',
url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js',
inject: ['@deepseek-ai/dsh-client-runtime'],
},
])
expect(registry.clientPath('@deepseek-ai/dsh-client-connection')).toMatch(/lib[/\\]client\.js$/)
expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined()
registry.dispose()
})
it('skips entries that are unloaded, disabled, or declare another platform', () => {
const { deps } = makeDeps([
{ name: 'not-loaded', pkg: webDecl(), loaded: false },
{ name: 'disabled', pkg: webDecl(), disabled: true },
{ name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } },
])
const registry = createHostWebPluginRegistry(deps)
expect(registry.snapshot()).toEqual([])
registry.dispose()
})
it('fails loud at build time on a dshClient declaration without a "./client" export', () => {
const { deps } = makeDeps([
{ name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } },
])
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
})
it('fails loud on malformed declaration fields', () => {
for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) {
const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }])
expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/)
}
})
it('rescans on internal/plugin (debounced) and keeps the old table when a rescan fails', async () => {
const { deps, entries, errors, ctx } = makeDeps([
{ name: 'late-loader', pkg: webDecl(), loaded: false },
])
const registry = createHostWebPluginRegistry(deps)
expect(registry.snapshot()).toEqual([])
// Entry finishes loading; a fiber lifecycle event triggers the debounced rescan.
;(entries[0] as { fiber?: unknown }).fiber = {}
ctx.emit('internal/plugin', ctx.fiber)
ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan
await Promise.resolve()
expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader'])
// A failing rescan reports the error and keeps serving the previous table.
entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false })
ctx.emit('internal/plugin', ctx.fiber)
await Promise.resolve()
expect(errors).toHaveLength(1)
expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader'])
// After dispose, further fiber events no longer rescan.
registry.dispose()
entries.pop()
ctx.emit('internal/plugin', ctx.fiber)
await Promise.resolve()
expect(errors).toHaveLength(1)
})
})
describe('injectBootManifest', () => {
it('injects the manifest as the first script inside <head> and escapes </script> breakouts', () => {
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
const out = injectBootManifest(html, [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js', inject: [] }])
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
expect(out).not.toContain('</script><script>alert(1)')
expect(out).toContain('\\u003c/script')
})
it('prepends when the page has no <head>', () => {
const out = injectBootManifest('<body>x</body>', [])
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
})
})
describe('clientExportOf shapes (through the registry build)', () => {
it('accepts the conditional {types, default} export form', () => {
const { deps } = makeDeps([{
name: 'conditional',
pkg: {
dshClient: { platform: 'web' },
exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
},
}])
const registry = createHostWebPluginRegistry(deps)
expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
registry.dispose()
})
it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
for (const exportsField of [
{ './client': { types: './x.d.ts' } },
{ './client': ['./a.js'] },
]) {
const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
}
// Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
})
it('skips duplicate loader entries for the same package name (first wins)', () => {
const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
const first = entries[0] as LoaderEntryView
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
void first
const registry = createHostWebPluginRegistry(deps)
expect(registry.snapshot().filter(r => r.id === 'dup-entry')).toHaveLength(1)
registry.dispose()
})
it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
// client: null → the object-form branch's null guard.
const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
// Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
const registry = createHostWebPluginRegistry(deps)
entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
const original = deps.resolvePkgJson
deps.resolvePkgJson = (name) => {
if (name === 'ghost-two') throw 'string failure'
return original(name)
}
ctx.emit('internal/plugin', ctx.fiber)
await Promise.resolve()
expect(errors[0]).toBeInstanceOf(Error)
expect(String(errors[0])).toContain('string failure')
registry.dispose()
})
})

View File

@@ -0,0 +1,337 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { createServer as createNetServer, type AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { startWebServer, type RunningWebServer } from '../src/index.ts'
/** RunningWebServer.port echoes options.port, so tests must pick a concrete free port up front. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createNetServer()
probe.once('error', reject)
probe.listen(0, () => {
const port = (probe.address() as AddressInfo).port
probe.close(() => { resolve(port) })
})
})
}
/** dist fixture: index.html + one asset of each MIME class + a subdir. */
function makeDist(): { distIndex: string; distRoot: string } {
const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
writeFileSync(join(distRoot, 'index.html'), '<html>INDEX</html>')
writeFileSync(join(distRoot, 'app.js'), 'console.log(1)')
writeFileSync(join(distRoot, 'app.css'), 'body{}')
writeFileSync(join(distRoot, 'logo.svg'), '<svg/>')
writeFileSync(join(distRoot, 'data.json'), '{}')
writeFileSync(join(distRoot, 'app.js.map'), '{}')
writeFileSync(join(distRoot, 'blob.bin'), 'BIN')
mkdirSync(join(distRoot, 'sub'))
writeFileSync(join(distRoot, 'sub', 'page.html'), '<html>SUB</html>')
return { distIndex: join(distRoot, 'index.html'), distRoot }
}
const echoingApi = {
fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const req = input instanceof Request ? input : new Request(input, init)
if (req.url.endsWith('/api/echo')) {
return Response.json({ method: req.method, body: await req.text(), header: req.headers.get('x-probe') })
}
if (req.url.endsWith('/api/empty')) return new Response(null, { status: 204 })
if (req.url.endsWith('/api/big')) {
// Chunks far above any socket highWaterMark force res.write to return false.
const big = new Uint8Array(4 * 1024 * 1024).fill(65)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(big)
controller.enqueue(big)
controller.close()
},
})
return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } })
}
if (req.url.endsWith('/api/sse')) {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: one\n\n'))
controller.enqueue(encoder.encode('data: two\n\n'))
controller.close()
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
}
if (req.url.endsWith('/api/throw-string')) {
// Non-Error rejection: the guard must wrap it for onError.
throw 'string failure'
}
if (req.url.endsWith('/api/explode-mid-stream')) {
// Headers go out with the first chunk, then the source errors: the
// guard's headersSent leg must destroy the socket, not writeHead again.
// The error is deferred a tick so the 200 + first chunk actually flush
// to the client before the teardown.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: first\n\n'))
setTimeout(() => { controller.error(new Error('stream exploded')) }, 20)
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
}
if (req.url.endsWith('/api/abort-probe')) {
// Endless SSE that only ends when the request signal aborts.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
req.signal.addEventListener('abort', () => {
try {
controller.close()
} catch { /* already closed by teardown: nothing else can reach this */ }
}, { once: true })
controller.enqueue(new TextEncoder().encode('data: open\n\n'))
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
}
return new Response('nope', { status: 404 })
},
}
let server: RunningWebServer | undefined
afterEach(async () => {
await server?.close()
server = undefined
})
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, onError)
return `http://127.0.0.1:${String(server.port)}`
}
describe('startWebServer', () => {
it('reports the listening port and closes idempotently', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)
expect(server.port).toBe(port)
const first = server.close()
const second = server.close()
expect(second).toBe(first)
await first
server = undefined
})
it('rejects when the port is already taken', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)
await expect(startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined))
.rejects.toMatchObject({ code: 'EADDRINUSE' })
})
})
describe('static serving', () => {
it('serves index at /, subpaths by MIME, octet-stream for unknown, SPA fallback on miss', async () => {
const base = await boot()
const index = await fetch(`${base}/`)
expect(index.status).toBe(200)
expect(index.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(await index.text()).toBe('<html>INDEX</html>')
expect((await fetch(`${base}/app.js`)).headers.get('content-type')).toBe('text/javascript; charset=utf-8')
expect((await fetch(`${base}/app.css`)).headers.get('content-type')).toBe('text/css; charset=utf-8')
expect((await fetch(`${base}/logo.svg`)).headers.get('content-type')).toBe('image/svg+xml')
expect((await fetch(`${base}/data.json`)).headers.get('content-type')).toBe('application/json')
expect((await fetch(`${base}/app.js.map`)).headers.get('content-type')).toBe('application/json')
expect((await fetch(`${base}/blob.bin`)).headers.get('content-type')).toBe('application/octet-stream')
expect(await (await fetch(`${base}/sub/page.html`)).text()).toBe('<html>SUB</html>')
const miss = await fetch(`${base}/routes/deep/link`)
expect(miss.status).toBe(200)
expect(await miss.text()).toBe('<html>INDEX</html>')
})
it('403s traversal outside the dist root and 405s non-GET/HEAD', async () => {
const base = await boot()
// %2e%2e would be dot-collapsed by WHATWG URL parsing on both ends; an
// encoded slash keeps the segment intact until the server's decodeURIComponent.
const traversal = await fetch(`${base}/..%2f..%2fetc%2fpasswd`)
expect(traversal.status).toBe(403)
const put = await fetch(`${base}/index.html`, { method: 'PUT', body: 'x' })
expect(put.status).toBe(405)
})
it('answers HEAD like GET (no 405)', async () => {
const base = await boot()
const head = await fetch(`${base}/`, { method: 'HEAD' })
expect(head.status).toBe(200)
})
})
describe('web plugin surfaces (boot injection + bundle endpoint)', () => {
const rows = [
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: ['@deepseek-ai/dsh-client-runtime'] },
]
async function bootWithPlugins(): Promise<string> {
const { distIndex, distRoot } = makeDist()
writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
const webPlugins = {
snapshot: () => rows,
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
}
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined)
return `http://127.0.0.1:${String(server.port)}`
}
it('injects window.__DSH_BOOT__ into / and SPA fallbacks; asset requests stay verbatim', async () => {
const base = await bootWithPlugins()
const index = await (await fetch(`${base}/`)).text()
expect(index).toContain('window.__DSH_BOOT__')
const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
expect(JSON.parse(manifest ?? '')).toEqual({ plugins: rows })
const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
expect(fallback).toContain('window.__DSH_BOOT__')
const direct = await (await fetch(`${base}/index.html`)).text()
expect(direct).toContain('window.__DSH_BOOT__')
expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
})
it('serves registered client bundles and 404s unknown ids (no SPA fallback)', async () => {
const base = await bootWithPlugins()
const bundle = await fetch(`${base}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
expect(bundle.status).toBe(200)
expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
expect(await bundle.text()).toContain('DSHClientProxy')
expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
})
it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
const { distIndex } = makeDist()
const webPlugins = {
snapshot: () => rows,
clientPath: () => '/nonexistent/lib/client.js',
}
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
expect(res.status).toBe(404)
})
it('keeps both surfaces off without the webPlugins option', async () => {
const base = await boot()
expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
// No plugin route: falls through to static SPA fallback semantics.
const res = await fetch(`${base}/plugins/x/client.js`)
expect(res.status).toBe(200)
expect(await res.text()).toBe('<html>INDEX</html>')
})
})
describe('request-handling guard (one bad request must not kill the process)', () => {
it('400s malformed %-escapes, reports to onError, and stays alive', async () => {
const errors: Error[] = []
const base = await boot(err => errors.push(err))
for (const path of ['/%', '/%c0', '/%zz%']) {
expect((await fetch(`${base}${path}`)).status).toBe(400)
}
expect(errors.length).toBe(3)
expect(errors[0]?.name).toBe('URIError')
// The barrage left the server serving.
expect((await fetch(`${base}/`)).status).toBe(200)
})
it('wraps a non-Error throw for onError and still answers 400', async () => {
const errors: Error[] = []
const base = await boot(err => errors.push(err))
expect((await fetch(`${base}/api/throw-string`, { method: 'POST' })).status).toBe(400)
expect(errors[0]).toBeInstanceOf(Error)
expect(errors[0]?.message).toBe('string failure')
})
it('destroys the socket when the failure lands after headers went out', async () => {
const errors: Error[] = []
const base = await boot(err => errors.push(err))
const response = await fetch(`${base}/api/explode-mid-stream`)
expect(response.status).toBe(200) // headers made it out before the explosion
await expect(response.text()).rejects.toThrow() // then the socket is torn down
expect(errors.length).toBe(1)
expect((await fetch(`${base}/`)).status).toBe(200)
})
})
describe('/api bridge', () => {
it('forwards method, headers, and body; relays status and body back', async () => {
const base = await boot()
const response = await fetch(`${base}/api/echo`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-probe': 'p1' },
body: JSON.stringify({ n: 1 }),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
})
it('relays a bodyless response', async () => {
const base = await boot()
const response = await fetch(`${base}/api/empty`, { method: 'POST' })
expect(response.status).toBe(204)
expect(await response.text()).toBe('')
})
it('streams SSE frames through chunk by chunk', async () => {
const base = await boot()
const response = await fetch(`${base}/api/sse`)
expect(response.headers.get('content-type')).toBe('text/event-stream')
expect(await response.text()).toBe('data: one\n\ndata: two\n\n')
})
it('waits for drain when a streamed chunk overfills the socket buffer', async () => {
// 4 MiB chunks dwarf the socket highWaterMark, so res.write returns false
// and the bridge parks on 'drain'; reading the body to completion proves
// the loop resumed instead of dropping the remainder.
const base = await boot()
const response = await fetch(`${base}/api/big`)
const body = new Uint8Array(await response.arrayBuffer())
expect(body.length).toBe(8 * 1024 * 1024)
expect(body[0]).toBe(65)
expect(body[body.length - 1]).toBe(65)
})
it('releases a drain wait when the client disconnects mid-chunk', async () => {
// The 'close' leg of the drain race: abort while the socket buffer is
// still full so the parked write wakes via 'close', not 'drain'.
const base = await boot()
const ac = new AbortController()
const response = await fetch(`${base}/api/big`, { signal: ac.signal })
const reader = response.body?.getReader()
const first = await reader?.read()
expect(first?.value?.length).toBeGreaterThan(0)
ac.abort()
// afterEach close() completing is the leak assertion, same as abort-probe.
await new Promise((resolve) => { setTimeout(resolve, 50) })
})
it('aborts the bridged request when the client disconnects mid-SSE', async () => {
const base = await boot()
const ac = new AbortController()
const response = await fetch(`${base}/api/abort-probe`, { signal: ac.signal })
const reader = response.body?.getReader()
expect(reader).toBeDefined()
const first = await reader?.read()
expect(new TextDecoder().decode(first?.value)).toContain('open')
ac.abort()
// server-side abort propagation has no client-observable handshake beyond
// the closed connection; close() would hang on a leaked live SSE socket,
// so afterEach completing IS the assertion that the bridge released it.
await new Promise((resolve) => { setTimeout(resolve, 50) })
})
})

View File

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