Merge remote-tracking branch 'origin/master' into codex/consolidate-agent-notes

This commit is contained in:
Turtle
2026-07-25 17:20:52 +08:00
29 changed files with 226 additions and 1492 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa
2026-07-24-web-config-tree-boot-and-transport-layering.md: 377ebd2b3cf9ff1dff81dd3546bb262e0ebde88a
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 403e95fb3088d2164d50710d2a69de5526807764

View File

@@ -18,14 +18,14 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
## Consequences
- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals.
- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
## Alternatives considered

View File

@@ -18,14 +18,14 @@ Status: implemented
**每个配置源有唯一声明位置。** yml 静态值是工程默认profile json`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model``api-gateway` 行,`persistenceRoot` → jsonl 行CLI flags 映射到 `webserver` 行、字段集与 json 不相交env 值经 yml `!!js` 表达式进入绝不进映射表。patch 整体替换行 config故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`config `{provider, model}`provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` runtime 迁入依赖方向允许runtime 保留 `bootHost`/`startHost` 供 headless`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer``register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输网关零改动。modules node 半(`ClientModuleHostService`provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏flush 逐名对账 live entries包元数据含否定结论永久缓存重哈希唯一入口 `rebuilt(id)`、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged``/plugins/events` SSE 路由。
**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`config `{provider, model}`provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer``register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输网关零改动。modules node 半(`ClientModuleHostService`provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏flush 逐名对账 live entries包元数据含否定结论永久缓存重哈希唯一入口 `rebuilt(id)`、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged``/plugins/events` SSE 路由。
**包出口纪律。** modules 包只暴露 `.`node 半)与 `./client`(完整浏览器半:`ClientModuleSystem``parseBootManifest`、收编插件面——不设特设子路径wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__``./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`
## 后果
- 重组一个 web 部署 = 改 yml/patch退役件`mountWebPlugins``CLIENT_PACKAGES``createHostWebPluginRegistry``startWebServer`、webserver 的图/SSE/api 知识)全部删除。
- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost``dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。
- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration无声打散全程序的 `Context` merge`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
## Alternatives considered

View File

@@ -10,7 +10,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)

View File

