From 4a27da44cf9ddbb316dc607c2c4125c7505f6029 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:49:46 +0800 Subject: [PATCH] test(web): real-composition webserver spec Boots a test-only cordis.yml through the real Loader and asserts the route service's behavior surface: exact/longest-prefix matching, tapIndex transform order and unsubscription, traversal 403, non-GET 405, SPA-200 fallback, malformed-request 400 without process exit, duplicate-pattern throw, dispose closing held connections with register/disposer symmetry, and a listen-failure fail-loud case (EADDRINUSE -> FAILED fiber + late rejection). Replaces the retired factory-era specs. --- apps/cli/package.json | 2 +- apps/cli/src/app-cli-entry.ts | 46 +- apps/cli/src/headless.ts | 39 +- apps/cli/tsconfig.json | 3 - docs/config-catalog.md | 3 +- docs/module-graph.md | 3 - knip.json | 5 - packages/host/apiproxy/src/api-proxy.ts | 2 +- .../tests/api-proxy-cold.spec.ts | 0 .../tests/api-proxy-view.spec.ts | 0 packages/host/runtime/README.md | 35 - packages/host/runtime/package.json | 77 -- packages/host/runtime/src/boot.ts | 171 ---- packages/host/runtime/src/index.ts | 11 - packages/host/runtime/src/invariant.ts | 31 - packages/host/runtime/src/start.ts | 54 -- .../host/runtime/tests/host-runtime.spec.ts | 792 ------------------ packages/host/runtime/tsconfig.json | 132 --- .../host/webserver/tests/webserver.spec.ts | 168 ++++ pnpm-lock.yaml | 130 +-- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 1 - tsconfig.host.json | 1 - 23 files changed, 222 insertions(+), 1485 deletions(-) rename packages/host/{runtime => apiproxy}/tests/api-proxy-cold.spec.ts (100%) rename packages/host/{runtime => apiproxy}/tests/api-proxy-view.spec.ts (100%) delete mode 100644 packages/host/runtime/README.md delete mode 100644 packages/host/runtime/package.json delete mode 100644 packages/host/runtime/src/boot.ts delete mode 100644 packages/host/runtime/src/index.ts delete mode 100644 packages/host/runtime/src/invariant.ts delete mode 100644 packages/host/runtime/src/start.ts delete mode 100644 packages/host/runtime/tests/host-runtime.spec.ts delete mode 100644 packages/host/runtime/tsconfig.json create mode 100644 packages/host/webserver/tests/webserver.spec.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index a39ce726bb..a92cb68caf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,6 +15,7 @@ "license": "BSD-3-Clause", "dependencies": { "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-logger-console": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:^", @@ -37,7 +38,6 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 52df4203b1..ce26903551 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** - * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares - * (config-tree boot wired for `dsh web` this round; TUI/headless migrate - * later). Everything here is what must exist before the Loader runs: layered - * env, the patch composition over the shipped cordis.yml (profile json + CLI + * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share + * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). + * Everything here is what must exist before the Loader runs: layered env, + * the patch composition over the shipped cordis.yml (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ @@ -62,22 +62,28 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_PENDING = 0 as FiberState.PENDING -/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */ +/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { /** Absolute path of the shipped cordis.yml. */ configPath: string - /** Whether to append the HMR row (the whole prod/dev difference). */ + /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ dev: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string - /** --port when explicitly passed; undefined keeps the yml engineering default. */ + /** + * Listen port override onto the webserver row. Web passes the --port flag + * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs + * never collide — and the printed URL still opens the live session in a + * browser). + */ port?: number } /** - * Boot driver for the config-tree `dsh web` shape: holds only what exists - * independently of (and prior to) cordis — argv facts, the composed patch - * set, and finally the root ctx. + * Boot driver for the config-tree dsh surfaces (web and headless share the + * one composition; the surfaces differ only in constructor facts): holds only + * what exists independently of (and prior to) cordis — argv facts, the + * composed patch set, and finally the root ctx. */ export class AppCLIEntry { /** The root context, set by {@link run}. */ @@ -99,13 +105,13 @@ export class AppCLIEntry { this.assertBoot() const port = this.ctx.get('httpServer')?.port /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ - if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot') + if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot') return { ctx: this.ctx, port } } /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ private loadEnvLayers(): void { - loadEnv('dsh web', resolveDshHome()) + loadEnv('dsh', resolveDshHome()) } /** @@ -127,7 +133,7 @@ export class AppCLIEntry { for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) if (mapping === undefined) { - throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) + throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) } put(mapping.entryId, mapping.configKey, value) } @@ -142,7 +148,7 @@ export class AppCLIEntry { this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) - if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`) + if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) return { id, config: { ...(yml.config ?? {}) as Record, ...bag } } }) } @@ -173,8 +179,8 @@ export class AppCLIEntry { * below catches PENDING fibers (cordis inject waiting has no timeout). */ private assertBoot(): void { - installFailLoud('dsh web') - assertEntriesLoaded(this.ctx, 'dsh web') + installFailLoud('dsh') + assertEntriesLoaded(this.ctx, 'dsh') const failures: string[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.fiber === undefined || entry.disabled) continue @@ -188,14 +194,14 @@ export class AppCLIEntry { } } if (failures.length > 0) { - throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) } } /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`) + if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) const rows = new Map() for (const row of doc as { id?: string; config?: unknown }[]) { if (typeof row.id === 'string') rows.set(row.id, row) @@ -214,7 +220,7 @@ export class AppCLIEntry { } const parsed: unknown = JSON.parse(raw) if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) + throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) } return parsed as Record } @@ -225,7 +231,7 @@ export class AppCLIEntry { try { return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') } catch { - throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') } } } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 303bac61f8..7dceebaf47 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,18 +1,20 @@ /** - * `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic - * injection (InProcessApiClient over the host handler, so the full carrier - * chain — wire serialization, zod, SSE framing — really runs; this is the - * protocol's second real consumer). No HTTP server, no port, no dist - * resolution. Runs one task turn, prints the final assistant text, exits - * (completed → 0, else 1). + * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry + * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never + * collide), then in-process isomorphic injection (InProcessApiClient over + * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire + * serialization, zod, SSE framing — really runs). The printed URL opens the + * live session in a browser while the task runs. Runs one task turn, prints + * the final assistant text, exits (completed → 0, else 1). */ import { parseArgs } from 'node:util' -import { startHost } from '@deepseek-ai/dsh-host-runtime' -import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy' +import { fileURLToPath } from 'node:url' +import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' +import { AppCLIEntry } from './app-cli-entry.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ - boot: { - persistenceRoot: './.sessions', - workspaceContext: false, - }, + const entry = new AppCLIEntry({ + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + dev: false, + port: 0, }) - const api = new InProcessApiClient(host.handler) + const { ctx, port } = await entry.run() + const dispose = async (): Promise => { await ctx.fiber.dispose() } + // The headless session is web-observable while it runs (same composition). + process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) + const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) - const created = await unwrap(await api.sessions.create({}), () => host.dispose()) + const created = await unwrap(await api.sessions.create({}), dispose) // Open the stream before prompting so no frame is lost — kept in this order // even though in-process delivery has no race, so the code survives a move @@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise { sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: task }], - }), () => host.dispose()) + }), dispose) const outcome = await done process.stdout.write(outcome.text + '\n') abort.abort() - await host.dispose() + await dispose() process.exit(outcome.reason === 'completed' ? 0 : 1) } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index b33280943a..4db4861b93 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../packages/host/apiproxy" }, - { - "path": "../../packages/host/runtime" - }, { "path": "../../packages/host/webserver" }, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 367b986d4c..6234dcca21 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -285,7 +285,7 @@ export interface Config { } ``` -Source: [`packages/client/hmr/src/index.ts:29`](../packages/client/hmr/src/index.ts) +Source: [`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -2071,7 +2071,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index d5845bf041..6988c3b5c3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -171,7 +171,6 @@ flowchart TD end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] - pkg_host_runtime["host-runtime"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -238,7 +237,6 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants - pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_llm --> pkg_brand @@ -808,7 +806,6 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | -| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | diff --git a/knip.json b/knip.json index b6a1fb63d7..2d437dd421 100644 --- a/knip.json +++ b/knip.json @@ -57,11 +57,6 @@ ] }, "packages/host/webserver": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/host/runtime": { "entry": [ "tests/**/*.spec.ts" ], diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a5ae45f1af..e5759d828d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -172,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade } } -/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */ +/** Host-level default agent routing: provider/model from the gateway config, cwd from the host process. */ export interface ApiProxyDefaults { provider: string model: string diff --git a/packages/host/runtime/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts similarity index 100% rename from packages/host/runtime/tests/api-proxy-cold.spec.ts rename to packages/host/apiproxy/tests/api-proxy-cold.spec.ts diff --git a/packages/host/runtime/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts similarity index 100% rename from packages/host/runtime/tests/api-proxy-view.spec.ts rename to packages/host/apiproxy/tests/api-proxy-view.spec.ts diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md deleted file mode 100644 index 7b91fdcf84..0000000000 --- a/packages/host/runtime/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# @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), 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. - -## Configuration - -| Key | Default | Contract | -|---|---:|---| -| `persistenceRoot` | (required) | Root directory for JSONL session persistence. | -| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. | -| `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`. | -| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. | -| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. | -| `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. | - -## ApiProxy implementation notes - -Unary methods take the narrow `RpcRequest

