Merge branch 'master' into worktree/dsbench-patch

This commit is contained in:
Yichen Jiang
2026-07-19 15:24:07 +08:00
committed by GitHub
63 changed files with 2471 additions and 52 deletions

View File

@@ -31,8 +31,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table.

View File

@@ -6,10 +6,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with terminal and ACP front-door clusters and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.

View File

@@ -0,0 +1,66 @@
# @deepseek-ai/dsh-cli-demo
Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits.
The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
## Config
| Key | Default | Routed to |
|---|---|---|
| `provider` | required | the configured agent's provider route |
| `model` | required | the configured agent's model |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap; `1` is serial |
| `persona` | — | the deployment persona in `dsh-system-prompt` |
| `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
| `toolTasks` | owner defaults | generic `task_output` wait bounds |
| `persistenceRoot` | `./.sessions` | JSONL session root |
| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading |
## CLI contract
```sh
dsh-cli-demo [--config path] [--output-format text|json|stream-json] <task>
```
`--config` defaults to `./cordis.yml`; `--output-format` defaults to `text`. Exactly one nonblank positional task is required, so quote tasks containing spaces. `--help` prints usage without booting. There is no `-p` or `--print` flag.
The root headless-agent example supplies its leaf:
```sh
pnpm run demo:headless -- "inspect the failing test and fix it"
```
Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag.
### Output formats
- `text` writes the last assistant message containing text, followed by one newline.
- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums every model step in the task turn.
- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results.
Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively.
The task turn is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits.
## Operational safety
The headless-agent leaf supplies local bash, filesystem, skill, subagent, workflow, and todo capabilities. A task can therefore mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. Run the CLI from the intended project directory, review the leaf's capability and sandbox configuration, and do not treat non-interactive execution as an approval boundary.
## Model Experience
### One-shot task turn
**What the model sees**: The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn.
**Token effect**: The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total.
## Known Limitations and Deferred Work
- **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app.
- **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy.
- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn.

View File

@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-cli-demo",
"description": "Headless one-shot agent app with text and DSH-native JSON output",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-cli-demo": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
}
}

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Process wrapper for `dsh-cli-demo`; covered parsing and task execution live in
* `cli.ts` while this entry owns Unix signal-to-exit-code mapping.
* @module @deepseek-ai/dsh-cli-demo/bin
*/
import { installFailLoud } from '@deepseek-ai/dsh-app-boot'
import { executeCli } from './cli.ts'
const NAME = 'dsh-cli-demo'
/* v8 ignore start -- thin self-executing process glue; built-bin tests exercise
real argv, signals, Loader boot, output, and exit codes */
const abort = new AbortController()
let signalExitCode: number | undefined
const interrupt = (signal: 'SIGINT' | 'SIGTERM', code: number): void => {
signalExitCode ??= code
if (!abort.signal.aborted) abort.abort(`received ${signal}`)
}
const onSigint = (): void => { interrupt('SIGINT', 130) }
const onSigterm = (): void => { interrupt('SIGTERM', 143) }
const uninstallFailLoud = installFailLoud(NAME)
process.on('SIGINT', onSigint)
process.on('SIGTERM', onSigterm)
try {
const code = await executeCli(process.argv.slice(2), { signal: abort.signal })
process.exitCode = signalExitCode ?? code
} finally {
process.off('SIGINT', onSigint)
process.off('SIGTERM', onSigterm)
uninstallFailLoud()
}
/* v8 ignore stop */

View File

@@ -0,0 +1,447 @@
/**
* Command parser and one-turn driver for `dsh-cli-demo`. The executable wrapper
* owns process signals; this module owns output, durability, and cleanup.
* @module @deepseek-ai/dsh-cli-demo/cli
*/
import { parseArgs } from 'node:util'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const CLI_NAME = 'dsh-cli-demo'
const DEFAULT_CONFIG_PATH = './cordis.yml'
const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] <task>\n`
/** Supported CLI output encodings. */
export type OutputFormat = typeof OUTPUT_FORMATS[number]
/** Parsed command: help exits before boot; run carries one validated task. */
export type CliCommand =
| { readonly kind: 'help' }
| {
readonly kind: 'run'
readonly configPath: string
readonly outputFormat: OutputFormat
readonly task: string
}
/** DSH-native final record emitted by JSON modes. */
export interface CliResult {
readonly type: 'result'
readonly success: boolean
readonly sessionId: string
readonly turn: number
readonly result: string
readonly reason: TurnEndReason
readonly usage?: TokenUsage
}
/** Options for one turn against the configured top-level agent. */
export interface OneShotOptions {
/** Exactly one nonblank user task. */
readonly task: string
/** Optional signal that cancels the selected agent. */
readonly signal?: AbortSignal
/** Synchronous task-turn observer; a throw cancels the agent and fails the run after flush. */
readonly onEvent?: (sessionId: string, event: SessionEvent) => void
}
/** Injectable process boundaries used by {@link executeCli}. */
export interface CliRuntime {
/** Process cwd for config resolution and `.env` loading. */
readonly cwd?: string
/** Cancellation signal, normally aborted by SIGINT or SIGTERM. */
readonly signal?: AbortSignal
/** Loader boot boundary. */
readonly boot?: (name: string, absoluteConfigPath: string) => Promise<Context>
/** Optional `.env` loader boundary. */
readonly loadEnv?: (name: string, dir: string, warn: (line: string) => void) => void
/** Stdout sink; throws are treated as output failures. */
readonly writeStdout?: (chunk: string) => unknown
/** Stderr diagnostic sink. */
readonly writeStderr?: (chunk: string) => unknown
/** Context disposal boundary. */
readonly dispose?: (ctx: Context) => Promise<void>
}
interface ParsedArguments {
readonly values: {
readonly config?: string
readonly 'output-format'?: string
readonly help?: boolean
}
readonly positionals: string[]
}
class CliArgumentError extends Error {
constructor(message: string) {
super(message)
this.name = 'CliArgumentError'
}
}
class CliInterruptedError extends Error {
constructor(reason: string) {
super(reason)
this.name = 'CliInterruptedError'
}
}
/** Render an arbitrary value without trusting its type traps or string coercion. */
function renderUnknown(value: unknown): string {
try {
return String(value)
} catch {
return '[unrenderable thrown value]'
}
}
/** Normalize an arbitrary thrown value without letting inspection escape containment. */
function toError(error: unknown): Error {
try {
if (error instanceof Error) return error
} catch {
// A hostile proxy may throw during instanceof; use the total renderer below.
}
return new Error(renderUnknown(error))
}
function interruptionReason(signal: AbortSignal): string {
return signal.reason === undefined ? 'interrupted' : renderUnknown(signal.reason)
}
/**
* Parse the bin arguments and enforce the one-positional-task contract.
* @param args - arguments after the executable name.
* @returns a help or run command.
* @throws {@link CliArgumentError} for unknown flags, invalid formats, or task cardinality.
*/
export function parseCliArgs(args: readonly string[]): CliCommand {
let parsed: ParsedArguments
try {
parsed = parseArgs({
args: [...args],
options: {
config: { type: 'string' },
'output-format': { type: 'string' },
help: { type: 'boolean' },
},
allowPositionals: true,
strict: true,
})
} catch (error: unknown) {
throw new CliArgumentError(toError(error).message)
}
if (parsed.values.help === true) return { kind: 'help' }
if (parsed.positionals.length !== 1) {
throw new CliArgumentError(`expected exactly one positional task, received ${parsed.positionals.length}`)
}
// Cardinality was checked above, so index zero exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const task = parsed.positionals[0]!
if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
const requestedFormat = parsed.values['output-format'] ?? 'text'
if (!OUTPUT_FORMATS.some(format => format === requestedFormat)) {
throw new CliArgumentError(`unsupported output format ${JSON.stringify(requestedFormat)}`)
}
return {
kind: 'run',
configPath: parsed.values.config ?? DEFAULT_CONFIG_PATH,
outputFormat: requestedFormat as OutputFormat,
task,
}
}
function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage {
const next: TokenUsage = {
inputTokens: (total?.inputTokens ?? 0) + step.inputTokens,
outputTokens: (total?.outputTokens ?? 0) + step.outputTokens,
}
for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const) {
if (total?.[key] !== undefined || step[key] !== undefined) next[key] = (total?.[key] ?? 0) + (step[key] ?? 0)
}
return next
}
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string | undefined {
const blocks = event.data.content.filter(block => block.type === 'text')
return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('')
}
/** Wait for startup quiescence while making pre-run cancellation terminal. */
async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<void> {
if (signal === undefined) {
await agent.whenIdle()
return
}
if (signal.aborted) {
agent.cancel(interruptionReason(signal))
throw new CliInterruptedError(interruptionReason(signal))
}
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
agent.cancel(interruptionReason(signal))
reject(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
void agent.whenIdle().then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort)
})
})
}
/**
* Run one message-triggered turn on the configured top-level agent, aggregate its
* final text and model usage, wait for idle plus an explicit persistence flush,
* and return its durable ending. Only the selected agent's task turn reaches
* `onEvent`; startup injections and unrelated sessions are ignored. The context
* must contain exactly one top-level agent. Signal abort cancels that agent; an
* abort before the correlated task turn rejects. An observer throw cancels the
* turn and is rethrown after the agent reaches idle and the session flushes.
* @param ctx - settled Loader root containing one agent plus `ctx.sessions`.
* @param options - task, optional cancellation, and optional stream observer.
* @returns the DSH-native result envelope after durable quiescence.
*/
export async function runOneShot(ctx: Context, options: OneShotOptions): Promise<CliResult> {
const agents = ctx.get('agents')?.roots() ?? []
const [agent] = agents
if (agent === undefined || agents.length !== 1) {
throw new Error(`config must create exactly one top-level agent, found ${agents.length}`)
}
await waitForStartupIdle(agent, options.signal)
let targetTurn: number | undefined
let reason: TurnEndReason | undefined
let result = ''
let usage: TokenUsage | undefined
let outputError: Error | undefined
let resolveTurn!: () => void
let rejectTurn!: (error: Error) => void
let settled = false
const turnEnded = new Promise<void>((resolve, reject) => {
resolveTurn = resolve
rejectTurn = reject
})
const settleResolved = (): void => {
settled = true
resolveTurn()
}
const settleRejected = (error: Error): void => {
settled = true
rejectTurn(error)
}
const observe = (sessionId: string, event: SessionEvent): void => {
if (outputError !== undefined || options.onEvent === undefined) return
try {
options.onEvent(sessionId, event)
} catch (error: unknown) {
outputError = toError(error)
agent.cancel('stream output failed')
}
}
const disposeListener = ctx.on('session/event', (session, event) => {
if (session !== agent.session || settled) return
if (targetTurn === undefined) {
if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return
targetTurn = event.data.turn
}
observe(session.id, event)
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
result = assistantText(event) ?? result
if (event.data.usage !== undefined) usage = addUsage(usage, event.data.usage)
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
reason = event.data.reason
settleResolved()
}
})
const signal = options.signal
let onAbort: (() => void) | undefined
if (signal !== undefined) {
onAbort = (): void => {
agent.cancel(interruptionReason(signal))
if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes the race between startup-idle completion and listener registration */
if (signal.aborted) onAbort()
}
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.send([{ type: 'text', text: options.task }])
}
await turnEnded
} finally {
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
disposeListener()
await agent.whenIdle()
}
/* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */
if (targetTurn === undefined || reason === undefined) {
throw new Error('task ended without a correlated turn/end event')
}
await ctx.sessions.flush(agent.session)
if (outputError !== undefined) throw outputError
return {
type: 'result',
success: reason.kind === 'completed',
sessionId: agent.session.id,
turn: targetTurn,
result,
reason,
...usage === undefined ? {} : { usage },
}
}
function renderResult(outputFormat: OutputFormat, result: CliResult): string {
return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n`
}
/**
* Race Loader boot with cancellation without abandoning a context that becomes
* available after the caller has been released. Waiting for that late context
* would recreate the signal hang, so its disposal and diagnostics run detached.
*/
async function bootInterruptibly(
start: () => Promise<Context>,
signal: AbortSignal | undefined,
disposeLateContext: (ctx: Context) => Promise<void>,
reportLateDisposalFailure: (error: unknown) => void,
): Promise<Context> {
if (signal === undefined) return await start()
if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal))
let onAbort!: () => void
const interruptedBoot = new Promise<never>((_resolve, reject) => {
onAbort = (): void => {
reject(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes registration against a non-standard synchronously mutating signal */
if (signal.aborted) onAbort()
})
const booting = Promise.resolve().then(start)
try {
return await Promise.race([booting, interruptedBoot])
} catch (error: unknown) {
// The awaited race permits the signal to change after the preflight check.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) {
void booting.then(
async (lateContext) => {
try {
await disposeLateContext(lateContext)
} catch (error: unknown) {
reportLateDisposalFailure(error)
}
},
() => {},
)
}
throw error
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/**
* Render a non-completed turn reason for stderr.
* @param reason - durable turn ending to describe.
* @returns a concise diagnostic fragment.
*/
export function formatTurnFailure(reason: TurnEndReason): string {
switch (reason.kind) {
case 'completed': return 'completed'
case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}`
case 'error': return `failed at step ${reason.step}: ${reason.message}`
case 'disposed': return 'was disposed'
case 'max-tokens': return 'reached the model output-token limit'
case 'rejected': return `was rejected: ${reason.reason}`
case 'interrupted': return 'was interrupted during persistence recovery'
default: return `ended with ${JSON.stringify(reason)}`
}
}
/**
* Execute one CLI invocation. Argument and boot failures never write stdout;
* context disposal is awaited before return, and its failure does not replace
* an earlier diagnostic.
* @param args - arguments after the executable name.
* @param runtime - optional injected process boundaries for tests and embedding.
* @returns the ordinary process exit code; the thin bin overrides it for Unix signals.
*/
export async function executeCli(args: readonly string[], runtime: CliRuntime = {}): Promise<number> {
/* v8 ignore next -- default process sinks are exercised by the built-bin smoke */
const writeStdout = runtime.writeStdout ?? (chunk => process.stdout.write(chunk))
/* v8 ignore next -- default process sinks are exercised by the built-bin smoke */
const writeStderr = runtime.writeStderr ?? (chunk => process.stderr.write(chunk))
let command: CliCommand
try {
command = parseCliArgs(args)
} catch (error: unknown) {
writeStderr(`${CLI_NAME}: ${toError(error).message}\n${USAGE}`)
return 1
}
if (command.kind === 'help') {
writeStdout(USAGE)
return 0
}
/* v8 ignore next -- default process cwd is exercised by the built-bin smoke */
const cwd = runtime.cwd ?? process.cwd()
/* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */
const loadEnvironment = runtime.loadEnv ?? loadEnv
/* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */
const bootContext = runtime.boot ?? boot
/* v8 ignore next -- default disposal is exercised by the built-bin smoke */
const disposeContext = runtime.dispose ?? (target => target.fiber.dispose())
let ctx: Context | undefined
let exitCode = 1
let diagnostic: string | undefined
try {
loadEnvironment(CLI_NAME, cwd, line => writeStderr(line))
ctx = await bootInterruptibly(
() => bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd)),
runtime.signal,
disposeContext,
error => writeStderr(`${CLI_NAME}: dispose after interrupted boot failed: ${toError(error).message}\n`),
)
const result = await runOneShot(ctx, {
task: command.task,
...runtime.signal === undefined ? {} : { signal: runtime.signal },
...command.outputFormat === 'stream-json'
? { onEvent: (sessionId: string, event: SessionEvent) => {
writeStdout(`${JSON.stringify({ type: 'session_event', sessionId, event })}\n`)
} }
: {},
})
writeStdout(renderResult(command.outputFormat, result))
exitCode = result.success ? 0 : 1
if (!result.success) diagnostic = `${CLI_NAME}: turn ${result.turn} ${formatTurnFailure(result.reason)}\n`
} catch (error: unknown) {
diagnostic = `${CLI_NAME}: ${toError(error).message}\n`
} finally {
if (ctx !== undefined) {
try {
await disposeContext(ctx)
} catch (error: unknown) {
diagnostic = `${diagnostic ?? ''}${CLI_NAME}: dispose failed: ${toError(error).message}\n`
exitCode = 1
}
}
}
if (diagnostic !== undefined) writeStderr(diagnostic)
return exitCode
}