@@ -37,7 +37,6 @@
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -1,8 +1,8 @@
/**
* AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares
* (config-tree boot wired for `dsh web` this round; TUI/headless migrate
* later). Everything here is what must exist before the Loader runs: layered
* env, the patch composition over the shipped cordis.yml (profile json + CLI
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped cordis.yml (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
*/
@@ -62,22 +62,28 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shipped cordis.yml. */
configPath: string
/** Whether to append the HMR row (the whole prod/dev difference). */
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
dev: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/** --port when explicitly passed; undefined keeps the yml engineering default. */
/**
* Listen port override onto the webserver row. Web passes the --port flag
* value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
* never collide — and the printed URL still opens the live session in a
* browser).
*/
port?: number
}
/**
* Boot driver for the config-tree `dsh web` shape: holds only what exists
* independently of (and prior to) cordis — argv facts, the composed patch
* set, and finally the root ctx.
* Boot driver for the config-tree dsh surfaces (web and headless share the
* one composition; the surfaces differ only in constructor facts): holds only
* what exists independently of (and prior to) cordis — argv facts, the
* composed patch set, and finally the root ctx.
*/
export class AppCLIEntry {
/** The root context, set by {@link run}. */
@@ -99,13 +105,13 @@ export class AppCLIEntry {
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot')
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh web', resolveDshHome())
loadEnv('dsh', resolveDshHome())
}
/**
@@ -127,7 +133,7 @@ export class AppCLIEntry {
for (const [key, value] of Object.entries(this.readProfile())) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
if (mapping === undefined) {
throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
}
put(mapping.entryId, mapping.configKey, value)
}
@@ -142,7 +148,7 @@ export class AppCLIEntry {
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
}
@@ -173,8 +179,8 @@ export class AppCLIEntry {
* below catches PENDING fibers (cordis inject waiting has no timeout).
*/
private assertBoot(): void {
installFailLoud('dsh web')
assertEntriesLoaded(this.ctx, 'dsh web')
installFailLoud('dsh')
assertEntriesLoaded(this.ctx, 'dsh')
const failures: string[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
@@ -188,14 +194,14 @@ export class AppCLIEntry {
}
}
if (failures.length > 0) {
throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
private parseYmlRows(): Map<string, { config?: unknown }> {
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`)
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
const rows = new Map<string, { config?: unknown }>()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
@@ -214,7 +220,7 @@ export class AppCLIEntry {
}
const parsed: unknown = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
}
return parsed as Record<string, unknown>
}
@@ -225,7 +231,7 @@ export class AppCLIEntry {
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
}
}
}

View File

@@ -1,18 +1,20 @@
/**
* `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
* injection (InProcessApiClient over the host handler, so the full carrier
* chain — wire serialization, zod, SSE framing — really runs; this is the
* protocol's second real consumer). No HTTP server, no port, no dist
* resolution. Runs one task turn, prints the final assistant text, exits
* (completed → 0, else 1).
* `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
* boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
* collide), then in-process isomorphic injection (InProcessApiClient over
* toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
* serialization, zod, SSE framing — really runs). The printed URL opens the
* live session in a browser while the task runs. Runs one task turn, prints
* the final assistant text, exits (completed → 0, else 1).
*/
import { parseArgs } from 'node:util'
import { startHost } from '@deepseek-ai/dsh-host-runtime'
import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
import { fileURLToPath } from 'node:url'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise<void> {
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: false,
},
const entry = new AppCLIEntry({
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
dev: false,
port: 0,
})
const api = new InProcessApiClient(host.handler)
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), () => host.dispose())
const created = await unwrap(await api.sessions.create({}), dispose)
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), () => host.dispose())
}), dispose)
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await host.dispose()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -14,9 +14,6 @@
{
"path": "../../packages/host/apiproxy"
},
{
"path": "../../packages/host/runtime"
},
{
"path": "../../packages/host/webserver"
},

View File

@@ -2071,7 +2071,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))

View File

@@ -171,7 +171,6 @@ flowchart TD
end
subgraph group_host["packages/host"]
pkg_host_apiproxy["host-apiproxy"]
pkg_host_runtime["host-runtime"]
pkg_host_webserver["host-webserver"]
end
subgraph group_lsp["packages/lsp"]
@@ -238,7 +237,6 @@ flowchart TD
pkg_code_runtime --> pkg_invariants
pkg_jsonrpc_demo --> pkg_invariants
pkg_host_apiproxy --> pkg_invariants
pkg_host_runtime --> pkg_invariants
pkg_host_webserver --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_llm --> pkg_brand
@@ -808,7 +806,6 @@ flowchart TD
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |

View File

@@ -57,11 +57,6 @@
]
},
"packages/host/webserver": {
"project": [
"src/**/*.ts"
]
},
"packages/host/runtime": {
"entry": [
"tests/**/*.spec.ts"
],

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-apiproxy
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`.
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The composition lives in `apps/cli/cordis.yml` (the `api-gateway` row).
## Contract layer (`/api`)

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

127
pnpm-lock.yaml generated
View File

@@ -167,9 +167,6 @@ importers:
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../packages/host/apiproxy
'@deepseek-ai/dsh-host-runtime':
specifier: workspace:^
version: link:../../packages/host/runtime
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../packages/host/webserver
@@ -2245,130 +2242,6 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/host/runtime:
dependencies:
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@cordisjs/plugin-timer':
specifier: workspace:^
version: link:../../../vendor/timer
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../../bash/bash-local
'@deepseek-ai/dsh-compact-basic':
specifier: workspace:^
version: link:../../compact/compact-basic
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
'@deepseek-ai/dsh-fs-policy':
specifier: workspace:^
version: link:../../fs/fs-policy
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../apiproxy
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^
version: link:../../llm/llm-deepseek
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session-title/session-title
'@deepseek-ai/dsh-session-title-first-message-llm':
specifier: workspace:^
version: link:../../session-title/session-title-first-message-llm
'@deepseek-ai/dsh-skill':
specifier: workspace:^
version: link:../../skill/skill
'@deepseek-ai/dsh-skill-local':
specifier: workspace:^
version: link:../../skill/skill-local
'@deepseek-ai/dsh-spill-local':
specifier: workspace:^
version: link:../../spill/spill-local
'@deepseek-ai/dsh-spill-policy':
specifier: workspace:^
version: link:../../spill/spill-policy
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
'@deepseek-ai/dsh-subagent-fork':
specifier: workspace:^
version: link:../../subagent/subagent-fork
'@deepseek-ai/dsh-subagent-spawn':
specifier: workspace:^
version: link:../../subagent/subagent-spawn
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../tasks/tasks
'@deepseek-ai/dsh-timeout-policy':
specifier: workspace:^
version: link:../../timeout/timeout-policy
'@deepseek-ai/dsh-token-meter':
specifier: workspace:^
version: link:../../llm/token-meter
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../bash/tool-bash
'@deepseek-ai/dsh-tool-fs':
specifier: workspace:^
version: link:../../fs/tool-fs
'@deepseek-ai/dsh-tool-fs-search':
specifier: workspace:^
version: link:../../fs/tool-fs-search
'@deepseek-ai/dsh-tool-skill':
specifier: workspace:^
version: link:../../skill/tool-skill
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:^
version: link:../../subagent/tool-subagent
'@deepseek-ai/dsh-tool-tasks':
specifier: workspace:^
version: link:../../tasks/tool-tasks
'@deepseek-ai/dsh-tool-todo':
specifier: workspace:^
version: link:../../todo/tool-todo
'@deepseek-ai/dsh-tool-workflow':
specifier: workspace:^
version: link:../../workflow/tool-workflow
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../../ui/user-interaction
'@deepseek-ai/dsh-workflow-workerthread':
specifier: workspace:^
version: link:../../workflow/workflow-workerthread
'@deepseek-ai/dsh-workspace-context':
specifier: workspace:^
version: link:../../context/workspace-context
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
packages/host/webserver:
dependencies:
schemastery:

View File

@@ -66,7 +66,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },

View File

@@ -99,7 +99,6 @@
"@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"],
"@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"],
"@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"],
"@deepseek-ai/dsh-host-runtime": ["./packages/host/runtime/src"],
"@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
"@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
"@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],

View File

@@ -142,7 +142,6 @@
{ "path": "./packages/hooks/hooks-codex" },
{ "path": "./packages/mcp/mcp-client" },
{ "path": "./packages/host/apiproxy" },
{ "path": "./packages/host/runtime" },
{ "path": "./packages/host/webserver" },
{ "path": "./packages/sdk/helper" },
{ "path": "./packages/sdk/scripts" },

View File

@@ -123,6 +123,7 @@ export default defineConfig({
'packages/client/connection/src/http-bridge.ts',
'packages/host/apiproxy/src/index.ts',
'packages/host/apiproxy/src/invariant.ts',
'packages/host/apiproxy/src/api-proxy.ts',
...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
...windowsCoverageExclusions,
],