` 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 and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. 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 non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled. - -#### KV Cache effect - -No main-request invalidation; when enabled, the auxiliary title request has its own cache behavior and leaves the conversation prefix unchanged. - -## Known Limitations and Deferred Work - -- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence. -- **`host.describe.version` is a placeholder** — it does not yet report 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. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json deleted file mode 100644 index f8067b6482..0000000000 --- a/packages/host/runtime/package.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "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-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-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-session-title-first-message-llm": "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-user-interaction": "workspace:^", - "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "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:^" - } -} diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts deleted file mode 100644 index c0960ab678..0000000000 --- a/packages/host/runtime/src/boot.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * 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 SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' -import * as SessionTitleFirstMessageLlm from '@deepseek-ai/dsh-session-title-first-message-llm' -import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm' -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 * as workspaceContext from '@deepseek-ai/dsh-workspace-context' -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' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' - -/** Default deterministic title policy for sessions created through the host. */ -const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = { - fallbackMaxWords: 5, - fallbackMaxBytes: 40, - maxTitleBytes: 80, -} - -/** Default first-message model-title policy for sessions created through the host. */ -const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = { - targetWords: 5, - targetCjkCharacters: 10, - maxInputBytes: 4_096, - maxOutputTokens: 64, - timeoutMs: 60_000, -} - -/** Options for bootHost — the assembly-layer composition knobs. */ -export interface BootHostOptions { - /** Root directory for JSONL session persistence. */ - persistenceRoot: string - /** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */ - workspaceContext: workspaceContext.Config | false - /** 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 - /** Deterministic fallback-title limits. */ - sessionTitle?: SessionTitleConfig - /** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */ - sessionTitleLlm?: true | SessionTitleLlmConfig - /** - * 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 -} - -/** - * 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, workspace instructions, and optional default routing. - * @returns the booted handle (ctx + defaults + dispose). - */ -export async function bootHost(options: BootHostOptions): Promise { - 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(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG) - if (options.sessionTitleLlm !== undefined) { - await ctx.plugin( - SessionTitleFirstMessageLlm, - options.sessionTitleLlm === true ? DEFAULT_SESSION_TITLE_LLM_CONFIG : options.sessionTitleLlm, - ) - } - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - await ctx.plugin(TaskService) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek, {}) - await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) - 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, {}) - if (options.workspaceContext !== false) { - await ctx.plugin(workspaceContext, options.workspaceContext) - } - // 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() } -} diff --git a/packages/host/runtime/src/index.ts b/packages/host/runtime/src/index.ts deleted file mode 100644 index 03780817a9..0000000000 --- a/packages/host/runtime/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine - * 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 { startHost } from './start.ts' -export type { StartHostOptions, RunningHost } from './start.ts' diff --git a/packages/host/runtime/src/invariant.ts b/packages/host/runtime/src/invariant.ts deleted file mode 100644 index 649df3c1b6..0000000000 --- a/packages/host/runtime/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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 */ diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts deleted file mode 100644 index d9009246bb..0000000000 --- a/packages/host/runtime/src/start.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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 automation transports all consume the same shape. - */ - -import type { Context } from 'cordis' -import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' -import { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { bootHost } from './boot.ts' -import type { BootHostOptions, HostDefaults } from './boot.ts' - -/** Options for startHost. */ -export interface StartHostOptions { - /** - * Passed through to bootHost verbatim. 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 - * automation transports; (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 -} - -/** - * 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 { - const host = await bootHost(options.boot) - const api = createApiProxy(host.ctx, host.defaults) - const handler = toFetchHandler(api) - let disposing: Promise | undefined - return { api, handler, defaults: host.defaults, ctx: host.ctx, dispose: () => (disposing ??= host.dispose()) } -} diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts deleted file mode 100644 index 7dea7589a9..0000000000 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ /dev/null @@ -1,792 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } 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 { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' -import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm' -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 { - readonly requests: GenerateOptions[] = [] - - constructor(private script: (StreamChunk[] | 'hang')[]) { - super() - } - - async * stream(options: GenerateOptions): AsyncIterable { - if ((options.tools?.length ?? 0) === 0) { - yield * textResponse('Durable append-only session titles') - return - } - this.requests.push(options) - 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((_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

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} -let nextRpc = 1 - -function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject: Agent, status: string) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) -} - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -async function nextMux(iterator: AsyncIterator>): Promise> { - const next = await iterator.next() - if (next.done === true) throw new Error('mux ended before the expected frame') - return next.value -} - -/** Durably append a title event without mounting title-generation policy. */ -function appendTitle(ctx: Context, agent: Agent, title: string) { - return ctx.sessions.appendOutOfBand(agent.session, 'session/title', { - title, - messageSeqs: [1], - source: { kind: 'fallback' }, - }, { kind: 'session-title' }) -} - -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')[] = [], - sessionTitle?: SessionTitleConfig, - sessionTitleLlm?: true | SessionTitleLlmConfig, -): Promise { - host = await startHost({ - boot: { - persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), - workspaceContext: false, - provider: 'scripted', - model: 'test-model', - ...(sessionTitle === undefined ? {} : { sessionTitle }), - ...(sessionTitleLlm === undefined ? {} : { sessionTitleLlm }), - }, - }) - 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-')), - workspaceContext: false, - }) - expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' }) - expect(typeof handle.defaults.cwd).toBe('string') - await handle.dispose() - }) - - it('uses the JSONL backend compressed default', async () => { - const handle: HostHandle = await bootHost({ - persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')), - workspaceContext: false, - }) - const session = handle.ctx.sessions.create() - expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/) - 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 - }) - - it('routes workspace instructions through the assembled agent request prefix', async () => { - const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-')) - mkdirSync(join(workspace, '.git')) - writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n') - const adapter = new ScriptedAdapter([textResponse('done')]) - host = await startHost({ - boot: { - persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')), - workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 }, - provider: 'scripted', - model: 'test-model', - cwd: workspace, - }, - }) - host.ctx.llm.registerAdapter(['scripted'], adapter) - const { sessionId } = expectOk(await host.api.sessions.create(request({}))) - const agent = host.ctx.agents.get(sessionId) as Agent - const idle = waitForIdle(host.ctx, agent) - - expectOk(await host.api.sessions.prompt(request({ - sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'go' }], - }))) - await idle - - const requestText = adapter.requests[0]?.messages - .flatMap(message => message.content) - .filter(block => block.type === 'text') - .map(block => block.text) - .join('\n') ?? '' - expect(requestText).toContain('Instructions from: AGENTS.md') - expect(requestText).toContain('host-workspace-context-probe') - }) - - it('keeps model title generation disabled when sessionTitleLlm is omitted', 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) as Agent - const idle = waitForIdle(ctx, agent) - expectOk(await api.sessions.prompt(request({ - sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'Explain durable session titles.' }], - }))) - await idle - - expect((await ctx.sessionTitle.refresh(agent.session))?.source).toEqual({ kind: 'fallback' }) - expect(agent.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false) - }) -}) - -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() - }) - - it('ensures a missing project directory before minting the session', async () => { - const { api } = await boot() - const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-')) - const cwd = join(root, 'nested', 'workspace') - expect(existsSync(cwd)).toBe(false) - const { sessionId } = expectOk(await api.sessions.create(request({ cwd }))) - expect(existsSync(cwd)).toBe(true) - const { items } = expectOk(await api.sessions.list(request({}))) - expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd) - }) - - it('fails loud when the project directory cannot be created', async () => { - const { api } = await boot() - const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-')) - const blocker = join(root, 'file-not-dir') - writeFileSync(blocker, 'x') - const response = await api.sessions.create(request({ cwd: join(blocker, 'child') })) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('expected mkdir failure') - expect(response.result.error.code).toBe('internal') - expect(response.result.error.message).toMatch(/failed to ensure project directory/) - }) -}) - -describe('sessions.prompt / cancel', () => { - it.each([ - { name: 'host default', config: true, target: '5 words', maxTokens: 64 }, - { - name: 'configured policy', - config: { - targetWords: 3, - targetCjkCharacters: 8, - maxInputBytes: 2_048, - maxOutputTokens: 24, - timeoutMs: 2_000, - }, - target: '3 words', - maxTokens: 24, - }, - ] satisfies { - name: string - config: true | SessionTitleLlmConfig - target: string - maxTokens: number - }[])('replaces the fallback with a model-backed first-message title using the $name', async ({ config, target, maxTokens }) => { - const modelTitle = 'Durable append-only session titles' - const running = await boot([textResponse('pong')], undefined, config) - const { api, ctx } = running - const { sessionId } = expectOk(await api.sessions.create(request({}))) - const agent = ctx.agents.get(sessionId) as Agent - const idle = waitForIdle(ctx, agent) - expectOk(await api.sessions.prompt(request({ - sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'Explain why append-only logs make session titles durable.' }], - }))) - await idle - - await vi.waitFor(() => { - expect(agent.session.events.filter(event => event.type === 'session/title').map(event => event.data)) - .toEqual([ - { - title: 'Explain why append-only logs make', - messageSeqs: [1], - source: { kind: 'fallback' }, - }, - { - title: modelTitle, - messageSeqs: [1], - source: { - kind: 'provider', - provider: 'session-title-first-message-llm', - model: { provider: 'scripted', model: 'test-model' }, - }, - }, - ]) - }) - const titleRequest = agent.session.events.find(event => event.type === 'session/title-llm-request') - expect(titleRequest?.data.system).toContain(target) - expect(titleRequest?.data.maxTokens).toBe(maxTokens) - }) - - it.each([ - { name: 'host default', config: undefined, expected: 'Show the Web UI durable' }, - { - name: 'configured limit', - config: { fallbackMaxWords: 2, fallbackMaxBytes: 40, maxTitleBytes: 80 }, - expected: 'Show the', - }, - ] satisfies { name: string; config: SessionTitleConfig | undefined; expected: string }[])( - 'logs a durable fallback title with the $name', - async ({ config, expected }) => { - const running = await boot([textResponse('pong')], config) - const { api, ctx } = running - const { sessionId } = expectOk(await api.sessions.create(request({}))) - const agent = ctx.agents.get(sessionId) as Agent - const idle = waitForIdle(ctx, agent) - expectOk(await api.sessions.prompt(request({ - sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'Show the Web UI durable session title' }], - }))) - await idle - - const title = agent.session.events.find(event => event.type === 'session/title') - expect(title?.data).toEqual({ - title: expected, - messageSeqs: [1], - source: { kind: 'fallback' }, - }) - }, - ) - - 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.followup([{ 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, workspaceContext: false, 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.followup([{ type: 'text', text: 'save me' }]) - await idle - const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') - await first.dispose() - - host = await startHost({ - boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' }, - }) - host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([])) - expect(host.ctx.agents.get(sessionId)).toBeUndefined() - const abort = new AbortController() - const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]() - 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) - expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId }) - expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({ - type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq, - })) - abort.abort() - }) - - 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.followup([{ 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 - 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 - 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 - do frame = (await stream.next()).value as RpcRequest - 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).payload).toMatchObject({ type: 'session/subscribed', sessionId }) - - const agent = ctx.agents.get(sessionId) as Agent - const idle = waitForIdle(ctx, agent) - agent.followup([{ type: 'text', text: 'go' }]) - await idle - const live = await stream.next() - expect((live.value as RpcRequest).payload.type).toBe('session/event') - - const other = expectOk(await api.sessions.create(request({}))).sessionId - let frame: RpcRequest - do frame = (await stream.next()).value as RpcRequest - while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other)) - - ac.abort() - expect((await stream.next()).done).toBe(true) - }) - - it('mux: projects durable titles after open baselines and immediately after live raw events', async () => { - const running = await boot() - const { api, ctx } = running - const { sessionId } = expectOk(await api.sessions.create(request({}))) - const agent = ctx.agents.get(sessionId) as Agent - const initial = await appendTitle(ctx, agent, 'Initial title') - - const ac = new AbortController() - const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() - expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId }) - expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({ - type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time, - })) - - const revised = await appendTitle(ctx, agent, 'Revised title') - let raw: RpcRequest - do raw = await nextMux(stream) - while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title')) - expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } }) - expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({ - type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time, - })) - ac.abort() - }) - - it('mux: emits no title control for untitled subscriptions', async () => { - const { api } = await boot() - const first = expectOk(await api.sessions.create(request({}))).sessionId - const ac = new AbortController() - const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() - expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first }) - - const second = expectOk(await api.sessions.create(request({}))).sessionId - expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second }) - ac.abort() - }) - - 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).payload).toMatchObject({ type: 'host/session-added', sessionId }) - - const agent = ctx.agents.get(sessionId) as Agent - const idle = waitForIdle(ctx, agent) - agent.followup([{ type: 'text', text: 'run' }]) - await idle - const runningFrame = await stream.next() - expect((runningFrame.value as RpcRequest).payload).toMatchObject({ type: 'host/session-status', running: true }) - const idleFrame = await stream.next() - expect((idleFrame.value as RpcRequest).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).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('question request / response', () => { - const questions = [{ - id: 'mode', question: 'Choose a mode', - options: [ - { label: 'Fast (Recommended)', description: 'Move quickly.' }, - { label: 'Careful', description: 'Review first.' }, - ], - }] - - it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => { - const running = await boot() - const { api, ctx } = running - const { sessionId } = expectOk(await api.sessions.create(request({}))) - const agent = ctx.agents.get(sessionId) as Agent - const ac = new AbortController() - const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() - await stream.next() // subscribed baseline starts the generator and installs the queue - - const answerPromise = ctx.userInteraction.ask({ questions, agent }) - const requested = (await stream.next()).value as RpcRequest - expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions }) - - const wrongSession = await api.respond({ - type: 'client-response', rpcId: requested.rpcId, - result: { - ok: true, - value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } }, - }, - }) - expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' }) - const badChoice = await api.respond({ - type: 'client-response', rpcId: requested.rpcId, - result: { - ok: true, - value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } }, - }, - }) - expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' }) - const invalidResults = [ - { ok: true as const, value: null }, - { ok: true as const, value: { sessionId, answer: { answers: [] } } }, - { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } }, - { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } }, - { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } }, - { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: ' ' }] } } }, - { ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } }, - { ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } }, - ] - for (const result of invalidResults) { - expect(await api.respond({ - type: 'client-response', rpcId: requested.rpcId, result, - })).toEqual({ accepted: false, reason: 'bad-response' }) - } - - const reconnectAbort = new AbortController() - const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]() - await replay.next() - const replayed = (await replay.next()).value as RpcRequest - expect(replayed.rpcId).toBe(requested.rpcId) - expect(replayed.payload).toEqual(requested.payload) - - const response = { - type: 'client-response' as const, - rpcId: requested.rpcId, - result: { - ok: true as const, - value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } }, - }, - } - const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)]) - expect([first, duplicate]).toContainEqual({ accepted: true }) - expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' }) - await expect(answerPromise).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }], - }) - - const resolved = (await stream.next()).value as RpcRequest - expect(resolved.payload).toMatchObject({ - type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered', - }) - expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' }) - - const customQuestions = [{ id: 'detail', question: 'What else?' }] - const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent }) - const customRequested = (await stream.next()).value as RpcRequest - expect(await api.respond({ - type: 'client-response', rpcId: customRequested.rpcId, - result: { - ok: true, - value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } }, - }, - })).toEqual({ accepted: true }) - await expect(customAnswer).resolves.toEqual({ - answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }], - }) - expect(((await stream.next()).value as RpcRequest).payload).toMatchObject({ - type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered', - }) - - const blankAnswer = ctx.userInteraction.ask({ questions, agent }) - const blankRequested = (await stream.next()).value as RpcRequest - expect(await api.respond({ - type: 'client-response', rpcId: blankRequested.rpcId, - result: { - ok: true, - value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } }, - }, - })).toEqual({ accepted: true }) - await expect(blankAnswer).resolves.toEqual({ - answers: [{ id: 'mode', selected: [] }], - }) - expect(((await stream.next()).value as RpcRequest).payload).toMatchObject({ - type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered', - }) - ac.abort() - reconnectAbort.abort() - }) - - it('distinguishes user cancellation from owner abort and rejects late responses', async () => { - const running = await boot() - const { api, ctx } = running - const { sessionId } = expectOk(await api.sessions.create(request({}))) - const agent = ctx.agents.get(sessionId) as Agent - const streamAbort = new AbortController() - const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]() - await stream.next() - - const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error) - const requested = (await stream.next()).value as RpcRequest - expect(await api.respond({ - type: 'client-response', rpcId: requested.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } }, - })).toEqual({ accepted: true }) - await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' }) - expect(((await stream.next()).value as RpcRequest).payload).toMatchObject({ - type: 'question/resolved', outcome: 'cancelled', - }) - - const ownerAbort = new AbortController() - const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal }) - .catch((error: unknown) => error) - const abortRequest = (await stream.next()).value as RpcRequest - ownerAbort.abort() - await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' }) - expect(((await stream.next()).value as RpcRequest).payload).toMatchObject({ - type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled', - }) - expect(await api.respond({ - type: 'client-response', rpcId: abortRequest.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } }, - })).toEqual({ accepted: false, reason: 'not-pending' }) - streamAbort.abort() - }) - - it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => { - const running = await boot() - const { ctx } = running - await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' }) - const { sessionId } = expectOk(await running.api.sessions.create(request({}))) - const agent = ctx.agents.get(sessionId) as Agent - const alreadyAborted = new AbortController() - alreadyAborted.abort() - await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal })) - .rejects.toMatchObject({ code: 'ASK_ABORTED' }) - - const outstanding = ctx.userInteraction.ask({ questions, agent }) - const disposed = running.dispose() - host = undefined - await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await disposed - }) -}) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json deleted file mode 100644 index aee28b5371..0000000000 --- a/packages/host/runtime/tsconfig.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "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": "../../session-title/session-title" - }, - { - "path": "../../session-title/session-title-first-message-llm" - }, - { - "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": "../../context/workspace-context" - }, - { - "path": "../../ui/user-interaction" - } - ] -} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts new file mode 100644 index 0000000000..c4373d2e50 --- /dev/null +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -0,0 +1,168 @@ +/** + * REAL-composition coverage: a test-only cordis.yml booted through the + * vendored Loader mounts the webserver row, and every assertion observes the + * user-visible HTTP surface of the running server (routing precedence, index + * taps, static-fallback semantics, per-request error containment, teardown). + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context, FiberState } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import HttpServer from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */ +async function loadComposition(port = 0): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-')) + const dist = join(root, 'dist') + await mkdir(dist) + const distIndex = join(dist, 'index.html') + await writeFile(distIndex, 'shell') + await writeFile(join(dist, 'app.js'), 'export {}') + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-host-webserver'", + ' config:', + " host: '127.0.0.1'", + ` port: ${String(port)}`, + ` distIndex: '${distIndex}'`, + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-host-webserver', HttpServer], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +/** GET (by default) one path against the running server; returns status plus a body prefix. */ +async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; body: string }> { + const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init) + return { status: response.status, body: (await response.text()).slice(0, 80) } +} + +describe('real Loader composition', () => { + // Real-Loader composition resolves workspace packages through tsx at test + // time; first resolution after the host/client program split is slow enough + // to trip the default 5s budget on cold caches. + it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const unloaded = [...loaded.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + + const server = loaded.httpServer + expect(server).toBeInstanceOf(HttpServer) + const port = server.port + expect(port).toBeGreaterThan(0) + + // Routing precedence: exact beats prefix, longest prefix wins, a prefix + // route answers its own path, and routes own their method handling + // (POST reaches a registered prefix; 405 is fallback-only semantics). + server.register({ kind: 'exact', path: '/probe', handler: (_req, res) => { res.writeHead(200); res.end('EXACT') } }) + server.register({ kind: 'prefix', path: '/api', handler: (_req, res) => { res.writeHead(200); res.end('API') } }) + server.register({ kind: 'prefix', path: '/api/deep', handler: (_req, res) => { res.writeHead(200); res.end('DEEP') } }) + expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' }) + expect(await request(port, '/api/anything')).toMatchObject({ status: 200, body: 'API' }) + expect(await request(port, '/api/deep/leaf')).toMatchObject({ status: 200, body: 'DEEP' }) + expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' }) + expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' }) + + // Index taps apply in registration order on `/` and on the SPA fallback; + // the disposer removes the transform. + const untap = server.tapIndex(html => html.replace('', '')) + expect((await request(port, '/')).body).toContain('__T__') + expect((await request(port, '/no/such/route')).body).toContain('__T__') + untap() + expect((await request(port, '/')).body).not.toContain('__T__') + + // Static fallback semantics: real asset served, traversal 403, non-GET/ + // HEAD without a matching route 405. + expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' }) + expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) + expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + + // Per-request error containment: a malformed %-escape answers 400 and the + // server keeps serving afterwards (no process-level failure path). + expect((await request(port, '/%zz')).status).toBe(400) + expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' }) + + // Duplicate (kind, path) is a misconfiguration and throws; the disposer + // restores registrability (register/disposer symmetry). + expect(() => server.register({ kind: 'exact', path: '/probe', handler: () => {} })) + .toThrow(/duplicate exact route/) + const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } }) + expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' }) + disposeOnce() + expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback + expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow() + + // Teardown: fiber dispose closes the socket and severs held connections. + await loaded.fiber.dispose() + await expect(request(port, '/probe')).rejects.toThrow() + }) + + it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { + const first = await loadComposition() + const takenPort = first.httpServer.port + const firstRoot = root + root = undefined // keep the first composition's files until the end + + // loader.await() never rejects (allSettled); the bind failure surfaces as + // a FAILED fiber whose error escapes as a late rejection — the shape the + // boot's installFailLoud is contracted to catch. Capture it here the same + // way, and assert it really is the bind error. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) + let second: Context | undefined + try { + second = await loadComposition(takenPort) + const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') + expect(entry?.fiber?.state).toBe(FiberState.FAILED) + // The rejection escapes a tick after loader.await() settles; bounded poll. + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(rejections.map(String).join('\n')).toContain('EADDRINUSE') + } finally { + process.off('unhandledRejection', onUnhandled) + await second?.fiber.dispose() + context = first + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = firstRoot + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58e5bae27c..7f50479ed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:* version: link:../../vendor/loader + '@cordisjs/plugin-logger-console': + specifier: workspace:* + version: link:../../vendor/logger-console '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer @@ -167,9 +170,6 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-runtime': - specifier: workspace:^ - version: link:../../packages/host/runtime '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -2245,130 +2245,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/host/runtime: - dependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@cordisjs/plugin-timer': - specifier: workspace:^ - version: link:../../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../../bash/bash-local - '@deepseek-ai/dsh-compact-basic': - specifier: workspace:^ - version: link:../../compact/compact-basic - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local - '@deepseek-ai/dsh-fs-policy': - specifier: workspace:^ - version: link:../../fs/fs-policy - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../apiproxy - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session-title/session-title - '@deepseek-ai/dsh-session-title-first-message-llm': - specifier: workspace:^ - version: link:../../session-title/session-title-first-message-llm - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../skill/skill - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../skill/skill-local - '@deepseek-ai/dsh-spill-local': - specifier: workspace:^ - version: link:../../spill/spill-local - '@deepseek-ai/dsh-spill-policy': - specifier: workspace:^ - version: link:../../spill/spill-policy - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../subagent/subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../../subagent/subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../../subagent/subagent-spawn - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tasks': - specifier: workspace:^ - version: link:../../tasks/tasks - '@deepseek-ai/dsh-timeout-policy': - specifier: workspace:^ - version: link:../../timeout/timeout-policy - '@deepseek-ai/dsh-token-meter': - specifier: workspace:^ - version: link:../../llm/token-meter - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../../bash/tool-bash - '@deepseek-ai/dsh-tool-fs': - specifier: workspace:^ - version: link:../../fs/tool-fs - '@deepseek-ai/dsh-tool-fs-search': - specifier: workspace:^ - version: link:../../fs/tool-fs-search - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../skill/tool-skill - '@deepseek-ai/dsh-tool-subagent': - specifier: workspace:^ - version: link:../../subagent/tool-subagent - '@deepseek-ai/dsh-tool-tasks': - specifier: workspace:^ - version: link:../../tasks/tool-tasks - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tool-workflow': - specifier: workspace:^ - version: link:../../workflow/tool-workflow - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../ui/user-interaction - '@deepseek-ai/dsh-workflow-workerthread': - specifier: workspace:^ - version: link:../../workflow/workflow-workerthread - '@deepseek-ai/dsh-workspace-context': - specifier: workspace:^ - version: link:../../context/workspace-context - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/host/webserver: dependencies: schemastery: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..5687e51bac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -66,7 +66,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, - 'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a593094372..533b23e724 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -99,7 +99,6 @@ "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], - "@deepseek-ai/dsh-host-runtime": ["./packages/host/runtime/src"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 4ef6d45dbb..f68c7cfd2d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -142,7 +142,6 @@ { "path": "./packages/hooks/hooks-codex" }, { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, - { "path": "./packages/host/runtime" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" },