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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session 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.
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the harness host plugin assembly (the one place deciding which plugins mount and
|
||||
* with what defaults — shells must not alter the assembly).
|
||||
* @param options - persistence, workspace instructions, and optional default routing.
|
||||
* @returns the booted handle (ctx + defaults + dispose).
|
||||
*/
|
||||
export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
||||
const defaults: HostDefaults = {
|
||||
provider: options.provider ?? 'deepseek',
|
||||
model: options.model ?? 'deepseek-v4-flash',
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(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() }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 */
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the host and assemble its consumption surfaces in one step.
|
||||
* @param options - boot passthrough (see StartHostOptions).
|
||||
* @returns the running host handle shared by every shell shape.
|
||||
*/
|
||||
export async function startHost(options: StartHostOptions): Promise<RunningHost> {
|
||||
const host = await bootHost(options.boot)
|
||||
const api = createApiProxy(host.ctx, host.defaults)
|
||||
const handler = toFetchHandler(api)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { api, handler, defaults: host.defaults, ctx: host.ctx, dispose: () => (disposing ??= host.dispose()) }
|
||||
}
|
||||
@@ -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<StreamChunk> {
|
||||
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<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
yield * entry
|
||||
}
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
|
||||
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<RunningHost> {
|
||||
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<MuxFrame>
|
||||
expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
ac.abort()
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('lists fork lineage and announces it on the host stream', async () => {
|
||||
const running = await boot()
|
||||
const { api, ctx } = running
|
||||
const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
const child = `session-child-${String(Date.now())}` as SessionId
|
||||
const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
|
||||
expect(handle.agent.id).toBe(child)
|
||||
const added = (await stream.next()).value as RpcRequest<HostFrame>
|
||||
expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
|
||||
const { items } = expectOk(await api.sessions.list(request({})))
|
||||
expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
|
||||
|
||||
await handle.dispose()
|
||||
let frame: RpcRequest<HostFrame>
|
||||
do frame = (await stream.next()).value as RpcRequest<HostFrame>
|
||||
while (frame.payload.type !== 'host/session-removed')
|
||||
expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
|
||||
ac.abort()
|
||||
})
|
||||
|
||||
it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
|
||||
const running = await boot([textResponse('live')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
const baseline = await stream.next()
|
||||
expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await idle
|
||||
const live = await stream.next()
|
||||
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
|
||||
|
||||
const other = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
let frame: RpcRequest<MuxFrame>
|
||||
do frame = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
|
||||
|
||||
ac.abort()
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('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<MuxFrame>
|
||||
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<HostFrame>).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<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
|
||||
const idleFrame = await stream.next()
|
||||
expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
|
||||
|
||||
// Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
|
||||
// enforces; dispatch the way the loop does.
|
||||
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
|
||||
const errorFrame = await stream.next()
|
||||
expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
|
||||
|
||||
ac.abort()
|
||||
// Push-after-done: an event landing between abort and generator wind-down
|
||||
// must be dropped silently, not crash the queue.
|
||||
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('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<MuxFrame>
|
||||
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<MuxFrame>
|
||||
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<MuxFrame>
|
||||
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<MuxFrame>
|
||||
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<MuxFrame>).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<MuxFrame>
|
||||
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<MuxFrame>).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<MuxFrame>
|
||||
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<MuxFrame>).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<MuxFrame>
|
||||
ownerAbort.abort()
|
||||
await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(((await stream.next()).value as RpcRequest<MuxFrame>).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
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
168
packages/host/webserver/tests/webserver.spec.ts
Normal file
168
packages/host/webserver/tests/webserver.spec.ts
Normal file
@@ -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<Context> {
|
||||
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, '<head></head><body>shell</body>')
|
||||
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<string, unknown>([
|
||||
['@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<typeof context.loader.internal>
|
||||
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('<head>', '<head><script>window.__T__=1</script>'))
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user