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:
imccyu
2026-07-25 12:49:46 +08:00
parent 0b1cd0a1cb
commit 4a27da44cf
23 changed files with 222 additions and 1485 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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:^"
}
}

View File

@@ -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() }
}

View File

@@ -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'

View File

@@ -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 */

View File

@@ -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()) }
}

View File

@@ -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
})
})

View File

@@ -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"
}
]
}

View 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
}
})
})