View File

@@ -0,0 +1,82 @@
/**
* Headless one-shot app composition: the default agent spine, JSONL session
* persistence, and one fresh top-level agent. The CLI driver owns task
* submission and output; the app deliberately mounts no interactive or logging
* front door so stdout remains protocol-pure.
* @module @deepseek-ai/dsh-cli-demo
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
export const name = 'cli-demo'
/** App config forwarded to the spine, configured agent, and JSONL backend. */
export interface Config {
/** Provider route for the configured agent. */
provider: string
/** Model name for the configured agent; a matching adapter must be registered. */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona forwarded to the system-prompt plugin. */
persona?: string
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
toolOrder?: string[]
/** Tool-registry presentation config forwarded through agent-spine-demo. */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-spine-demo. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-spine-demo. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
// Each front door keeps a complete Loader schema so its deployment contract is
// readable without a cross-package config facade.
/* jscpd:ignore-start */
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persona: z.string(),
dshHome: z.string(),
skills: agentCore.SkillConfigSchema,
// Absent means lexicographic order; schemastery's native array default is [].
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: agentCore.ToolTasksConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */
/**
* Compose the UI-less spine, a fresh top-level agent rooted at the process cwd,
* and JSONL persistence. Swappable adapters, executors, and product tools stay
* in the leaf `cordis.yml`.
* @param ctx - app context that owns the composed child plugins.
* @param config - validated app configuration.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }],
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
}

View File

@@ -0,0 +1,177 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
const dshPackages = [
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
'context/workspace-context',
]
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
async function packageName(dir: string): Promise<string> {
return (JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name: string }).name
}
async function linkPackage(dir: string, nodeModules: string): Promise<void> {
const target = join(nodeModules, await packageName(dir))
await mkdir(dirname(target), { recursive: true })
await symlink(dir, target)
}
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'cli-built-bin-'))
const nodeModules = join(dir, 'node_modules')
for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
await writeFile(join(dir, 'mock-llm.mjs'), [
"import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
'class Mock extends LlmAdapter {',
' async * stream(options) {',
" const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
" yield { type: 'block-start', index: 0, blockType: 'text' }",
" if (text === 'hang') {",
" yield { type: 'text-delta', index: 0, text: 'partial' }",
' await new Promise((resolve, reject) => {',
" const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
" const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
' if (options.signal.aborted) onAbort()',
" else options.signal.addEventListener('abort', onAbort, { once: true })",
' })',
' return',
' }',
' const reply = `BUILT: ${text}`',
" yield { type: 'text-delta', index: 0, text: reply }",
" yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }",
" yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }",
" yield { type: 'finish', reason: { kind: 'stop' } }",
' }',
'}',
"export const name = 'built-cli-mock'",
"export const inject = ['llm']",
"export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
'',
].join('\n'))
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.mjs'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',
" name: '@deepseek-ai/dsh-cli-demo'",
' config:',
' provider: built-cli-mock',
' model: built-cli-mock',
" persona: 'built CLI test'",
" persistenceRoot: './.sessions'",
' workspaceContext: false',
'',
].join('\n'))
return dir
}
interface BinResult {
readonly code: number
readonly signal: NodeJS.Signals | null
readonly stdout: string
readonly stderr: string
}
function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, ['--expose-internals', cliBin, ...args], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let interrupted = false
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
interrupted = true
child.kill(interrupt)
}
})
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.once('error', (error) => { clearTimeout(timer); reject(error) })
child.once('exit', (code, signal) => {
clearTimeout(timer)
resolveResult({ code: code ?? -1, signal, stdout, stderr })
})
})
}
let consumer: string | undefined
afterEach(async () => {
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
consumer = undefined
})
describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
it('runs text, json, and stream-json under plain Node and persists fresh sessions', async () => {
consumer = await makeConsumer()
const text = await runBuiltBin(consumer, ['--config', './cordis.yml', 'hello'])
expect(text).toMatchObject({ code: 0, signal: null, stdout: 'BUILT: hello\n', stderr: '' })
const json = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'json', 'json task'])
expect(JSON.parse(json.stdout)).toMatchObject({
type: 'result', success: true, result: 'BUILT: json task', reason: { kind: 'completed' },
usage: { inputTokens: 4, outputTokens: 2 },
})
const stream = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'stream-json', 'stream task'])
const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } })
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' })
const files = await readdir(join(consumer, '.sessions'), { recursive: true })
expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(3)
}, 30_000)
it('keeps stdout empty for invalid argv and missing config', async () => {
consumer = await makeConsumer()
for (const args of [
['--config', './cordis.yml'],
['--config', './cordis.yml', 'one', 'two'],
['--config', './missing.yml', 'task'],
]) {
const result = await runBuiltBin(consumer, args)
expect(result.code).not.toBe(0)
expect(result.stdout).toBe('')
expect(result.stderr.length).toBeGreaterThan(0)
}
}, 30_000)
describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => {
it.each([
['SIGINT', 130],
['SIGTERM', 143],
] as const)('cancels and disposes on %s with exit %i', async (signal, code) => {
consumer = await makeConsumer()
const result = await runBuiltBin(
consumer,
['--config', './cordis.yml', '--output-format', 'stream-json', 'hang'],
signal,
)
expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null })
expect(result.stdout).toContain('"kind":"aborted"')
expect(result.stderr).toContain(`received ${signal}`)
}, 30_000)
})
})

View File

@@ -0,0 +1,159 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as cliDemo from '../src/index.ts'
const contexts: Context[] = []
async function skillConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<cliDemo.Config['skills']>> {
const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-skills-'))
return {
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
...catalogDescriptionMaxLength === undefined ? {} : { tool: { catalogDescriptionMaxLength } },
}
}
async function mount(config: cliDemo.Config, withBash = false): Promise<Context> {
const ctx = new Context()
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
contexts.push(ctx)
await ctx.plugin(cliDemo, config)
await new Promise(resolve => setTimeout(resolve, 80))
return ctx
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('dsh-cli-demo app composition', () => {
it('composes the UI-less spine, JSONL persistence, and a main agent', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-compose-'))
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'Headless.',
tools: { mode: 'native' },
persistenceRoot: root,
skills: await skillConfig(),
workspaceContext: false,
})
const [agent] = ctx.get('agents')?.roots() ?? []
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(agent?.session.header.cwd).toBe(process.cwd())
expect(ctx.get('userInteraction')).toBeUndefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
})
it('covers direct-apply defaults and forwards skill and tool-order config', async () => {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-defaults-'))
process.env.DSH_HOME = join(home, '.dsh')
process.env.DSH_AGENTS_HOME = join(home, '.agents')
try {
const ctx = new Context()
contexts.push(ctx)
cliDemo.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
const [agent] = ctx.get('agents')?.roots() ?? []
expect(agent?.session.id).toMatch(/^main-session-/)
expect(await ctx.skills.list()).toEqual([])
} finally {
if (oldDshHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = oldDshHome
if (oldAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
else process.env.DSH_AGENTS_HOME = oldAgentsHome
}
const ctx = await mount({
provider: 'mock',
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
skills: await skillConfig(6),
workspaceContext: false,
})
ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' })
for (const name of ['alpha', 'zulu']) {
ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] })
}
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...')
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([
'zulu',
'alpha',
'skill',
'task_kill',
'task_list',
'task_output',
])
})
it('forwards the complete shared spine configuration', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-agents-'))
const ctx = await mount({
provider: 'mock',
model: 'mock',
maxParallelToolCalls: 3,
dshHome,
skills: { local: { agentsHome } },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
workspaceContext: false,
}, true)
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
const execution: ToolExecution = {
token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'],
callId: CallId('cli-demo-dsh-home'),
name: 'bash',
arguments: { command: 'true' },
}
expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: dshHome })
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
.not.toContain('run_in_background')
const id = ctx.tasks.start({
kind: 'bash',
label: 'config forwarding probe',
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
callId: CallId('cli-demo-task-config'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
})
it('exposes the Loader-safe namespace plugin shape and schema', () => {
expect(cliDemo.name).toBe('cli-demo')
expect(cliDemo.Config).toBeDefined()
expect('default' in cliDemo).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(cliDemo) as Record<string, unknown>
expect(unwrapped).toBe(cliDemo)
expect(unwrapped.name).toBe('cli-demo')
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -0,0 +1,474 @@
import { readdir, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk, type TokenUsage } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { afterEach, describe, expect, it } from 'vitest'
import * as cliDemo from '../src/index.ts'
import {
executeCli,
formatTurnFailure,
parseCliArgs,
runOneShot,
type CliResult,
} from '../src/cli.ts'
type ScriptEntry = readonly StreamChunk[] | 'hang'
class ScriptedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
private cursor = 0
constructor(private readonly script: readonly ScriptEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script[this.cursor++]
if (entry === undefined) throw new Error('script exhausted')
if (entry === 'hang') {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
if (options.signal?.aborted === true) {
reject(new Error('aborted'))
return
}
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
for (const chunk of entry) yield chunk
}
}
function textResponse(text: string, usage?: TokenUsage, finish: 'stop' | 'max-tokens' = 'stop'): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
...usage === undefined ? [] : [{ type: 'usage', usage } as const],
{ type: 'finish', reason: { kind: finish } },
]
}
function toolResponse(usage: TokenUsage): StreamChunk[] {
const id = CallId('cli-call')
const args = JSON.stringify({ text: 'round trip' })
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'working' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'working' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 1, id, name: 'echo', argumentsDelta: args },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'echo', arguments: args } },
{ type: 'usage', usage },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function reasoningResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'reasoning', text } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
interface Harness {
readonly ctx: Context
readonly agent: Agent
readonly persistenceRoot: string
}
const liveContexts: Context[] = []
async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
const root = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-'))
const skillHome = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-skills-'))
const ctx = new Context()
liveContexts.push(ctx)
await ctx.plugin(cliDemo, {
provider: 'mock',
model: 'mock',
persistenceRoot: root,
skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } },
workspaceContext: false,
})
await new Promise(resolve => setTimeout(resolve, 80))
ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script))
ctx.tools.register({
name: 'echo',
description: 'Echo text.',
parameters: { text: { type: 'string', required: true } },
execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }],
})
const [agent] = ctx.agents.roots()
if (agent === undefined) throw new Error('test main agent missing')
return { ctx, agent, persistenceRoot: root }
}
async function invoke(
ctx: Context,
args: readonly string[],
options: { signal?: AbortSignal; failStdout?: boolean; failDispose?: boolean } = {},
): Promise<{ code: number; stdout: string; stderr: string }> {
let stdout = ''
let stderr = ''
const code = await executeCli(args, {
cwd: '/tmp/cli-cwd',
...options.signal === undefined ? {} : { signal: options.signal },
boot: async () => ctx,
loadEnv: () => {},
writeStdout: (chunk) => {
if (options.failStdout === true) throw new Error('stdout closed')
stdout += chunk
},
writeStderr: (chunk) => { stderr += chunk },
...options.failDispose === true
? { dispose: async (target: Context) => {
await target.fiber.dispose()
throw new Error('dispose exploded')
} }
: {},
})
return { code, stdout, stderr }
}
afterEach(async () => {
await Promise.all(liveContexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('parseCliArgs', () => {
it('parses defaults, explicit options, spaces, and an option-like task after --', () => {
expect(parseCliArgs(['task with spaces'])).toEqual({
kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces',
})
expect(parseCliArgs(['--config', 'custom.yml', '--output-format', 'stream-json', 'do it'])).toEqual({
kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it',
})
expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' })
expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' })
})
it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => {
expect(() => parseCliArgs([])).toThrow('received 0')
expect(() => parseCliArgs([' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2')
expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format')
expect(() => parseCliArgs(['-p', 'task'])).toThrow('Unknown option')
})
})
describe('runOneShot and executeCli', () => {
it('prints help and argument diagnostics without booting or contaminating stdout', async () => {
let booted = false
let stdout = ''
let stderr = ''
const runtime = {
boot: async (): Promise<Context> => { booted = true; throw new Error('unexpected') },
writeStdout: (chunk: string): void => { stdout += chunk },
writeStderr: (chunk: string): void => { stderr += chunk },
}
expect(await executeCli(['--help'], runtime)).toBe(0)
expect(stdout).toContain('Usage: dsh-cli-demo')
stdout = ''
expect(await executeCli([], runtime)).toBe(1)
expect(stdout).toBe('')
expect(stderr).toContain('received 0')
expect(booted).toBe(false)
})
it('leaves stdout empty for environment and boot failures and resolves the default config', async () => {
let bootPath = ''
let stderr = ''
const code = await executeCli(['task'], {
cwd: '/tmp/cli-work',
loadEnv: (_name, _dir, warn) => { warn('env warning\n') },
boot: async (_name, path) => { bootPath = path; throw 'boot exploded' },
writeStdout: () => { throw new Error('stdout must stay empty') },
writeStderr: (chunk) => { stderr += chunk },
})
expect(code).toBe(1)
expect(bootPath).toBe(resolve('/tmp/cli-work/cordis.yml'))
expect(stderr).toContain('env warning')
expect(stderr).toContain('boot exploded')
})
it('contains a thrown value whose inspection and coercion both fail', async () => {
const hostile = new Proxy({}, {
getPrototypeOf: () => { throw new Error('prototype trap escaped') },
get: (target, key, receiver) => {
if (key === Symbol.toPrimitive) throw new Error('coercion escaped')
return Reflect.get(target, key, receiver) as unknown
},
})
let stdout = ''
let stderr = ''
const code = await executeCli(['task'], {
boot: async () => { throw hostile },
loadEnv: () => {},
writeStdout: (chunk) => { stdout += chunk },
writeStderr: (chunk) => { stderr += chunk },
})
expect(code).toBe(1)
expect(stdout).toBe('')
expect(stderr).toBe('dsh-cli-demo: [unrenderable thrown value]\n')
})
it('interrupts Loader boot and contains every late boot outcome', async () => {
const abort = new AbortController()
const lateContext = new Context()
liveContexts.push(lateContext)
const boot = Promise.withResolvers<Context>()
const disposed = Promise.withResolvers<undefined>()
let disposeCalls = 0
let stderr = ''
const running = executeCli(['task'], {
signal: abort.signal,
boot: () => boot.promise,
loadEnv: () => {},
writeStdout: () => {},
writeStderr: (chunk) => { stderr += chunk },
dispose: async (ctx) => {
disposeCalls += 1
await ctx.fiber.dispose()
disposed.resolve(undefined)
},
})
abort.abort('received SIGTERM')
await expect(running).resolves.toBe(1)
expect(stderr).toContain('received SIGTERM')
expect(disposeCalls).toBe(0)
boot.resolve(lateContext)
await disposed.promise
expect(disposeCalls).toBe(1)
const rejectedBoot = Promise.withResolvers<Context>()
const rejectedAbort = new AbortController()
const rejected = executeCli(['task'], {
signal: rejectedAbort.signal,
boot: () => rejectedBoot.promise,
loadEnv: () => {},
writeStdout: () => {},
writeStderr: () => {},
})
rejectedAbort.abort('stop rejected boot')
await expect(rejected).resolves.toBe(1)
rejectedBoot.reject(new Error('late boot rejection'))
await Promise.resolve()
let ordinaryBootStderr = ''
const ordinaryBootFailure = await executeCli(['task'], {
signal: new AbortController().signal,
boot: async () => { throw new Error('ordinary boot failure') },
loadEnv: () => {},
writeStdout: () => {},
writeStderr: (chunk) => { ordinaryBootStderr += chunk },
})
expect(ordinaryBootFailure).toBe(1)
expect(ordinaryBootStderr).toContain('ordinary boot failure')
const failedCleanupBoot = Promise.withResolvers<Context>()
const failedCleanupAbort = new AbortController()
const cleanupFailure = Promise.withResolvers<undefined>()
const failedCleanupContext = new Context()
liveContexts.push(failedCleanupContext)
const failedCleanup = executeCli(['task'], {
signal: failedCleanupAbort.signal,
boot: () => failedCleanupBoot.promise,
loadEnv: () => {},
writeStdout: () => {},
writeStderr: (chunk) => {
if (chunk.includes('dispose after interrupted boot failed: late cleanup')) cleanupFailure.resolve(undefined)
},
dispose: async (ctx) => {
await ctx.fiber.dispose()
throw new Error('late cleanup')
},
})
failedCleanupAbort.abort('stop failed cleanup boot')
await expect(failedCleanup).resolves.toBe(1)
failedCleanupBoot.resolve(failedCleanupContext)
await cleanupFailure.promise
})
it('renders text, flushes a persisted fresh session, and disposes the context', async () => {
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
const output = await invoke(ctx, ['task'])
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
expect(agent.status).toBe('disposed')
const files = await readdir(persistenceRoot, { recursive: true })
expect(files.some(file => file.endsWith('.jsonl'))).toBe(true)
})
it('sums usage across tool steps and selects the last text-bearing assistant message', async () => {
const first = { inputTokens: 10, outputTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 1 }
const second = { inputTokens: 7, outputTokens: 5, cacheReadTokens: 4, reasoningTokens: 6 }
const { ctx } = await harness([toolResponse(first), textResponse('done', second)])
const output = await invoke(ctx, ['--output-format', 'json', 'task'])
const result = JSON.parse(output.stdout) as CliResult
expect(output.code).toBe(0)
expect(result).toMatchObject({ type: 'result', success: true, turn: 1, result: 'done', reason: { kind: 'completed' } })
expect(result.usage).toEqual({
inputTokens: 17,
outputTokens: 8,
cacheReadTokens: 6,
cacheWriteTokens: 1,
reasoningTokens: 6,
})
})
it('keeps the prior text when a later assistant message has no text blocks', async () => {
const { ctx } = await harness([
toolResponse({ inputTokens: 1, outputTokens: 1 }),
reasoningResponse('reasoning only'),
])
const result = await runOneShot(ctx, { task: 'task' })
expect(result.result).toBe('working')
})
it('streams only the correlated main message turn and then the result envelope', async () => {
const { ctx, agent } = await harness([textResponse('streamed')])
const other = ctx.sessions.create(SessionId('unrelated'))
let injected = false
ctx.on('agent/queued', (subject) => {
if (subject !== agent || injected) return
injected = true
agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } })
other.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } })
other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent)
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 2, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
expect(events.some(event => event.type === 'context/message')).toBe(false)
})
it('emits partial data and a diagnostic for non-completed turns', async () => {
const { ctx } = await harness([textResponse('partial', { inputTokens: 2, outputTokens: 3 }, 'max-tokens')])
const output = await invoke(ctx, ['--output-format', 'json', 'task'])
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, result: 'partial', reason: { kind: 'max-tokens' } })
expect(output.code).toBe(1)
expect(output.stderr).toContain('output-token limit')
})
it('cancels an active turn, emits its durable aborted result, and disposes', async () => {
const { ctx, agent } = await harness(['hang'])
const abort = new AbortController()
let started!: () => void
const running = new Promise<void>((resolveStarted) => { started = resolveStarted })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/chunk') started()
})
const outcome = invoke(ctx, ['--output-format', 'json', 'task'], { signal: abort.signal })
await running
abort.abort('received SIGINT')
const output = await outcome
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted', reason: 'received SIGINT' } })
expect(output.code).toBe(1)
expect(output.stderr).toContain('was aborted: received SIGINT')
expect(agent.status).toBe('disposed')
})
it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => {
const { ctx, agent } = await harness(['hang'])
await expect(runOneShot(ctx, {
task: 'task',
onEvent: () => { throw new Error('stream sink failed') },
})).rejects.toThrow('stream sink failed')
expect(agent.status).toBe('idle')
})
it('handles cancellation before submission, a missing main agent, and final-output failure', async () => {
const early = await harness([textResponse('unused')])
const fakeSignal = {
aborted: true,
reason: undefined,
} as unknown as AbortSignal
await expect(runOneShot(early.ctx, { task: 'task', signal: fakeSignal })).rejects.toThrow('interrupted')
const preBootAbort = new AbortController()
preBootAbort.abort('before boot completed')
const preBoot = await invoke(early.ctx, ['task'], { signal: preBootAbort.signal })
expect(preBoot).toMatchObject({ code: 1, stdout: '' })
expect(preBoot.stderr).toContain('before boot completed')
const empty = new Context()
liveContexts.push(empty)
await expect(runOneShot(empty, { task: 'task' })).rejects.toThrow('exactly one top-level agent')
const final = await harness([textResponse('answer')])
const output = await invoke(final.ctx, ['task'], { failStdout: true })
expect(output.code).toBe(1)
expect(output.stdout).toBe('')
expect(output.stderr).toContain('stdout closed')
expect(final.agent.status).toBe('disposed')
const disposal = await harness([textResponse('answer')])
const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true })
expect(disposalOutput).toMatchObject({ code: 1, stdout: 'answer\n' })
expect(disposalOutput.stderr).toContain('dispose exploded')
})
it('reports disposal failure alongside an earlier run failure', async () => {
const ctx = new Context()
liveContexts.push(ctx)
const output = await invoke(ctx, ['task'], { failDispose: true })
expect(output).toEqual({
code: 1,
stdout: '',
stderr: 'dsh-cli-demo: config must create exactly one top-level agent, found 0\n'
+ 'dsh-cli-demo: dispose failed: dispose exploded\n',
})
})
it('cancels startup work and queued work before the correlated turn begins', async () => {
const startup = await harness(['hang'])
let started!: () => void
const running = new Promise<void>((resolveStarted) => { started = resolveStarted })
startup.ctx.on('session/event', (session, event) => {
if (session === startup.agent.session && event.type === 'assistant/chunk') started()
})
startup.agent.send([{ type: 'text', text: 'first' }])
await running
const startupAbort = new AbortController()
const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })
startupAbort.abort('cancel startup')
await expect(waiting).rejects.toThrow('cancel startup')
await startup.agent.whenIdle()
const queued = await harness([textResponse('unused')])
const queuedAbort = new AbortController()
queued.ctx.on('agent/queued', (agent) => {
if (agent === queued.agent) queuedAbort.abort('cancel queued')
})
await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued')
await queued.agent.whenIdle()
})
})
describe('formatTurnFailure', () => {
it('diagnoses every durable reason and preserves merge-extensible unknowns', () => {
const cases: [TurnEndReason, string][] = [
[{ kind: 'completed' }, 'completed'],
[{ kind: 'aborted' }, 'was aborted'],
[{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'],
[{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'],
[{ kind: 'disposed' }, 'was disposed'],
[{ kind: 'max-tokens' }, 'output-token limit'],
[{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'],
[{ kind: 'interrupted' }, 'persistence recovery'],
]
for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected)
expect(formatTurnFailure({ kind: 'extension' } as unknown as TurnEndReason)).toContain('extension')
})
})

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "../../../.typecheck/cli-demo.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../../vendor/schemastery" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" },
{ "path": "../../core/agent" },
{ "path": "../../core/system-prompt" },
{ "path": "../../core/tools" },
{ "path": "../agent-spine-demo" },
{ "path": "../../session-persistence/session-persistence-jsonl" },
{ "path": "../../ui/app-boot" }
]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'tsdown'
/** Builds the plugin and executable entries from declarations emitted by `tsc -b`. */
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/bin.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})

