feat(examples): add one-shot CLI demo

This commit is contained in:
Tianyi Cui
2026-07-15 21:21:24 +08:00
parent b045b553a9
commit b78cdbcd51
37 changed files with 1600 additions and 28 deletions

View File

@@ -54,7 +54,7 @@ pnpm run test:snapshot
Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change.
```sh
pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts
pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts
```
Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets.

View File

@@ -43,8 +43,8 @@ Package groups: [packages/README.md](packages/README.md).
```sh
pnpm install # pnpm workspaces, node ^22.19 || >=24
pnpm run test # vitest unit tests
pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src
pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
pnpm run test:coverage # gate: per-file 100% on packages/*/*/src
pnpm run test:e2e # real API; skips without key
pnpm run test:snapshot # keyless ACP replay vs goldens; filter: -t <name>
pnpm run test:snapshot:record # re-record goldens (needs key)
pnpm run typecheck
@@ -54,9 +54,10 @@ pnpm run build # tsc emits lib/types, tsdown bundles runtime
pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check
pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json
pnpm run demo:echo # mock-model REPL, no key needed
pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY)
pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key)
pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
pnpm run demo:repl # real coding REPL (needs key)
pnpm run demo:cli -- "task" # one-shot agent (needs key)
pnpm run demo:cordis # self-modifying runtime demo (needs key)
pnpm run demo:acp # ACP server (needs key)
```
### Run the CI gates locally before marking a PR ready
@@ -79,7 +80,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})'
printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE'
test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)"
rm -rf .sessions
pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
```
`test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run.

View File

