fix review findings: harden the app bins + built-bin smokes, arch-exception doc, snapshot fixture-guard
BLOCKER — the published lib/bin.js (stdio + acp) was exercised only via tsx
(demo:* / the src/bin.ts smokes); the built artifact under plain `node` was
unguarded. Root-cause on the BUILT bin:
1. Settle race: boot() returned once loader.create() registered the include
ENTRY, but the include loads its child plugins asynchronously — so boot()
(and main()) resolved while the app plugins (stdin reader, agent loop, ACP
bridge) were still mounting. A CLI with no attached handles yet exits 0
silently, and a load error surfaces as an unhandled rejection AFTER boot.
Fix: `await ctx.loader.await()` after create() — settle the whole tree.
2. Config-path robustness: hand the include the config's ABSOLUTE file:// URL
so resolution never depends on ctx.baseUrl / can never fall back to cwd.
Both bins fixed identically. NOTE: the cordis Loader resolves a config's bare
plugin specifiers via its internal module loader, active only under
`node --expose-internals`; the bin cannot add a node flag itself, so this is
documented in the bin JSDoc + both package READMEs (the demos already comply).
The repo `examples/*/cordis.yml` are tsx-only artifacts (workspace plugins
resolve through the tsconfig paths map, not node_modules), so they are not a
valid plain-node bin target — the smokes use a real-install-shaped temp dir.
Fail loud on a load failure: boot() previously exited 0 SILENTLY when a config
path's directory does not exist — the include plugin fails to IMPORT, the cordis
Loader catches+LOGS it and leaves the entry with no fiber (no rejection), and
`loader.await()` does not rethrow (EntryTree.await uses Promise.allSettled). Fix:
boot() now calls assertEntriesLoaded(ctx) after the tree settles and throws on
any entry with no fiber, so a typo'd config dir exits non-zero with a clear
message. main() also installs an unhandledRejection guard (installFailLoud) that
replaces Node's stack dump with a single labelled stderr line for the
companion case (a missing config FILE in a real dir, whose include-init throw
surfaces as a rejection Node already exits non-zero on). Regression tests added
to both built-bin smokes (missing dir + missing file → non-zero exit + stderr);
verified the missing-dir test fails on the pre-fix bin.
Built-bin smokes (the reviewer's ask): packages/ui/{stdio,acp}-agent/tests/
built-bin.e2e.ts run the REAL lib/bin.js under `node` (NOT tsx) in a temp
consumer dir, asserting the stdio echo round-trip / the acp initialize response
+ stdout purity, plus the fail-loud cases above. They build-gate (skip if lib/
absent) and run in a new ci.yml step after the build.
Issue 2 — packages/README.md + docs/architecture.md said "plugins depend on
interfaces, never on the concrete loop", but dsh-agent-core imports the concrete
dsh-agent-loop. Scope the rule to EXTENSION plugins and carve out the sanctioned
COMPOSITION/bundle exception (dsh-agent-core composes the concrete spine); note
it in the implemented RFC too.
Issue 3 — examples/acp-agent/tests/acp.snapshot.ts fixture-guard claimed
no-model scenarios need no session.jsonl, but runScenario() always boots
llm-replay with the session.jsonl path and loadReplayScript() throws when it is
absent. Require session.jsonl for ALL scenarios (no-model ones ship a
header-only fixture) and rewrite the comment to match reality.
This commit is contained in:
@@ -40,7 +40,7 @@ dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-json
|
||||
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
|
||||
```
|
||||
|
||||
The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
The rule: **extension** plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
## What goes where
|
||||
|
||||
|
||||
@@ -36,4 +36,6 @@ The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the
|
||||
- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);
|
||||
- in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit.
|
||||
|
||||
Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers through its internal module loader, active only under that flag. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.)
|
||||
|
||||
All diagnostics go to **stderr** — stdout is the protocol.
|
||||
|
||||
@@ -59,10 +59,70 @@ function loadEnv(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath`. `baseUrl` is pinned to the
|
||||
* config's directory and the include gets only the basename, so the config's
|
||||
* relative plugin/include paths resolve as the upstream `cordis` bin does.
|
||||
* Returns the root context.
|
||||
* Make a load failure fail loud with a clear message on stderr. Covers the
|
||||
* failure path the entry-tree check below cannot: when the include's
|
||||
* `[Service.init]` throws (e.g. a config FILE missing in a real directory), the
|
||||
* cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()`
|
||||
* resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses
|
||||
* `Promise.allSettled`, which swallows rejections). Node's default handler
|
||||
* already exits non-zero on an unhandled rejection, so this does not change the
|
||||
* exit code; it replaces the noisy stack dump with a single labelled line (on
|
||||
* STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`.
|
||||
* Install before `boot()`.
|
||||
*/
|
||||
export function installFailLoud(): void {
|
||||
process.on('unhandledRejection', (err: unknown) => {
|
||||
process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, assert every loader entry actually started. This is
|
||||
* the load-bearing guard against the SILENT-exit-0 bug: a plugin module that
|
||||
* fails to IMPORT (e.g. a config path in a non-existent directory) is caught and
|
||||
* only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no
|
||||
* `fiber` and producing no rejection — so the process would otherwise exit 0. A
|
||||
* started entry has a `fiber`; throw on any entry still missing one so `boot()`
|
||||
* rejects.
|
||||
*/
|
||||
function assertEntriesLoaded(ctx: Context): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath`. The include is handed the
|
||||
* config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on
|
||||
* `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to
|
||||
* the cwd. `baseUrl` is still pinned to the config's directory so the config's
|
||||
* OWN relative plugin/include paths resolve against it. Returns the root context
|
||||
* once the whole tree has settled.
|
||||
*
|
||||
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
|
||||
* the include ENTRY is registered, but the include then loads its child plugins
|
||||
* asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP
|
||||
* bridge is still mounting — the process would have no stdin handle attached yet
|
||||
* and could exit 0 silently. Awaiting keeps the process alive until the bridge
|
||||
* is up.
|
||||
*
|
||||
* `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses
|
||||
* `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails
|
||||
* to IMPORT leaves an entry with no fiber, caught here by
|
||||
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS
|
||||
* surfaces as an unhandled rejection caught by {@link installFailLoud} (installed
|
||||
* by `main()` before this runs). Together any load failure exits non-zero.
|
||||
*
|
||||
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
|
||||
* resolved by the cordis Loader's internal module loader, which is only active
|
||||
* under `node --expose-internals`. The `demo:acp` script runs under tsx (whose
|
||||
* tsconfig `paths` map resolves the workspace plugins instead), but a consumer
|
||||
* running the built bin under plain node must pass `--expose-internals` so the
|
||||
* Loader resolves the config's plugins from the config directory rather than
|
||||
* relative to its own module.
|
||||
*/
|
||||
export async function boot(absoluteConfigPath: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
@@ -70,19 +130,23 @@ export async function boot(absoluteConfigPath: string): Promise<Context> {
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.loader.create({
|
||||
name: '@cordisjs/plugin-include',
|
||||
config: { path: `./${basename(absoluteConfigPath)}` },
|
||||
config: { path: pathToFileURL(absoluteConfigPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point. Selects the config (snapshot-aware), loads `.env` outside replay,
|
||||
* boots, and — in a snapshot run — disposes the context on stdin EOF so the
|
||||
* session log is fully flushed before exit and the harness's `waitForExit`
|
||||
* resolves. In a normal editor session stdin stays open for the connection's
|
||||
* lifetime (the editor kills the process), so the EOF handler never fires.
|
||||
* Entry point. Installs the fail-loud guard, selects the config (snapshot-aware),
|
||||
* loads `.env` outside replay, boots, and — in a snapshot run — disposes the
|
||||
* context on stdin EOF so the session log is fully flushed before exit and the
|
||||
* harness's `waitForExit` resolves. In a normal editor session stdin stays open
|
||||
* for the connection's lifetime (the editor kills the process), so the EOF
|
||||
* handler never fires.
|
||||
*/
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
installFailLoud()
|
||||
const snapshotMode = process.env.DSH_SNAPSHOT
|
||||
const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode)
|
||||
if (snapshotMode !== 'replay') loadEnv()
|
||||
|
||||
187
packages/ui/acp-agent/tests/built-bin.e2e.ts
Normal file
187
packages/ui/acp-agent/tests/built-bin.e2e.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* BUILT-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts`
|
||||
* boots `src/bin.ts` under tsx — but the package's `bin` field points at
|
||||
* `lib/bin.js`, run under plain `node` by a real consumer. This runs the REAL
|
||||
* `lib/bin.js` under `node` (NOT tsx) and asserts it answers an `initialize`
|
||||
* JSON-RPC frame, so a regression in the published entry (a settle race that
|
||||
* exits before the bridge attaches, a stdout logger leaking onto the protocol)
|
||||
* fails here.
|
||||
*
|
||||
* It build-gates: SKIPS if `lib/bin.js` is absent (suite run without
|
||||
* `pnpm run build`); CI runs it after the build step. Setup mirrors a real
|
||||
* install (a temp dir whose `node_modules` symlinks the built packages) and runs
|
||||
* `node --expose-internals` (the cordis Loader resolves bare plugin specifiers
|
||||
* via its internal module loader, active only under that flag). KEYLESS:
|
||||
* `initialize` never reaches the model; a dummy key lets `llm-deepseek` boot.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
|
||||
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
'schemastery', 'cosmokit',
|
||||
]
|
||||
// Third-party deps the ACP bridge needs (resolved from the acp package's own
|
||||
// node_modules and linked into the consumer so plain node finds them).
|
||||
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
|
||||
|
||||
async function pkgName(absDir: string): Promise<string> {
|
||||
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
||||
return json.name
|
||||
}
|
||||
|
||||
async function link(target: string, name: string, nm: string): Promise<void> {
|
||||
const dest = join(nm, name)
|
||||
await mkdir(dirname(dest), { recursive: true })
|
||||
await symlink(target, dest)
|
||||
}
|
||||
|
||||
/** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */
|
||||
async function makeConsumer(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of dshPackages) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
await link(abs, await pkgName(abs), nm)
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
await link(abs, await pkgName(abs), nm)
|
||||
}
|
||||
for (const dep of npmDeps) {
|
||||
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`))
|
||||
await link(dirname(resolved), dep, nm)
|
||||
}
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: llm-deepseek',
|
||||
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
|
||||
' config:',
|
||||
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
|
||||
' models: [deepseek-v4-flash]',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: acp-agent',
|
||||
' name: \'@deepseek-ai/dsh-acp-agent\'',
|
||||
' config:',
|
||||
' model: deepseek-v4-flash',
|
||||
' systemPrompt: \'test agent\'',
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
let child: ReturnType<typeof spawn> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined) { child.kill('SIGKILL'); child = undefined }
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
|
||||
consumer = await makeConsumer()
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const stderr: string[] = []
|
||||
child.stderr!.setEncoding('utf8')
|
||||
child.stderr!.on('data', (c: string) => stderr.push(c))
|
||||
// Tee raw stdout for a protocol-purity check, and feed it to the SDK client.
|
||||
const rawOut: string[] = []
|
||||
const passthrough = new Readable({ read() {} })
|
||||
child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) })
|
||||
child.stdout!.on('end', () => passthrough.push(null))
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const makeClient = (_a: AcpAgent): Client => ({
|
||||
sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
|
||||
requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
|
||||
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// A response at all proves the built bin booted the bridge (the settle-race
|
||||
// regression would exit before answering); loadSession proves the real app
|
||||
// mounted, not a collapsed export shape.
|
||||
expect(init.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(stderr.join('')).not.toContain('without inject')
|
||||
// stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
|
||||
for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
|
||||
expect(() => JSON.parse(line) as unknown).not.toThrow()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
|
||||
// A typo'd config path must fail clearly, not exit 0. The include plugin
|
||||
// itself cannot be imported from a non-existent dir; the Loader logs that and
|
||||
// leaves the entry with no fiber, which boot()'s entry-load check throws on.
|
||||
const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('failed to load')
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
|
||||
// The directory exists (the include imports), but the file does not — the
|
||||
// include's init throws "config file not found", which surfaces as an
|
||||
// unhandled rejection the fail-loud guard turns into a non-zero exit.
|
||||
consumer = await makeConsumer()
|
||||
const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer)
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
|
||||
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
||||
cwd,
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stderr = ''
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
|
||||
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
|
||||
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
proc.stdin.end()
|
||||
})
|
||||
}
|
||||
@@ -31,7 +31,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config — the boot glue the `examples/*/start.ts` files once each duplicated. The `demo:echo` / `demo:coding` scripts invoke it.
|
||||
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way.
|
||||
|
||||
## Example leaf `cordis.yml`
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
@@ -38,10 +38,72 @@ function loadEnv(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `configPath` (resolved from the CWD). `baseUrl` is
|
||||
* pinned to the config's directory and the include is handed only the basename,
|
||||
* so the config's relative plugin/include paths resolve exactly as the upstream
|
||||
* `cordis` bin does. Returns the root context (the process owns its lifetime).
|
||||
* Make a load failure fail loud with a clear message on stderr. Covers the
|
||||
* failure path the entry-tree check below cannot: when the include's
|
||||
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
|
||||
* directory), the cordis Loader surfaces it as an unhandled promise rejection
|
||||
* AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because
|
||||
* `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections.
|
||||
* Node's default handler already exits non-zero on an unhandled rejection, so
|
||||
* this does not change the exit code; it replaces Node's noisy stack dump with a
|
||||
* single labelled line and guarantees `process.exit(1)`. Install before `boot()`.
|
||||
*/
|
||||
export function installFailLoud(): void {
|
||||
process.on('unhandledRejection', (err: unknown) => {
|
||||
process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, assert every loader entry actually started. This is
|
||||
* the load-bearing guard against the SILENT-exit-0 bug: when a plugin module
|
||||
* fails to IMPORT (e.g. a config path in a non-existent directory, so the include
|
||||
* plugin itself cannot be resolved), the cordis Loader catches the import error
|
||||
* and only LOGS it (`entry._init`), leaving the entry with no `fiber` and
|
||||
* producing no rejection — so the process would otherwise exit 0 with a usable
|
||||
* config typo reported only as a log line. A started entry has a `fiber`; an
|
||||
* entry with `fiber === undefined` after the tree settled never loaded. Throw on
|
||||
* any such entry so `boot()` rejects (and the top-level `await` fails the process
|
||||
* non-zero) instead of returning a half-empty context.
|
||||
*/
|
||||
function assertEntriesLoaded(ctx: Context): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `configPath` (resolved from the CWD). The include is
|
||||
* handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never
|
||||
* depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall
|
||||
* back to the cwd. `baseUrl` is still pinned to the config's directory so the
|
||||
* config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve
|
||||
* against it. Returns the root context once the whole tree has settled.
|
||||
*
|
||||
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
|
||||
* the include ENTRY is registered, but the include then loads its child plugins
|
||||
* asynchronously. Without awaiting the tree, `boot()` (and `main()`) would
|
||||
* resolve while the app plugins — the stdin reader, the agent loop — are still
|
||||
* mounting, and a CLI process with no attached handles yet exits 0 silently.
|
||||
* Awaiting the tree keeps the process alive until the app's handles are attached.
|
||||
*
|
||||
* `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()`
|
||||
* uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that
|
||||
* fails to IMPORT leaves an entry with no fiber, caught here by
|
||||
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init
|
||||
* THROWS surfaces as an unhandled rejection caught by {@link installFailLoud}
|
||||
* (installed by `main()` before this runs). Together they make any load failure
|
||||
* exit non-zero with a clear message.
|
||||
*
|
||||
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
|
||||
* resolved by the cordis Loader's internal module loader, which is only active
|
||||
* under `node --expose-internals` (the flag the `demo:echo`/`demo:coding` scripts
|
||||
* pass). Without it the Loader falls back to resolving relative to its own module
|
||||
* and cannot find the config's plugins, so a consumer running the built bin must
|
||||
* pass `--expose-internals` (or install the plugins where node hoists them).
|
||||
*/
|
||||
export async function boot(configPath: string): Promise<Context> {
|
||||
const absolute = resolve(process.cwd(), configPath)
|
||||
@@ -50,17 +112,20 @@ export async function boot(configPath: string): Promise<Context> {
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.loader.create({
|
||||
name: '@cordisjs/plugin-include',
|
||||
config: { path: `./${basename(absolute)}` },
|
||||
config: { path: pathToFileURL(absolute).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point: load `.env`, then boot the config named on argv (default
|
||||
* `./cordis.yml`). Awaited at the module top level by the published bin
|
||||
* (`#!/usr/bin/env node` shebang via the package's `bin` field).
|
||||
* Entry point: install the fail-loud guard, load `.env`, then boot the config
|
||||
* named on argv (default `./cordis.yml`). Awaited at the module top level by the
|
||||
* published bin (`#!/usr/bin/env node` shebang via the package's `bin` field).
|
||||
*/
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
installFailLoud()
|
||||
loadEnv()
|
||||
await boot(argv[0] ?? './cordis.yml')
|
||||
}
|
||||
|
||||
166
packages/ui/stdio-agent/tests/built-bin.e2e.ts
Normal file
166
packages/ui/stdio-agent/tests/built-bin.e2e.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { cp, mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* BUILT-ARTIFACT smoke for the published `dsh-stdio-agent` bin. The other smokes
|
||||
* boot `src/bin.ts` under tsx — but the package's `bin` field points at
|
||||
* `lib/bin.js`, run under plain `node` by a real consumer. tsx masks two failure
|
||||
* modes the built bin had: (1) `boot()` returned before the loader tree settled,
|
||||
* so the process exited 0 with no output and load errors surfaced as unhandled
|
||||
* rejections AFTER boot; (2) config-path resolution could fall back to the cwd.
|
||||
* This test runs the REAL `lib/bin.js` under `node` (NOT tsx) and asserts the
|
||||
* banner + echo round-trip, so a regression in the published entry fails here.
|
||||
*
|
||||
* It build-gates: if `lib/bin.js` is absent (suite run without `pnpm run build`)
|
||||
* the test SKIPS with a note. CI runs it after the build step. Setup mirrors a
|
||||
* real install: a temp dir whose `node_modules/@deepseek-ai/*` (and the vendored
|
||||
* `cordis`/`@cordisjs/*`) are symlinked to the built packages, a `cordis.yml`
|
||||
* that loads the app + the example's mock backend, and `node --expose-internals`
|
||||
* (the cordis Loader resolves bare plugin specifiers via its internal module
|
||||
* loader, active only under that flag — the same flag `demo:echo` passes).
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
|
||||
|
||||
// Workspace packages the stdio app's tree needs, by repo-relative path. Each is
|
||||
// symlinked into the temp consumer's node_modules under its package name, so
|
||||
// plain `node` resolves the bare `@deepseek-ai/dsh-*` specifiers in cordis.yml
|
||||
// to the built `lib/` (package.json `main`), exactly as an installed dep would.
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'support/invariants', 'support/ui-stdio',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
'schemastery', 'cosmokit',
|
||||
]
|
||||
|
||||
async function pkgName(absDir: string): Promise<string> {
|
||||
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
||||
return json.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temp consumer dir: `node_modules` with the workspace + vendor packages
|
||||
* symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml`
|
||||
* that wires them onto the stdio app. Returns the dir (caller removes it).
|
||||
*/
|
||||
async function makeConsumer(welcome: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of dshPackages) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
// The example's mock model + echo tool are example-local TS plugins (Node 24+
|
||||
// strips types natively, so plain `node` loads them); they import the workspace
|
||||
// packages the symlinked node_modules now provides.
|
||||
await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
' name: \'./src/mock-llm.ts\'',
|
||||
'- id: echo-tool',
|
||||
' name: \'./src/echo-tool.ts\'',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: stdio-agent',
|
||||
' name: \'@deepseek-ai/dsh-stdio-agent\'',
|
||||
' config:',
|
||||
' model: mock-echo',
|
||||
' systemPrompt: \'demo\'',
|
||||
` welcome: '${welcome}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */
|
||||
function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// --expose-internals: the cordis Loader resolves bare plugin specifiers via
|
||||
// its internal module loader (active only under this flag); demo:echo passes
|
||||
// it too. NO tsx — this is the published `node lib/bin.js` path.
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
env: { ...process.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => { stdout += c })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
|
||||
child.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
child.stdin.write(`${line}\n`)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => {
|
||||
consumer = await makeConsumer('BUILT-BIN-OK ready.')
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stderr).not.toContain('without inject')
|
||||
// The banner proves boot() awaited the tree (the settle-race regression would
|
||||
// exit 0 with empty stdout); the round-trip proves the whole app mounted.
|
||||
expect(stdout).toContain('BUILT-BIN-OK ready.')
|
||||
expect(stdout).toContain('[tool call] echo')
|
||||
expect(stdout).toContain('[tool result] ECHO: HI')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
|
||||
// A consumer who typos the config path must get a clear failure, not silent
|
||||
// success. This dir does not exist, so the include PLUGIN itself fails to
|
||||
// import; the cordis Loader logs that and leaves the entry with no fiber (no
|
||||
// rejection), which `boot()`'s entry-load check turns into a thrown error.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('failed to load')
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
|
||||
// The config DIRECTORY exists (the include plugin imports), but the file does
|
||||
// not — the include's init throws "config file not found", which surfaces as
|
||||
// an unhandled rejection the fail-loud guard turns into a non-zero exit.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
})
|
||||
Reference in New Issue
Block a user