View File

@@ -23,7 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`.
- **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
@@ -46,3 +46,4 @@ The plugin buffers frozen session events and drains them on flush or disposal. A
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated.
- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend.
- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive.

View File

@@ -57,6 +57,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
private root: string
private coordinator: PersistenceCoordinator<number>
/** Runtime host platform used to decide whether directory sync is supported. */
readonly internals: { platform: NodeJS.Platform } = { platform: process.platform }
constructor(ctx: Context, public config: Config) {
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
@@ -208,11 +211,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
/** fsync a directory so a just-created or published entry inside it is crash-durable. */
/** fsync a directory when the host exposes that durability primitive. */
private async syncDir(dir: string): Promise<void> {
const handle = await open(dir, 'r')
try {
await handle.sync()
try {
await handle.sync()
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | null)?.code
// Node opens directories on Windows but its fsync binding rejects them.
// File-content fsync remains mandatory; only this unsupported primitive is skipped.
if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error
}
} finally {
await handle.close()
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -40,9 +41,25 @@ async function freshRoot(): Promise<string> {
}
afterEach(async () => {
vi.restoreAllMocks()
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
async function rejectDirectorySync(code: string): Promise<void> {
const handle = await open(root, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
if ((await this.stat()).isDirectory()) {
const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
error.code = code
throw error
}
return realSync.call(this)
})
}
function appendClosedTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
@@ -336,6 +353,28 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
await rejectDirectorySync('EPERM')
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = 'win32'
const m = meta('windows-directory-sync')
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
})
it.each([
['linux', 'EPERM'],
['win32', 'EIO'],
] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
await rejectDirectorySync(code)
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = platform
const m = meta(`directory-sync-${platform}-${code}`)
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
})
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
const m = meta('meta-copy', '/proj')
await ctx.sessionPersistence.create(m)

View File

@@ -2,7 +2,7 @@
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is visible to clients such as ACP editors; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
Its consumer is the ACP snapshot harness in `examples/acp-agent`, which loads this plugin (via `cordis.snapshot.yml`) in place of a real LLM adapter. The package exists so its derive/parse/replay logic falls under the per-file 100% coverage gate on `packages/*/src` (the same logic, while it lived under `examples/`, was outside the gate).
Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
## How the fixture works

View File

@@ -2,7 +2,7 @@
Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`.
`runLoaderSmoke` owns the isolated cwd, DSH homes, stdin, diagnostics, deadline, termination, and cleanup. It returns both streams after a zero exit and rejects with both streams on failure.
`runLoaderSmoke` accepts bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup; it returns both streams after a zero exit and rejects with both streams on failure.
This is support-tier test infrastructure, not product API.

View File

@@ -1,14 +1,12 @@
/**
* Shared subprocess harness for keyless example smokes that boot a real
* `cordis.yml` through the stdio-agent bin and Cordis Loader.
* `cordis.yml` through an app bin and Cordis Loader.
*
* It also owns the mode-aware launch resolver every example subprocess harness shares
* ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the
* zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths`
* map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an
* installed consumer does, while Node type-strips relative example-local TypeScript plugins).
* Consolidating that spawn glue here retires the copies in the ACP snapshot harness and the example
* e2e drivers (the `TODO(acp-test-harness)`).
*
* @module @deepseek-ai/dsh-loader-smoke
*/
@@ -126,12 +124,14 @@ export interface LoaderSmokeOptions {
readonly label: string
/** Prefix for the isolated temporary process cwd. */
readonly tempDirPrefix: string
/** Absolute stdio-agent bin SOURCE path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
/** Absolute app-bin source path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
readonly binScript: string
/** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
readonly libBinScript?: string | undefined
/** Absolute real Loader config path. */
/** Absolute real Loader config path, passed as the sole bin argument by default. */
readonly configPath: string
/** Complete argv after the bin path; overrides the default `[configPath]`. */
readonly binArgs?: readonly string[]
/** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */
readonly tsconfigPath: string
/** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */
@@ -142,6 +142,10 @@ export interface LoaderSmokeOptions {
readonly stdinLines?: readonly string[]
/** Process deadline override for harness tests. */
readonly processTimeoutMs?: number
/** Optional world-state setup run in the isolated cwd before process start. */
readonly prepare?: (cwd: string) => Promise<void> | void
/** Optional world-state assertion run in the isolated cwd before cleanup. */
readonly inspect?: (cwd: string) => Promise<void> | void
}
/** Captured output from a Loader smoke that exited successfully. */
@@ -162,17 +166,18 @@ export interface LoaderSmokeResult {
export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
const launch = resolveExampleLaunch({
srcBin: options.binScript,
libBin: options.libBinScript,
configArgs: [options.configPath],
...options.mode !== undefined ? { mode: options.mode } : {},
tsconfigPath: options.tsconfigPath,
exposeInternals: true,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
})
try {
return await new Promise((resolve, reject) => {
await options.prepare?.(cwd)
const launch = resolveExampleLaunch({
srcBin: options.binScript,
libBin: options.libBinScript,
configArgs: options.binArgs ?? [options.configPath],
...options.mode !== undefined ? { mode: options.mode } : {},
tsconfigPath: options.tsconfigPath,
exposeInternals: true,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
})
const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
const child = spawn(launch.command, launch.args, {
cwd,
env: { ...process.env, ...launch.env },
@@ -217,6 +222,8 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join(''))
})
await options.inspect?.(cwd)
return result
} finally {
await rm(cwd, { recursive: true, force: true })
}

View File

@@ -6,6 +6,7 @@ process.stdin.on('data', (chunk: string) => { input += chunk })
process.stdin.on('end', () => {
console.log(JSON.stringify({
configPath: process.argv[2],
args: process.argv.slice(2),
cwd: process.cwd(),
dshHome: process.env.DSH_HOME,
agentsHome: process.env.DSH_AGENTS_HOME,

View File

@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
@@ -22,6 +24,7 @@ describe('runLoaderSmoke', () => {
})
const output = JSON.parse(result.stdout) as {
configPath: string
args: string[]
cwd: string
dshHome: string
agentsHome: string
@@ -30,6 +33,7 @@ describe('runLoaderSmoke', () => {
}
expect(output).toMatchObject({
configPath,
args: [configPath],
marker: 'present',
input: 'one\ntwo\n',
})
@@ -39,6 +43,30 @@ describe('runLoaderSmoke', () => {
expect(existsSync(output.cwd)).toBe(false)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('passes an arbitrary bin argv and inspects world state before cleanup', async () => {
let inspected = ''
let marker = ''
const result = await runLoaderSmoke({
label: 'argv fixture',
tempDirPrefix: 'loader-smoke-argv-',
binScript: fixture('success'),
libBinScript: fixture('success'),
configPath,
binArgs: ['--config', configPath, '--output-format', 'json', 'task with spaces'],
tsconfigPath,
prepare: cwd => writeFile(join(cwd, 'marker.txt'), 'prepared'),
inspect: async (cwd) => {
inspected = cwd
marker = await readFile(join(cwd, 'marker.txt'), 'utf8')
},
})
const output = JSON.parse(result.stdout) as { args: string[]; cwd: string }
expect(output.args).toEqual(['--config', configPath, '--output-format', 'json', 'task with spaces'])
expect(canonicalTempPath(inspected)).toBe(canonicalTempPath(output.cwd))
expect(marker).toBe('prepared')
expect(existsSync(inspected)).toBe(false)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('rejects a non-zero exit with captured diagnostics', async () => {
await expect(runLoaderSmoke({
label: 'failure fixture',