From 3cad7f69578d98c74a7a6eae9dd394df388758b5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:18:12 +0800 Subject: [PATCH] refactor(web): upgrade apiproxy to the api-gateway service plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createApiProxy moves from dsh-host-runtime into dsh-host-apiproxy (the dependency direction already pointed this way); the package now default-exports ApiProxyService (config {provider, model}, provides ctx.apiProxy) while staying transport-agnostic — it registers no routes. Runtime keeps bootHost/startHost for the headless path with its import re-anchored, and drops the mountWebPlugins roster mounting helper. --- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/package.json | 6 +- .../{runtime => apiproxy}/src/api-proxy.ts | 16 +-- packages/host/apiproxy/src/index.ts | 65 +++++++++- packages/host/apiproxy/src/invariant.ts | 11 +- packages/host/apiproxy/tsconfig.json | 15 +++ packages/host/runtime/README.md | 2 +- packages/host/runtime/package.json | 1 - packages/host/runtime/src/index.ts | 9 +- packages/host/runtime/src/start.ts | 3 +- packages/host/runtime/src/web-plugins.ts | 57 --------- .../host/runtime/tests/api-proxy-cold.spec.ts | 2 +- .../host/runtime/tests/api-proxy-view.spec.ts | 2 +- .../host/runtime/tests/web-plugins.spec.ts | 111 ------------------ 14 files changed, 104 insertions(+), 200 deletions(-) rename packages/host/{runtime => apiproxy}/src/api-proxy.ts (97%) delete mode 100644 packages/host/runtime/src/web-plugins.ts delete mode 100644 packages/host/runtime/tests/web-plugins.spec.ts diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1badfe65e8..c9bdd73a64 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -1,6 +1,6 @@ # @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`. +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`. ## Contract layer (`/api`) @@ -24,6 +24,6 @@ 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. +- **`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 `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **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. diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index fe50c16a60..471cccc96d 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,6 +1,6 @@ { "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", + "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", "version": "0.0.1", "private": true, "type": "module", @@ -40,12 +40,16 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "schemastery": "^3.18.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts similarity index 97% rename from packages/host/runtime/src/api-proxy.ts rename to packages/host/apiproxy/src/api-proxy.ts index acf77751af..a5ae45f1af 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,12 +11,14 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). +import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema' -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' +} from './api/index.ts' +import { questionResponsePayloadSchema } from './api/questions.schema.ts' +import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' +import { RpcId } from './api/rpc.ts' import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' @@ -170,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade } } -/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */ +/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */ export interface ApiProxyDefaults { provider: string model: string @@ -272,8 +274,8 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: class SessionNotFound extends Error {} /** - * Implement ApiProxy over the ctx composed by bootHost. - * @param ctx - the root context returned by bootHost (sessions/agents services mounted). + * Implement ApiProxy over a composed host context. + * @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services). * @param defaults - host-level default provider/model: injected as * agentOptions on create/resume, reported by describe from the same source. * @returns the ApiProxy implementation. diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 2999e48b24..9eb862962b 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -1,13 +1,68 @@ /** - * @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. + * @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares: + * the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch + * carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient + + * platform subclasses on the client side), and the host-side implementation + * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing + * `ctx.apiProxy`). Transport-agnostic by design: this package registers no + * routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. */ +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { ApiProxy } from './api/index.ts' +import { createApiProxy } from './api-proxy.ts' + 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' +export { createApiProxy } from './api-proxy.ts' +export type { ApiProxyDefaults } from './api-proxy.ts' + +declare module 'cordis' { + interface Context { + /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */ + apiProxy: ApiProxy + } +} + +/** Gateway plugin config: the host-level default agent routing. */ +export interface Config { + /** Default provider route for created/resumed agents. */ + provider: string + /** Default model id. */ + model: string +} + +/** + * The API gateway service: implements the ApiProxy contract over the composed + * host context and provides it as `ctx.apiProxy`. The default project + * directory for new sessions is the host process working directory (not a + * config field this round). + */ +export class ApiProxyService extends Service implements ApiProxy { + static inject = ['agents', 'sessions', 'tools', 'userInteraction'] + + static Config: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + }) + + readonly sessions: ApiProxy['sessions'] + readonly host: ApiProxy['host'] + readonly events: ApiProxy['events'] + readonly respond: ApiProxy['respond'] + + constructor(ctx: Context, config: Config) { + super(ctx, 'apiProxy') + const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() }) + this.sessions = api.sessions + this.host = api.host + this.events = api.events + this.respond = api.respond + } +} + +export default ApiProxyService diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts index 068cbcaa72..a96b5d081d 100644 --- a/packages/host/apiproxy/src/invariant.ts +++ b/packages/host/apiproxy/src/invariant.ts @@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant' 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. + * No runtime invariant: this package is the wire contract layer plus the + * host-side gateway over services owned elsewhere — it emits no cordis events + * of its own; the session/agent event streams it projects are asserted by + * their owning packages' companions. rpcId round-trip and schema acceptance + * are enforced at the carrier boundary and exercised by the + * protocol-isomorphism suite. */ const install: InvariantInstaller = () => {} diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 718c5a9042..4e22627590 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -8,18 +8,33 @@ "src" ], "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../util/brand" }, { "path": "../../llm/llm" }, + { + "path": "../../core/agent" + }, { "path": "../../core/session" }, { "path": "../../core/tools" }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../session-title/session-title" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..7b91fdcf84 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `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 }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition). 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. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..f8067b6482 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -39,7 +39,6 @@ "@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-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", diff --git a/packages/host/runtime/src/index.ts b/packages/host/runtime/src/index.ts index af10f0be16..03780817a9 100644 --- a/packages/host/runtime/src/index.ts +++ b/packages/host/runtime/src/index.ts @@ -1,14 +1,11 @@ /** * @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. + * composition (bootHost) and the one-step shell seam (startHost). The ApiProxy + * implementation lives in @deepseek-ai/dsh-host-apiproxy. 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 } from './web-plugins.ts' diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts index 94e5f22da1..dc5d26d859 100644 --- a/packages/host/runtime/src/start.ts +++ b/packages/host/runtime/src/start.ts @@ -8,10 +8,9 @@ import type { Context } from 'cordis' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' -import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { createApiProxy, 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 { diff --git a/packages/host/runtime/src/web-plugins.ts b/packages/host/runtime/src/web-plugins.ts deleted file mode 100644 index 5866d46492..0000000000 --- a/packages/host/runtime/src/web-plugins.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory - * entry tree over the caller-supplied client plugin roster. The roster is a - * composition decision and lives in the composing app (apps/cli); this module - * only owns the mount/settle/fail-loud mechanics. The web plugin registry - * discovers fetch-arrival entries among the mounted packages 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' - -/** 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 client - * plugin package, 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 client plugin). - * @param ctx - host root context (bootHost product). - * @param plugins - client plugin package names to mount (the composition layer's roster). - * @param anchor - module URL anchoring bare-specifier resolution (the composing - * app's import.meta.url; the roster packages must be dependencies of that app). - * @returns the loader view and package.json resolver the registry consumes. - */ -export async function mountWebPlugins( - ctx: Context, plugins: readonly string[], anchor: string, -): Promise { - // The Loader resolves bare specifiers against ctx.baseUrl; without one the - // import silently fails and every entry stays fiber-less. The composing app - // declares the roster packages as dependencies, so its URL is the right anchor. - ctx.baseUrl ??= anchor - 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 plugins) { - if (!existing.has(name)) await ctx.loader.create({ name }) - } - await ctx.loader.await() - const dead = [...ctx.loader.entries()] - .filter(entry => plugins.includes(entry.options.name)) - .filter(entry => entry.fiber === undefined && !entry.disabled) - if (dead.length > 0) { - throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`) - } - const require = createRequire(anchor) - return { - loader: ctx.loader, - resolvePkgJson: name => require.resolve(`${name}/package.json`), - } -} diff --git a/packages/host/runtime/tests/api-proxy-cold.spec.ts b/packages/host/runtime/tests/api-proxy-cold.spec.ts index 3d4ba8e15a..a3e2bf4e7a 100644 --- a/packages/host/runtime/tests/api-proxy-cold.spec.ts +++ b/packages/host/runtime/tests/api-proxy-cold.spec.ts @@ -16,7 +16,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction' 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' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId diff --git a/packages/host/runtime/tests/api-proxy-view.spec.ts b/packages/host/runtime/tests/api-proxy-view.spec.ts index a7dcdc73c5..596bf25ac8 100644 --- a/packages/host/runtime/tests/api-proxy-view.spec.ts +++ b/packages/host/runtime/tests/api-proxy-view.spec.ts @@ -21,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' 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' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' const reply = (text: string): Promise => Promise.resolve([{ type: 'text', text }]) diff --git a/packages/host/runtime/tests/web-plugins.spec.ts b/packages/host/runtime/tests/web-plugins.spec.ts deleted file mode 100644 index b558c253c2..0000000000 --- a/packages/host/runtime/tests/web-plugins.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * mountWebPlugins unit coverage (keyless). 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. The roster is - * caller-supplied now (composition moved to apps/cli), so these tests pass - * their own lists. - */ -import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' -import { mountWebPlugins } from '../src/web-plugins.ts' - -const ROSTER = [ - '@deepseek-ai/dsh-plugin-a', - '@deepseek-ai/dsh-plugin-b', - '@deepseek-ai/dsh-plugin-c', -] as const - -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 { - return this.entriesList - } - async create(options: { name: string }): Promise { - this.created.push(options.name) - this.onCreate?.(options.name) - } - async await(): Promise { - 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 roster package, 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, ROSTER, import.meta.url) - expect(loader.created).toEqual([...ROSTER]) - expect(loader.awaited).toBe(1) - expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER]) - // The resolver resolves a real package manifest through real module resolution, anchored at this test file. - 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[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false })) - const { ctx, loader } = withLoader(preexisting) - await mountWebPlugins(ctx, ROSTER, import.meta.url) - expect(loader.created).toEqual([]) - }) - - it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => { - const entriesList: FakeEntry[] = [] - const { ctx } = withLoader(entriesList, (name) => { - // First one loads; the rest stay fiber-less (import failed silently). - entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false }) - }) - await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)) - .rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/) - }) - - it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => { - const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true })) - const { ctx } = withLoader(entriesList) - await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined() - }) - - it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => { - root = new Context() - // An empty roster keeps this keyless and artifact-free: the branch under - // test is only the Loader auto-mount. - await mountWebPlugins(root, [], import.meta.url) - expect(root.get('loader') !== undefined).toBe(true) - }, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default - - 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, ROSTER, import.meta.url) - expect(ctx.baseUrl).toBe('file:///caller/anchor/') - }) -})