@@ -138,7 +138,7 @@ Some seams bend the template deliberately. LLM keeps interface and consumer voca
### Bundles And Apps
`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, `dsh-cli-demo` for one headless persisted turn with format-pure stdout, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
### Where New Behavior Goes

View File

@@ -17,6 +17,7 @@ flowchart LR
pkg_session["session"]
svc_sessions["ctx.sessions<br/>In-memory session store"]
pkg_agent["agent"]
pkg_cli_demo["cli-demo"]
pkg_session_persistence["session-persistence"]
pkg_session_query["session-query"]
pkg_subagent_inprocess["subagent-inprocess"]
@@ -132,6 +133,7 @@ flowchart LR
svc_agentLoop --> pkg_agent_spine_demo
svc_agents --> pkg_acp
svc_agents --> pkg_agent_loop
svc_agents --> pkg_cli_demo
svc_agents --> pkg_invariants
svc_agents --> pkg_stdio_demo
svc_agents --> pkg_subagent_inprocess
@@ -152,6 +154,7 @@ flowchart LR
svc_sessionPersistence --> pkg_session_query
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_cli_demo
svc_sessions --> pkg_invariants
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_session_query
@@ -183,14 +186,14 @@ flowchart LR
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |

View File

@@ -169,6 +169,30 @@ Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-
Source: [`packages/bash/bash-sandbox/src/index.ts:26`](../packages/bash/bash-sandbox/src/index.ts)
## `@deepseek-ai/dsh-cli-demo`
```ts config-catalog
/** App config forwarded to the spine, pre-created agent, and JSONL backend. */
export interface Config {
/** Model name for the `main` agent; a matching adapter must be registered. */
model: string
/** 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
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
}
```
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
Source: [`packages/examples/cli-demo/src/index.ts:21`](../packages/examples/cli-demo/src/index.ts)
## `@deepseek-ai/dsh-code-runtime-worker`
```ts config-catalog

View File

@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

View File

@@ -111,6 +111,7 @@ flowchart TD
subgraph group_examples["packages/examples"]
pkg_acp_demo["acp-demo"]
pkg_agent_spine_demo["agent-spine-demo"]
pkg_cli_demo["cli-demo"]
pkg_jsonrpc_demo["jsonrpc-demo"]
pkg_stdio_demo["stdio-demo"]
end
@@ -340,6 +341,13 @@ flowchart TD
pkg_acp_demo --> pkg_session_persistence_jsonl
pkg_acp_demo --> pkg_tools
pkg_acp_demo --> pkg_user_interaction
pkg_cli_demo --> pkg_agent
pkg_cli_demo --> pkg_agent_spine_demo
pkg_cli_demo --> pkg_app_boot
pkg_cli_demo --> pkg_llm
pkg_cli_demo --> pkg_session
pkg_cli_demo --> pkg_session_persistence_jsonl
pkg_cli_demo --> pkg_tools
pkg_stdio_demo --> pkg_agent
pkg_stdio_demo --> pkg_agent_spine_demo
pkg_stdio_demo --> pkg_app_boot
@@ -427,4 +435,5 @@ flowchart TD
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools) |
| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

View File

@@ -1,6 +1,6 @@
# Examples
Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`.
Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`.
## echo-agent
@@ -17,7 +17,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigge
A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. The UI is a terminal readline REPL.
Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
Run interactively with `pnpm run demo:repl`, or run one headless task with `pnpm run demo:cli -- "task"` (both need `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task.

View File

@@ -1,6 +1,6 @@
# coding-agent
The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. The UI is a terminal readline REPL.
Coding-agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + JSONL persistence. `cordis.yml` runs the terminal readline REPL; `cli.cordis.yml` keeps the same coding capabilities behind a headless one-shot CLI.
## Run it
@@ -17,10 +17,24 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem
> fix the failing test in /path/to/project
[main turn 1] (reasoning…)
[tool call] bash({"command": "node --test", "workdir": "/path/to/project"})
[tool result] … [exit code: 1]
[tool result] … [exit code: 1]
```
### One-shot CLI
Run one task through all model and tool steps, flush its fresh session, print the final result, and exit:
```sh
pnpm run demo:cli -- "fix the failing test in this workspace"
pnpm run demo:cli --output-format json -- "summarize the current implementation"
pnpm run demo:cli --output-format stream-json -- "run the focused tests"
```
The root command supplies `cli.cordis.yml`, which disables HMR and the REPL app and inserts [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo). Exactly one quoted positional task is required; there is no `-p` flag. `text` prints the last text-bearing assistant message, `json` prints one DSH-native result record, and `stream-json` emits the parent `main` session's canonical task-turn events before that record. Non-completed turns retain partial output but exit nonzero; argument and boot failures leave stdout empty.
This is non-interactive automation with the same local bash, filesystem, skill, subagent, workflow, and todo capabilities as the REPL. It can mutate the launch workspace and spend provider tokens. No prompt, approval, resume, further turn, or stdin context is available in v1; see the [CLI package contract](../../packages/examples/cli-demo/README.md).
### Resuming a prior session
Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, set `RESUME_SESSION_ID` to that session's id — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history:
@@ -55,7 +69,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes |
| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`bash_output`/`bash_kill` tool schemas (`tool-bash`) come from `agent-core`, so only the executor is a leaf choice |
| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins |
| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the REPL app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio |
@@ -69,4 +83,4 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction.
- `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event.
These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless boot smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` (the full real tree, dummy key, no prompt → no model call) and `tests/code-mode-keyless-smoke.e2e.ts` (the same guard for the Code Mode overlay).
These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. `tests/cli.e2e.ts` runs the one-shot bin with a real model and verifies its temporary file externally. The keyless Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts`, `tests/code-mode-keyless-smoke.e2e.ts`, and `tests/cli-keyless-smoke.e2e.ts`; the CLI smoke mocks only the LLM boundary and asserts a real bash round trip plus persisted stream output.

View File

@@ -0,0 +1,24 @@
# One-shot headless overlay: keep the coding capabilities from `cordis.yml`,
# replace its REPL app with the stdout-pure CLI app, and disable dev-only HMR.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: hmr
name: '@cordisjs/plugin-hmr'
disabled: true
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
disabled: true
- insert:
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
model: deepseek-v4-flash
persistenceRoot: './.sessions'
persona: |
You are coding-agent, a coding assistant powered by the {{model}} model.
Verify your work by running the code or tests. Keep answers brief and
factual.

View File

@@ -0,0 +1,43 @@
import { readdir } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
describe('coding-agent one-shot CLI keyless smoke', () => {
it('boots the real Loader tree, runs a real bash tool round trip, and persists the turn', async () => {
let persisted = false
const { stdout, stderr } = await runLoaderSmoke({
label: 'coding-agent CLI',
tempDirPrefix: 'coding-cli-smoke-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'stream-json', 'prove the tool path'],
tsconfigPath,
inspect: async (cwd) => {
const files = await readdir(cwd, { recursive: true })
persisted = files.some(file => file.endsWith('.jsonl'))
},
})
const lines = 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)
const result = lines.at(-1)
expect(stderr).toBe('')
expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true)
const toolResult = events.find(event => event.type === 'tool/result')
expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP')
expect(result).toMatchObject({
type: 'result',
success: true,
turn: 1,
reason: { kind: 'completed' },
usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 },
})
expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP')
expect(persisted).toBe(true)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,33 @@
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cli.cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const hasKey = Boolean(process.env.DEEPSEEK_API_KEY)
describe.skipIf(!hasKey)('coding-agent one-shot CLI with real model', () => {
it('modifies a temporary workspace and verifies the file outside the agent', async () => {
let verified = ''
const { stdout } = await runLoaderSmoke({
label: 'coding-agent CLI real model',
tempDirPrefix: 'coding-cli-real-',
binScript,
configPath,
binArgs: [
'--config',
configPath,
'Read task.txt, replace its complete contents with exactly "value=after" followed by a newline, read it again, and report briefly.',
],
tsconfigPath,
processTimeoutMs: 120_000,
prepare: cwd => writeFile(join(cwd, 'task.txt'), 'value=before\n'),
inspect: async (cwd) => { verified = await readFile(join(cwd, 'task.txt'), 'utf8') },
})
expect(verified).toBe('value=after\n')
expect(stdout.trim().length).toBeGreaterThan(0)
}, 135_000)
})

View File

@@ -0,0 +1,37 @@
import type { Context } from 'cordis'
import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
/** Keyless coding smoke adapter: one real bash call followed by a final answer. */
class CliMockAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result')
if (toolResult === undefined) {
const args = JSON.stringify({ command: 'printf CLI_TOOL_ROUND_TRIP', description: 'Prove the CLI tool round trip.' })
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 0, id: CallId('cli-smoke-call'), name: 'bash', argumentsDelta: args }
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('cli-smoke-call'), name: 'bash', arguments: args } }
yield { type: 'usage', usage: { inputTokens: 11, outputTokens: 3, cacheReadTokens: 2 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
const toolText = toolResult.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
const reply = `CLI tool round trip complete: ${toolText.trim()}`
yield { type: 'block-start', index: 0, blockType: '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: 7, outputTokens: 5, reasoningTokens: 1 } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'cli-mock-llm'
export const inject = ['llm']
/** Register the keyless `cli-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter())
}

View File

@@ -0,0 +1,24 @@
- id: cli-mock-llm
name: './cli-mock-llm.ts'
- id: base
name: '@cordisjs/plugin-include'
config:
path: ../../cordis.yml
patches:
- id: hmr
name: '@cordisjs/plugin-hmr'
disabled: true
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
disabled: true
- insert:
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
model: cli-mock
persistenceRoot: './.sessions'
persona: 'Keyless CLI smoke.'

View File

@@ -9,6 +9,7 @@
"examples/echo-agent/src/*.ts",
"examples/echo-agent/tests/**/*.e2e.ts",
"examples/coding-agent/tests/**/*.e2e.ts",
"examples/coding-agent/tests/fixtures/*.ts",
"examples/cordis-agent/tests/**/*.e2e.ts",
"examples/acp-agent/tests/**/*.e2e.ts",
"examples/*/tests/**/*.snapshot.ts"
@@ -90,6 +91,10 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/examples/cli-demo": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/ui/stdio": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]

View File

@@ -74,6 +74,7 @@
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
"demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml",
"demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/coding-agent/cordis.yml",
"demo:cli": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/coding-agent/cli.cordis.yml",
"demo:code-mode": "node scripts/demo-code-mode.mjs",
"demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml",
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",

View File

@@ -28,7 +28,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | 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 |
| [`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 (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |

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` + `tool-skill` + `agent-loop`) |
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + 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 opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) 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,62 @@
# @deepseek-ai/dsh-cli-demo
Headless one-shot app and bin for running one coding-agent task without a readline or editor client. The app composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and one fresh `main` agent; the bin submits one task, waits through all model and tool steps, emits the selected result, 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 |
|---|---|---|
| `model` | required | the pre-created `main` agent's model |
| `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` |
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
| `persistenceRoot` | `./.sessions` | JSONL session root |
Each process creates a new session whose workspace cwd is the launch directory. The app has no resume setting.
## 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 coding demo supplies its overlay:
```sh
pnpm run demo:cli -- "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 `main` 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 coding overlay retains 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 `main` agent also receives the configured persona, 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 main session per process** — 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 main-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,59 @@
{
"name": "@deepseek-ai/dsh-cli-demo",
"description": "Headless one-shot coding-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",
"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:^",
"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,380 @@
/**
* Covered command parser and one-turn driver for `dsh-cli-demo`. The executable
* entry only installs process signal handlers and delegates here.
* @module @deepseek-ai/dsh-cli-demo/cli
*/
import { parseArgs } from 'node:util'
import type { Context } from 'cordis'
import { AgentId, 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 pre-created `main` agent. */
export interface OneShotOptions {
/** Exactly one nonblank user task. */
readonly task: string
/** Optional cancellation signal owned by the process wrapper. */
readonly signal?: AbortSignal
/** Synchronous observer for each canonical event in the selected task turn. */
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'
}
}
/** Convert an unknown thrown value to an Error without losing its text. */
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
/** Render the reason carried by an AbortSignal. */
function interruptionReason(signal: AbortSignal): string {
return signal.reason === undefined ? 'interrupted' : String(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,
}
}
/** Add one model step's usage into a detached turn total. */
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
}
/** Select the text blocks from an assistant message, or undefined when it has none. */
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 pre-created `main` agent, aggregate its
* final text and model usage, wait for idle plus an explicit persistence flush,
* and return its durable ending. Only the exact main-session task turn reaches
* `onEvent`; startup injections and unrelated sessions are ignored.
* @param ctx - settled Loader root containing `ctx.agents` and `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 agent = ctx.get('agents')?.get(AgentId('main'))
if (agent === undefined) throw new Error('config did not create the required "main" agent')
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 },
}
}
/** Render one final result in the selected output encoding. */
function renderResult(outputFormat: OutputFormat, result: CliResult): string {
return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n`
}
/**
* 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)}`
}
}
/**
* Parse, boot, run, render, diagnose, and dispose one CLI invocation. Argument
* and boot failures never write stdout; all booted contexts are disposed before
* this promise resolves.
* @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 bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd))
if (runtime.signal?.aborted === true) throw new CliInterruptedError(interruptionReason(runtime.signal))
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 ??= `${CLI_NAME}: dispose failed: ${toError(error).message}\n`
exitCode = 1
}
}
}
if (diagnostic !== undefined) writeStderr(diagnostic)
return exitCode
}

View File

@@ -0,0 +1,63 @@
/**
* Headless one-shot app composition: the default agent spine, JSONL session
* persistence, and one pre-created `main` 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 { AgentId } from '@deepseek-ai/dsh-agent'
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'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
export const name = 'cli-demo'
/** App config forwarded to the spine, pre-created agent, and JSONL backend. */
export interface Config {
/** Model name for the `main` agent; a matching adapter must be registered. */
model: string
/** 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
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
}
export const Config: z<Config> = z.object({
model: z.string().required(),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persona: 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,
})
/**
* Compose the UI-less spine, a fresh `main` 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 {
const spineConfig: agentCore.Config = {
agents: [{ id: AgentId('main'), model: config.model, cwd: process.cwd() }],
}
if (config.persona !== undefined) spineConfig.persona = config.persona
if (config.toolOrder !== undefined) spineConfig.toolOrder = config.toolOrder
if (config.tools !== undefined) spineConfig.tools = config.tools
if (config.skills !== undefined) spineConfig.skills = config.skills
ctx.plugin(agentCore, spineConfig)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
}

View File

@@ -0,0 +1,172 @@
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',
]
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:',
' model: built-cli-mock',
" persona: 'built CLI test'",
" persistenceRoot: './.sessions'",
'',
].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)
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,105 @@
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 { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import { afterEach, describe, expect, it } 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): Promise<Context> {
const ctx = new Context()
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({
model: 'mock',
persona: 'Headless.',
tools: { mode: 'native' },
persistenceRoot: root,
skills: await skillConfig(),
})
const agent = ctx.get('agents')?.get(AgentId('main'))
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, { model: 'mock' })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
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({
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
skills: await skillConfig(6),
})
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'])
})
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,362 @@
import { readdir, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import { AgentId, 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, {
model: 'mock',
persistenceRoot: root,
skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } },
})
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.get(AgentId('main'))
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('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('required "main" 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('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

@@ -1,6 +1,6 @@
# `@deepseek-ai/dsh-loader-smoke`
Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup.
Shared subprocess harness for keyless example smokes that boot a real app bin and `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional complete bin arguments, environment overrides, stdin lines, pre-run world setup, and a pre-cleanup world assertion; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup.
Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first.

View File

@@ -1,6 +1,6 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-loader-smoke
*/
@@ -23,10 +23,12 @@ export interface LoaderSmokeOptions {
readonly label: string
/** Prefix for the isolated temporary process cwd. */
readonly tempDirPrefix: string
/** Absolute stdio-agent bin path. */
/** Absolute app-bin path. */
readonly binScript: string
/** 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. */
readonly tsconfigPath: string
/** Environment overrides layered over the parent and isolated DSH homes. */
@@ -35,6 +37,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. */
@@ -56,10 +62,11 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
try {
return await new Promise((resolve, reject) => {
await options.prepare?.(cwd)
const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
const child = spawn(
process.execPath,
['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath],
['--expose-internals', '--import', TSX_LOADER, options.binScript, ...(options.binArgs ?? [options.configPath])],
{
cwd,
env: {
@@ -111,6 +118,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'
@@ -21,6 +23,7 @@ describe('runLoaderSmoke', () => {
})
const output = JSON.parse(result.stdout) as {
configPath: string
args: string[]
cwd: string
dshHome: string
agentsHome: string
@@ -29,6 +32,7 @@ describe('runLoaderSmoke', () => {
}
expect(output).toMatchObject({
configPath,
args: [configPath],
marker: 'present',
input: 'one\ntwo\n',
})
@@ -38,6 +42,29 @@ 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'),
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',

39
pnpm-lock.yaml generated
View File

@@ -525,6 +525,45 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/examples/cli-demo:
devDependencies:
'@cordisjs/plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-spine-demo':
specifier: workspace:^
version: link:../agent-spine-demo
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../../ui/app-boot
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
schemastery:
specifier: ^3.17.0
version: 3.18.0
packages/examples/jsonrpc-demo:
dependencies:
'@deepseek-ai/dsh-app-boot':

View File

@@ -89,7 +89,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'],
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
@@ -147,7 +147,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'agent',
title: 'Agent registry',
mode: 'core',
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'],
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'],
note: 'Owns live Agent handles and the create/resume factory seam.',
},
{
@@ -426,6 +426,8 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-cli-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>pre-created main agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
}
@@ -452,7 +454,7 @@ function renderAppComposition(example: AppExample): string {
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
lines.push(` cfg --> ${pluginNode}`)
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
renderAppExpansion(lines, pluginNode, plugin.name)
}
}

View File

@@ -344,6 +344,7 @@ function builtBinSmokeGate(): Gate {
'--config',
'vitest.e2e.config.ts',
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
// The worker-entry packages' built bundles: the only automated proof
// that lib/index.js resolves its sibling lib/worker.cjs under plain node

View File

@@ -32,6 +32,7 @@
{ "path": "./packages/ui/tool-ask-user" },
{ "path": "./packages/core/agent-loop" },
{ "path": "./packages/examples/agent-spine-demo" },
{ "path": "./packages/examples/cli-demo" },
{ "path": "./packages/bash/bash" },
{ "path": "./packages/code-runtime/code-runtime" },
{ "path": "./packages/code-runtime/code-runtime-worker" },

View File

@@ -43,6 +43,7 @@
{ "path": "./packages/ui/tool-ask-user" },
{ "path": "./packages/core/agent-loop" },
{ "path": "./packages/examples/agent-spine-demo" },
{ "path": "./packages/examples/cli-demo" },
{ "path": "./packages/bash/bash" },
{ "path": "./packages/code-runtime/code-runtime" },
{ "path": "./packages/code-runtime/code-runtime-worker" },