From b78cdbcd51d5c397f181a71ff5b0e61063e6f791 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:21:24 +0800 Subject: [PATCH 01/13] feat(examples): add one-shot CLI demo --- .agents/skills/dsh-pre-push-checks/SKILL.md | 2 +- AGENTS.md | 13 +- docs/architecture.md | 2 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 24 ++ docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 9 + examples/README.md | 4 +- examples/coding-agent/README.md | 22 +- examples/coding-agent/cli.cordis.yml | 24 ++ .../tests/cli-keyless-smoke.e2e.ts | 43 ++ examples/coding-agent/tests/cli.e2e.ts | 33 ++ .../tests/fixtures/cli-mock-llm.ts | 37 ++ .../tests/fixtures/cli.cordis.yml | 24 ++ knip.json | 5 + package.json | 1 + packages/README.md | 2 +- packages/examples/README.md | 3 +- packages/examples/cli-demo/README.md | 62 +++ packages/examples/cli-demo/package.json | 59 +++ packages/examples/cli-demo/src/bin.ts | 34 ++ packages/examples/cli-demo/src/cli.ts | 380 ++++++++++++++++++ packages/examples/cli-demo/src/index.ts | 63 +++ .../examples/cli-demo/tests/built-bin.e2e.ts | 172 ++++++++ .../examples/cli-demo/tests/cli-demo.spec.ts | 105 +++++ packages/examples/cli-demo/tests/cli.spec.ts | 362 +++++++++++++++++ packages/examples/cli-demo/tsconfig.json | 22 + packages/examples/cli-demo/tsdown.config.ts | 13 + packages/support/loader-smoke/README.md | 2 +- packages/support/loader-smoke/src/index.ts | 19 +- .../loader-smoke/tests/fixtures/success.ts | 1 + .../loader-smoke/tests/loader-smoke.spec.ts | 27 ++ pnpm-lock.yaml | 39 ++ scripts/gen-doc-graphs.ts | 8 +- scripts/run-gates.ts | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 37 files changed, 1600 insertions(+), 28 deletions(-) create mode 100644 examples/coding-agent/cli.cordis.yml create mode 100644 examples/coding-agent/tests/cli-keyless-smoke.e2e.ts create mode 100644 examples/coding-agent/tests/cli.e2e.ts create mode 100644 examples/coding-agent/tests/fixtures/cli-mock-llm.ts create mode 100644 examples/coding-agent/tests/fixtures/cli.cordis.yml create mode 100644 packages/examples/cli-demo/README.md create mode 100644 packages/examples/cli-demo/package.json create mode 100644 packages/examples/cli-demo/src/bin.ts create mode 100644 packages/examples/cli-demo/src/cli.ts create mode 100644 packages/examples/cli-demo/src/index.ts create mode 100644 packages/examples/cli-demo/tests/built-bin.e2e.ts create mode 100644 packages/examples/cli-demo/tests/cli-demo.spec.ts create mode 100644 packages/examples/cli-demo/tests/cli.spec.ts create mode 100644 packages/examples/cli-demo/tsconfig.json create mode 100644 packages/examples/cli-demo/tsdown.config.ts diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 4ce005ee79..433aa38942 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 23d7c0263b..b9731f2c80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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. diff --git a/docs/architecture.md b/docs/architecture.md index 4049049e25..c46e7b1619 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index af22ba4c15..ff22465148 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -17,6 +17,7 @@ flowchart LR pkg_session["session"] svc_sessions["ctx.sessions
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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index af8ef0832c..54438b0738 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ec2da1408d..794466b60b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5d5ef1addf..6400ba89c9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/examples/README.md b/examples/README.md index 5db1e18372..d9e3e544db 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 " 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. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 380ab8134f..c9f606766d 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -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. diff --git a/examples/coding-agent/cli.cordis.yml b/examples/coding-agent/cli.cordis.yml new file mode 100644 index 0000000000..5a58dfd889 --- /dev/null +++ b/examples/coding-agent/cli.cordis.yml @@ -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. diff --git a/examples/coding-agent/tests/cli-keyless-smoke.e2e.ts b/examples/coding-agent/tests/cli-keyless-smoke.e2e.ts new file mode 100644 index 0000000000..c2d0f3f946 --- /dev/null +++ b/examples/coding-agent/tests/cli-keyless-smoke.e2e.ts @@ -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) + 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) +}) diff --git a/examples/coding-agent/tests/cli.e2e.ts b/examples/coding-agent/tests/cli.e2e.ts new file mode 100644 index 0000000000..a05e19fc06 --- /dev/null +++ b/examples/coding-agent/tests/cli.e2e.ts @@ -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) +}) diff --git a/examples/coding-agent/tests/fixtures/cli-mock-llm.ts b/examples/coding-agent/tests/fixtures/cli-mock-llm.ts new file mode 100644 index 0000000000..6447ba1e18 --- /dev/null +++ b/examples/coding-agent/tests/fixtures/cli-mock-llm.ts @@ -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 { + 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()) +} diff --git a/examples/coding-agent/tests/fixtures/cli.cordis.yml b/examples/coding-agent/tests/fixtures/cli.cordis.yml new file mode 100644 index 0000000000..bdec0154c4 --- /dev/null +++ b/examples/coding-agent/tests/fixtures/cli.cordis.yml @@ -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.' diff --git a/knip.json b/knip.json index 71c9d1e01d..754aace023 100644 --- a/knip.json +++ b/knip.json @@ -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"] diff --git a/package.json b/package.json index aaec1e62c4..d6f64f210b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/README.md b/packages/README.md index db364421b1..fcd29c3666 100644 --- a/packages/README.md +++ b/packages/README.md @@ -28,7 +28,7 @@ Packages are grouped by modular role at `packages///`. 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` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/examples/README.md b/packages/examples/README.md index 5ee0206d0f..d961e38aeb 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -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. diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md new file mode 100644 index 0000000000..558c5eeff3 --- /dev/null +++ b/packages/examples/cli-demo/README.md @@ -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] +``` + +`--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. diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json new file mode 100644 index 0000000000..3268f9733f --- /dev/null +++ b/packages/examples/cli-demo/package.json @@ -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" + } +} diff --git a/packages/examples/cli-demo/src/bin.ts b/packages/examples/cli-demo/src/bin.ts new file mode 100644 index 0000000000..5b6638ef72 --- /dev/null +++ b/packages/examples/cli-demo/src/bin.ts @@ -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 */ diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts new file mode 100644 index 0000000000..6e42220939 --- /dev/null +++ b/packages/examples/cli-demo/src/cli.ts @@ -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] \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 + /** 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 +} + +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): 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 { + if (signal === undefined) { + await agent.whenIdle() + return + } + if (signal.aborted) { + agent.cancel(interruptionReason(signal)) + throw new CliInterruptedError(interruptionReason(signal)) + } + await new Promise((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 { + 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((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 { + /* 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 +} diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts new file mode 100644 index 0000000000..a2a2271b03 --- /dev/null +++ b/packages/examples/cli-demo/src/index.ts @@ -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 = 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 }) +} diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..aed7f1f2d4 --- /dev/null +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -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 { + return (JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name: string }).name +} + +async function linkPackage(dir: string, nodeModules: string): Promise { + const target = join(nodeModules, await packageName(dir)) + await mkdir(dirname(target), { recursive: true }) + await symlink(dir, target) +} + +async function makeConsumer(): Promise { + 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 { + 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) + 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) +}) diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts new file mode 100644 index 0000000000..4cb7a243a5 --- /dev/null +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -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> { + 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 { + 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 { + 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 + expect(unwrapped).toBe(cliDemo) + expect(unwrapped.name).toBe('cli-demo') + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts new file mode 100644 index 0000000000..c4d46ea3da --- /dev/null +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -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 { + 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((_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 { + 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 => { 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) + 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((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((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') + }) +}) diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json new file mode 100644 index 0000000000..f25b1592ca --- /dev/null +++ b/packages/examples/cli-demo/tsconfig.json @@ -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" } + ] +} diff --git a/packages/examples/cli-demo/tsdown.config.ts b/packages/examples/cli-demo/tsdown.config.ts new file mode 100644 index 0000000000..e5b164d46f --- /dev/null +++ b/packages/examples/cli-demo/tsdown.config.ts @@ -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, +}) diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index ea197b25d0..4527be6430 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -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. diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 72839c6a05..c49fe1f4cc 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -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 + /** Optional world-state assertion run in the isolated cwd before cleanup. */ + readonly inspect?: (cwd: string) => Promise | void } /** Captured output from a Loader smoke that exited successfully. */ @@ -56,10 +62,11 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { + await options.prepare?.(cwd) + const result = await new Promise((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 `${line}\n`).join('')) }) + await options.inspect?.(cwd) + return result } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/packages/support/loader-smoke/tests/fixtures/success.ts b/packages/support/loader-smoke/tests/fixtures/success.ts index fed57162e2..82a63cdfd3 100644 --- a/packages/support/loader-smoke/tests/fixtures/success.ts +++ b/packages/support/loader-smoke/tests/fixtures/success.ts @@ -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, diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 4cc9f878f9..e4e899d67d 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea9494fe81..5be597e52e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0e1774c2e0..2c24396e12 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -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
console logger
pre-created main agent"]`) + } else if (pluginName === '@deepseek-ai/dsh-cli-demo') { + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
pre-created main agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
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)}
${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) } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d103f11461..b01cb8a579 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -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 diff --git a/tsconfig.build.json b/tsconfig.build.json index 05725fe0ae..05f27b0849 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" }, diff --git a/tsconfig.json b/tsconfig.json index af6e900f0f..6f39963be3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" }, From 7278fc21d8bfad12124fe876a61f5a6cc0ba5bc4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:36:42 +0800 Subject: [PATCH 02/13] fix(session-persistence): handle Windows directory fsync --- .../examples/cli-demo/tests/built-bin.e2e.ts | 30 +++++++------- .../session-persistence-jsonl/README.md | 3 +- .../session-persistence-jsonl/src/index.ts | 14 ++++++- .../tests/jsonl.spec.ts | 41 ++++++++++++++++++- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index aed7f1f2d4..7fa9bf977a 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -155,18 +155,20 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { } }, 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) + describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => { + it.each([ + ['SIGINT', 130], + ['SIGTERM', 143], + ] as const)('cancels and disposes on %s with exit %i', async (signal, code) => { + consumer = await makeConsumer() + const result = await runBuiltBin( + consumer, + ['--config', './cordis.yml', '--output-format', 'stream-json', 'hang'], + signal, + ) + expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null }) + expect(result.stdout).toContain('"kind":"aborted"') + expect(result.stderr).toContain(`received ${signal}`) + }, 30_000) + }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 343fb4a70a..1d00f28301 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -21,7 +21,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Durability and crash semantics -- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. +- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. @@ -44,3 +44,4 @@ The plugin buffers frozen session events and drains them on flush or disposal. A - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated. - **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend. +- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 1d13ff424e..95b8b1078c 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -56,6 +56,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi private root: string private coordinator: PersistenceCoordinator + /** Runtime-only host-platform seam for directory-sync compatibility tests. */ + readonly internals: { platform: NodeJS.Platform } = { platform: process.platform } + constructor(ctx: Context, public config: Config) { super(ctx) // Resolve once so later process.cwd() changes cannot split one backend across roots. @@ -202,11 +205,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** fsync a directory so a just-created or published entry inside it is crash-durable. */ + /** fsync a directory when the host exposes that durability primitive. */ private async syncDir(dir: string): Promise { const handle = await open(dir, 'r') try { - await handle.sync() + try { + await handle.sync() + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException | null)?.code + // Node opens directories on Windows but its fsync binding rejects them. + // File-content fsync remains mandatory; only this unsupported primitive is skipped. + if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error + } } finally { await handle.close() } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 75fcb48732..c85278f0c4 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -40,9 +41,25 @@ async function freshRoot(): Promise { } afterEach(async () => { + vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +async function rejectDirectorySync(code: string): Promise { + const handle = await open(root, 'r') + const proto = Object.getPrototypeOf(handle) as { sync: () => Promise } + await handle.close() + const realSync = proto.sync + vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) { + if ((await this.stat()).isDirectory()) { + const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException + error.code = code + throw error + } + return realSync.call(this) + }) +} + function appendClosedTurn(session: Session): void { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { @@ -264,6 +281,28 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) }) + it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => { + await rejectDirectorySync('EPERM') + const backend = ctx.sessionPersistence as SessionPersistenceJsonl + backend.internals.platform = 'win32' + const m = meta('windows-directory-sync') + await ctx.sessionPersistence.create(m) + await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() + expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog()) + }) + + it.each([ + ['linux', 'EPERM'], + ['win32', 'EIO'], + ] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => { + await rejectDirectorySync(code) + const backend = ctx.sessionPersistence as SessionPersistenceJsonl + backend.internals.platform = platform + const m = meta(`directory-sync-${platform}-${code}`) + await ctx.sessionPersistence.create(m) + await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code }) + }) + it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => { const m = meta('meta-copy', '/proj') await ctx.sessionPersistence.create(m) From 5e3da065db2cf3d2442e274dea95311d5c4cd257 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:43:03 +0800 Subject: [PATCH 03/13] test(examples): snapshot headless one-shot stream --- AGENTS.md | 4 +- README.i18n.yaml | 4 +- README.md | 1 + README.zh.md | 1 + docs/testing.md | 4 +- examples/README.md | 2 +- examples/acp-agent/README.md | 2 +- .../advanced-headless.cordis.snapshot.yml | 32 ++++ examples/acp-agent/tests/headless.snapshot.ts | 140 ++++++++++++++++++ .../stream-json.golden.jsonl | 64 ++++++++ examples/coding-agent/README.md | 6 +- package.json | 2 +- packages/examples/cli-demo/README.md | 2 +- 13 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 examples/acp-agent/advanced-headless.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/headless.snapshot.ts create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl diff --git a/AGENTS.md b/AGENTS.md index b9731f2c80..cf6d1c3e89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ pnpm install # pnpm workspaces, node ^22.19 || >=24 pnpm run test # vitest unit tests 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 +pnpm run test:snapshot # keyless ACP/headless replay vs goldens; filter: -t pnpm run test:snapshot:record # re-record goldens (needs key) pnpm run typecheck pnpm run lint @@ -55,7 +55,7 @@ pnpm run hygiene # knip + publint + workspace constraints + NodeNext cons 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 coding REPL (needs key) -pnpm run demo:cli -- "task" # one-shot agent (needs key) +pnpm run demo:headless -- "task" # one-shot agent (needs key) pnpm run demo:cordis # self-modifying runtime demo (needs key) pnpm run demo:acp # ACP server (needs key) ``` diff --git a/README.i18n.yaml b/README.i18n.yaml index 790812344d..15992ac276 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 53dd3896eb15800125673e7c44f7de02daca9376 -README.zh.md: ab826f62658248249ec18c57b35c0065c0f909d1 +README.md: 4d2d2c20a27aff67d42a4555f4e0ce410dd5d6d7 +README.zh.md: 0fb307653f978143b851ef4822d93714910043af diff --git a/README.md b/README.md index 53dd3896eb..4d2d2c20a2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra pnpm install pnpm run test # vitest pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # one-shot agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/README.zh.md b/README.zh.md index ab826f6265..0fb307653f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,6 +12,7 @@ pnpm install pnpm run test # vitest pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # one-shot agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/docs/testing.md b/docs/testing.md index d4acdc33c4..8d318ebd60 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Use `pnpm run test:snapshot:record` when the model transcript should change; use `pnpm run test:snapshot:refresh` when the committed transcript is still the right mock LLM input and replay goldens need keyless rewrite. Review the golden diff. System-prompt/tool-schema content is pinned by ONE scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): real example subprocesses replay recorded model sessions keylessly and compare normalized stdout plus re-persisted logs ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). The primary suite pins ACP JSON-RPC; the headless projection reuses `advanced-toolchain` for `stream-json`. Use `pnpm run test:snapshot:record` when the model transcript changes and `pnpm run test:snapshot:refresh` when only replay outputs change; review the golden diff. One scenario per header class pins system-prompt/tool-schema content; other fixtures tokenize it ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here @@ -30,4 +30,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Any change affecting the editor-facing transcript or end-to-end agent UX — the ACP bridge, the loop's observable output, tool presentation — adds or updates a scenario in the owning example's snapshot suite (`examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory; `examples/acp-agent` is the primary suite), or states in the PR why none applies. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. +A change affecting an editor transcript, headless event stream, or agent UX adds or updates the owning `examples//tests/snapshots/` scenario, or explains its omission in the PR. `examples/acp-agent` hosts the primary [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) table and the headless `stream-json` projection. Plans for new capability seams, lifecycle shapes, or transcript surfaces identify every test tier and any required harness work before implementation. diff --git a/examples/README.md b/examples/README.md index d9e3e544db..399769992f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,7 +17,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " 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 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 interactively with `pnpm run demo:repl`, or run one headless task with `pnpm run demo:headless -- "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. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 35218f4753..427b924d45 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -33,7 +33,7 @@ The editor sets each session's `cwd` to the project it opens, and bash uses that ## Snapshot tests (record-once / replay-deterministic) -This example hosts the ACP snapshot suite. `dsh-llm-replay` reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL, so replay is keyless. Recording runs the real agent and harvests that log; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the full design. +This example hosts the ACP snapshot suite and the headless `stream-json` snapshot. Both replay through `dsh-llm-replay`, which reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL. The headless snapshot reuses `advanced-toolchain` to pin the one-shot stream plus its re-persisted parent and child logs; child activity appears in the stream only through parent tool events. Recording runs the real ACP agent and harvests its logs; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the ACP harness design. ## Permissions and sandboxing diff --git a/examples/acp-agent/advanced-headless.cordis.snapshot.yml b/examples/acp-agent/advanced-headless.cordis.snapshot.yml new file mode 100644 index 0000000000..5047635ddb --- /dev/null +++ b/examples/acp-agent/advanced-headless.cordis.snapshot.yml @@ -0,0 +1,32 @@ +# Replay the advanced toolchain through the headless one-shot front door. It +# receives this replay config explicitly; unlike the ACP bin, it does not swap +# a live config for a sibling snapshot overlay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + disabled: true + - insert: + - id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + model: deepseek-v4-flash + persistenceRoot: './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/tests/headless.snapshot.ts b/examples/acp-agent/tests/headless.snapshot.ts new file mode 100644 index 0000000000..a38931a8ef --- /dev/null +++ b/examples/acp-agent/tests/headless.snapshot.ts @@ -0,0 +1,140 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { delimiter, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + normalizeSessionLog, + normalizeStdout, + scrubRequestHeaders, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { describe, expect, it } from 'vitest' + +const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +const scenarioDir = join(snapshotsDir, 'advanced-toolchain') +const sessionFixture = join(scenarioDir, 'session.jsonl') +const streamGolden = join(scenarioDir, 'stream-json.golden.jsonl') +const configPath = fileURLToPath(new URL('../advanced-headless.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' + +interface JsonObject { + [key: string]: unknown +} + +interface PersistedLog { + readonly content: string + readonly header: JsonObject +} + +function parseJsonl(content: string): JsonObject[] { + return content.split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as JsonObject) +} + +function contextFromLogs(contents: readonly string[]): NormalizeContext { + const headers = contents.map(content => parseJsonl(content)[0]) + return { + sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []), + cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0', + } +} + +function normalizeHeadlessStream(rawStdout: string, cwd: string): string { + const records = parseJsonl(rawStdout) + if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records') + const final = records.at(-1) + if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record') + if (records.slice(0, -1).some(record => record.type !== 'session_event')) { + throw new Error('headless snapshot emitted a non-event record before its result') + } + + const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))] + if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`) + const context: NormalizeContext = { sessionIds, cwd } + const events = records.slice(0, -1).map((record) => { + if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) { + throw new Error('headless snapshot emitted an invalid session event') + } + return record.event as JsonObject + }) + const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog( + `${events.map(event => JSON.stringify(event)).join('\n')}\n`, + context, + ))) + const normalizedRecords = records.map((record, index) => index < normalizedEvents.length + ? { ...record, event: normalizedEvents[index] } + : record) + return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context) +} + +async function advancedPrompt(): Promise { + const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as { + steps?: { op?: unknown; text?: unknown }[] + } + const prompt = input.steps?.find(step => step.op === 'prompt')?.text + if (typeof prompt !== 'string') throw new Error('advanced-toolchain input has no prompt step') + return prompt +} + +async function persistedLogs(cwd: string): Promise { + const root = join(cwd, '.sessions') + const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl')) + return Promise.all(files.map(async (file) => { + const content = await readFile(join(root, file), 'utf8') + return { content, header: parseJsonl(content)[0] ?? {} } + })) +} + +describe('headless stream-json snapshots', () => { + it('replays the advanced toolchain through the one-shot app', async () => { + const prompt = await advancedPrompt() + const expectedSessions = await Promise.all([ + sessionFixture, + join(scenarioDir, 'session.1.jsonl'), + join(scenarioDir, 'session.2.jsonl'), + ].map(file => readFile(file, 'utf8'))) + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'advanced headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-advanced-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: sessionFixture, + DSH_SNAPSHOT_CHILD_FILES: [join(scenarioDir, 'session.1.jsonl'), join(scenarioDir, 'session.2.jsonl')].join(delimiter), + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(3) + const parents = logs.filter(log => typeof log.header.parentSession !== 'string') + expect(parents).toHaveLength(1) + const parent = parents[0] + if (parent === undefined) throw new Error('headless snapshot did not persist its main session') + const children = logs.filter(log => typeof log.header.parentSession === 'string') + .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) + const actualSessions = [parent, ...children] + const actualContext = contextFromLogs(actualSessions.map(log => log.content)) + const expectedContext = contextFromLogs(expectedSessions) + for (const [index, actual] of actualSessions.entries()) { + const expected = expectedSessions[index] + if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`) + expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext))) + } + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamGolden, normalized) + expect(normalized).toBe(await readFile(streamGolden, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl new file mode 100644 index 0000000000..ea5eca3765 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl @@ -0,0 +1,64 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":21,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"ADVANCED_ACP_OK","reason":{"kind":"completed"},"usage":{"inputTokens":18,"outputTokens":18}} diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index c9f606766d..5efe939cfe 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -26,9 +26,9 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem 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" +pnpm run demo:headless -- "fix the failing test in this workspace" +pnpm run demo:headless --output-format json -- "summarize the current implementation" +pnpm run demo:headless --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. diff --git a/package.json b/package.json index d6f64f210b..2bf8506bf8 100644 --- a/package.json +++ b/package.json @@ -74,7 +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:headless": "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", diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 558c5eeff3..38c38cf69c 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -28,7 +28,7 @@ dsh-cli-demo [--config path] [--output-format text|json|stream-json] The root coding demo supplies its overlay: ```sh -pnpm run demo:cli -- "inspect the failing test and fix it" +pnpm run demo:headless -- "inspect the failing test and fix it" ``` Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag. From fb7fa980b8411e285c5e396a42868b2f72d68b92 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:58:44 +0800 Subject: [PATCH 04/13] test(examples): include task controls in CLI composition --- packages/examples/cli-demo/tests/cli-demo.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 4cb7a243a5..1fb0e09999 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -89,7 +89,14 @@ describe('dsh-cli-demo app composition', () => { 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']) + expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([ + 'zulu', + 'alpha', + 'skill', + 'task_kill', + 'task_list', + 'task_output', + ]) }) it('exposes the Loader-safe namespace plugin shape and schema', () => { From 8d35092e267102827a3153d0f113f838571e7b34 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:12:27 +0800 Subject: [PATCH 05/13] fix(examples): isolate headless agent example --- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/graph-atlas.md | 1 + docs/testing.md | 4 +- examples/README.md | 10 +- examples/acp-agent/README.md | 2 +- .../advanced-headless.cordis.snapshot.yml | 32 ------ examples/coding-agent/README.md | 18 +--- examples/coding-agent/cli.cordis.yml | 24 ----- .../tests/fixtures/cli.cordis.yml | 24 ----- examples/headless-agent/README.md | 24 +++++ .../advanced.cordis.snapshot.yml | 12 +++ examples/headless-agent/advanced.cordis.yml | 22 +++++ examples/headless-agent/composition.md | 70 +++++++++++++ examples/headless-agent/cordis.yml | 97 +++++++++++++++++++ examples/headless-agent/package.json | 7 ++ .../tests/fixtures/cli-mock-llm.ts | 2 +- .../tests/fixtures/cli.cordis.yml | 17 ++++ .../tests/headless.snapshot.ts | 2 +- .../tests/keyless-smoke.e2e.ts} | 6 +- .../tests/real-model.e2e.ts} | 8 +- .../snapshots/advanced-toolchain/input.json | 7 ++ .../advanced-toolchain/session.1.jsonl | 13 +++ .../advanced-toolchain/session.2.jsonl | 13 +++ .../advanced-toolchain/session.jsonl | 64 ++++++++++++ .../stream-json.golden.jsonl | 20 ++-- knip.json | 3 +- package.json | 2 +- packages/examples/cli-demo/README.md | 6 +- packages/examples/cli-demo/package.json | 2 +- packages/support/llm-replay/README.md | 2 +- packages/support/loader-smoke/README.md | 2 +- scripts/gen-doc-graphs.ts | 10 ++ vitest.snapshot.config.ts | 8 +- 38 files changed, 410 insertions(+), 140 deletions(-) delete mode 100644 examples/acp-agent/advanced-headless.cordis.snapshot.yml delete mode 100644 examples/coding-agent/cli.cordis.yml delete mode 100644 examples/coding-agent/tests/fixtures/cli.cordis.yml create mode 100644 examples/headless-agent/README.md create mode 100644 examples/headless-agent/advanced.cordis.snapshot.yml create mode 100644 examples/headless-agent/advanced.cordis.yml create mode 100644 examples/headless-agent/composition.md create mode 100644 examples/headless-agent/cordis.yml create mode 100644 examples/headless-agent/package.json rename examples/{coding-agent => headless-agent}/tests/fixtures/cli-mock-llm.ts (95%) create mode 100644 examples/headless-agent/tests/fixtures/cli.cordis.yml rename examples/{acp-agent => headless-agent}/tests/headless.snapshot.ts (98%) rename examples/{coding-agent/tests/cli-keyless-smoke.e2e.ts => headless-agent/tests/keyless-smoke.e2e.ts} (93%) rename examples/{coding-agent/tests/cli.e2e.ts => headless-agent/tests/real-model.e2e.ts} (83%) create mode 100644 examples/headless-agent/tests/snapshots/advanced-toolchain/input.json create mode 100644 examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl create mode 100644 examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl create mode 100644 examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl rename examples/{acp-agent => headless-agent}/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl (92%) diff --git a/README.i18n.yaml b/README.i18n.yaml index 15992ac276..5fa4727e00 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4d2d2c20a27aff67d42a4555f4e0ce410dd5d6d7 -README.zh.md: 0fb307653f978143b851ef4822d93714910043af +README.md: f3b602b070f32101ebab5e11d95e4d49f7ad77a2 +README.zh.md: 0e77269c121f7e95e71bc50855f6df8da9807651 diff --git a/README.md b/README.md index 4d2d2c20a2..f3b602b070 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra pnpm install pnpm run test # vitest pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # headless-agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/README.zh.md b/README.zh.md index 0fb307653f..0e77269c12 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,7 +12,7 @@ pnpm install pnpm run test # vitest pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # headless-agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 207ea114e0..ce6b8a5316 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 3474bc116b43f9be57b52e947f9cf99730f7e796 -extension-cookbook.zh.md: 1a605b20fe4e171a948ac2a046193a2f4d884e44 +extension-cookbook.md: 9d583a484fca89810bf1e7062fe9aab5b2a2da5e +extension-cookbook.zh.md: afb6edee56be5aeec22957acb903a6ac3a869856 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 3474bc116b..9d583a484f 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle. +Five complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + coding tools behind a terminal REPL, `pnpm run demo:repl`), [`examples/headless-agent`](../../examples/headless-agent) (the same capability class behind a one-shot positional task and DSH-native output, `pnpm run demo:headless -- "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (the self-referential runtime-inspection demo, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is its swappable backends plus one app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the headless demo loads [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share the spine through [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 1a605b20fe..afb6edee56 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。 +五个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + coding 工具,配合终端 REPL,`pnpm run demo:repl`)、[`examples/headless-agent`](../../examples/headless-agent)(同类能力通过单次位置任务和 DSH 原生输出运行,`pnpm run demo:headless -- "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自引用的运行时检查演示,`pnpm run demo:cordis`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子由其可替换后端加一个 app 包入口组成:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),headless 演示加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 60de01ef81..6645fb5ef9 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -14,6 +14,7 @@ The process decision behind this index is recorded in [the documentation graph R | [capability seams and core services](capability-seams.md) | `hybrid generated` | | [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | | [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` | +| [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | | [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` | diff --git a/docs/testing.md b/docs/testing.md index 8d318ebd60..cf7343f8d9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): real example subprocesses replay recorded model sessions keylessly and compare normalized stdout plus re-persisted logs ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). The primary suite pins ACP JSON-RPC; the headless projection reuses `advanced-toolchain` for `stream-json`. Use `pnpm run test:snapshot:record` when the model transcript changes and `pnpm run test:snapshot:refresh` when only replay outputs change; review the golden diff. One scenario per header class pins system-prompt/tool-schema content; other fixtures tokenize it ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): real example subprocesses replay recorded model sessions keylessly and compare normalized stdout plus re-persisted logs ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). `examples/acp-agent` pins ACP JSON-RPC, while `examples/headless-agent` independently pins its `stream-json` event surface. Use `pnpm run test:snapshot:record` when an ACP model transcript changes and `pnpm run test:snapshot:refresh` when replay outputs change; review the golden diff. One ACP scenario per header class pins system-prompt/tool-schema content; other ACP fixtures tokenize it ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here @@ -30,4 +30,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -A change affecting an editor transcript, headless event stream, or agent UX adds or updates the owning `examples//tests/snapshots/` scenario, or explains its omission in the PR. `examples/acp-agent` hosts the primary [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) table and the headless `stream-json` projection. Plans for new capability seams, lifecycle shapes, or transcript surfaces identify every test tier and any required harness work before implementation. +A change affecting an editor transcript, headless event stream, or agent UX adds or updates the owning `examples//tests/snapshots/` scenario, or explains its omission in the PR. `examples/acp-agent` owns the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) table; `examples/headless-agent` owns the `stream-json` snapshot and its replay fixtures. Plans for new capability seams, lifecycle shapes, or transcript surfaces identify every test tier and any required harness work before implementation. diff --git a/examples/README.md b/examples/README.md index 399769992f..2ee6d3eee9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,10 +17,16 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " 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 interactively with `pnpm run demo:repl`, or run one headless task with `pnpm run demo:headless -- "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 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 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. +## headless-agent + +A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. + +Run with: `pnpm run demo:headless -- "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the wire contract, mutation and token risks, and the headless-owned snapshot suite. + ## cordis-agent The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. @@ -29,7 +35,7 @@ Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/R ## acp-agent -An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. +An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) app — drive it from Zed or any other ACP client. It owns the ACP keyless snapshot suite. Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 427b924d45..000f693941 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -33,7 +33,7 @@ The editor sets each session's `cwd` to the project it opens, and bash uses that ## Snapshot tests (record-once / replay-deterministic) -This example hosts the ACP snapshot suite and the headless `stream-json` snapshot. Both replay through `dsh-llm-replay`, which reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL. The headless snapshot reuses `advanced-toolchain` to pin the one-shot stream plus its re-persisted parent and child logs; child activity appears in the stream only through parent tool events. Recording runs the real ACP agent and harvests its logs; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the ACP harness design. +This example hosts the ACP snapshot suite. It replays through `dsh-llm-replay`, which reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL. Recording runs the real ACP agent and harvests its logs; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the ACP harness design. ## Permissions and sandboxing diff --git a/examples/acp-agent/advanced-headless.cordis.snapshot.yml b/examples/acp-agent/advanced-headless.cordis.snapshot.yml deleted file mode 100644 index 5047635ddb..0000000000 --- a/examples/acp-agent/advanced-headless.cordis.snapshot.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Replay the advanced toolchain through the headless one-shot front door. It -# receives this replay config explicitly; unlike the ACP bin, it does not swap -# a live config for a sibling snapshot overlay. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: acp-agent - name: '@deepseek-ai/dsh-acp-demo' - disabled: true - - insert: - - id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' - config: - model: deepseek-v4-flash - persistenceRoot: './.sessions' - tools: - mode: both - persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. - - Verify your work by running the code or tests. Keep answers brief and factual. - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' - - id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index cf47fb2a85..8dfff33f8b 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,6 +1,6 @@ # coding-agent -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. +Coding-agent REPL wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + JSONL persistence. ## Run it @@ -21,20 +21,6 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem … ``` -### 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:headless -- "fix the failing test in this workspace" -pnpm run demo:headless --output-format json -- "summarize the current implementation" -pnpm run demo:headless --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: @@ -83,4 +69,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. `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. +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 Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` and `tests/code-mode-keyless-smoke.e2e.ts`. diff --git a/examples/coding-agent/cli.cordis.yml b/examples/coding-agent/cli.cordis.yml deleted file mode 100644 index 5a58dfd889..0000000000 --- a/examples/coding-agent/cli.cordis.yml +++ /dev/null @@ -1,24 +0,0 @@ -# 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. diff --git a/examples/coding-agent/tests/fixtures/cli.cordis.yml b/examples/coding-agent/tests/fixtures/cli.cordis.yml deleted file mode 100644 index bdec0154c4..0000000000 --- a/examples/coding-agent/tests/fixtures/cli.cordis.yml +++ /dev/null @@ -1,24 +0,0 @@ -- 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.' diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md new file mode 100644 index 0000000000..5c21855afe --- /dev/null +++ b/examples/headless-agent/README.md @@ -0,0 +1,24 @@ +# headless-agent + +Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. + +## Run it + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:headless -- "fix the failing test in this workspace" +pnpm run demo:headless --output-format json -- "summarize the implementation" +pnpm run demo:headless --output-format stream-json -- "run the focused tests" +``` + +Exactly one nonblank positional task is required; quote tasks containing spaces. 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. Child sessions surface only through parent tool events and results. + +Each invocation creates and persists a fresh session, runs all model and tool steps in one turn, flushes, disposes, and exits. This is non-interactive automation: there is no prompt, approval, resume, second turn, or stdin context. The configured tools can mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. + +## Advanced and snapshot wiring + +[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the shipped leaf. [`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) replaces only the live LLM with replay. The tests under [`tests/`](tests/) own the keyless real-Loader smoke, key-gated world-verified smoke, and the `stream-json` replay snapshot with its parent and child session fixtures. + +The package-level [CLI contract](../../packages/examples/cli-demo/README.md) documents output records, exit status, cancellation, persistence, and model/token effects. diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml new file mode 100644 index 0000000000..48541a1054 --- /dev/null +++ b/examples/headless-agent/advanced.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Replay counterpart to advanced.cordis.yml; only the live model is replaced. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./advanced.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml new file mode 100644 index 0000000000..646bd8a11c --- /dev/null +++ b/examples/headless-agent/advanced.cordis.yml @@ -0,0 +1,22 @@ +# Add Code Mode and Cordis tools to the headless spawn/workflow stack. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + model: deepseek-v4-flash + persistenceRoot: './.sessions' + tools: + mode: both + persona: | + You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md new file mode 100644 index 0000000000..9a734fa2f0 --- /dev/null +++ b/examples/headless-agent/composition.md @@ -0,0 +1,70 @@ + + +# Headless Agent App Composition + +The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted main session. + +```mermaid +flowchart LR + cfg["examples/headless-agent
cordis.yml"] + plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_headless_llm_deepseek + plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_headless_bash + plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] + cfg --> plugin_headless_cli_agent + plugin_headless_cli_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_headless_cli_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_headless_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_headless_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_headless_compact_basic + plugin_headless_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_headless_subagent + plugin_headless_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_headless_subagent_spawn + plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_headless_subagent_fork + plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_headless_tool_subagent + plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_headless_tool_subagent_fork + plugin_headless_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_headless_workflow_workerthread + plugin_headless_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_headless_tool_workflow + plugin_headless_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_headless_tool_todo + plugin_headless_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_headless_fs_local + plugin_headless_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_headless_fs_policy + plugin_headless_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_headless_tool_fs +``` + +| Plugin id | Package / module | +| --- | --- | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `cli-agent` | `@deepseek-ai/dsh-cli-demo` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | +| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | + +Source config: [`examples/headless-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml new file mode 100644 index 0000000000..3cf1d127a2 --- /dev/null +++ b/examples/headless-agent/cordis.yml @@ -0,0 +1,97 @@ +# One-shot coding agent with format-pure stdout. The app bin loads the +# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional +# `DEEPSEEK_BASE_URL` through `!!js`. + +# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed +# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-pro + - deepseek-v4-flash + +# Local executor for the app bundle's bash tool. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +# The app bundle pre-creates one fresh `main` agent per invocation. +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + model: deepseek-v4-flash + persistenceRoot: './.sessions' + persona: | + You are headless-agent, a coding assistant powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +# Summarize an older range when derived history approaches the context window. +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 + +# Expose fresh-child `spawn` and completed-prefix `fork` through independent +# in-process backends. +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend. +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +# `todo_write` replaces the logged whole list. +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# Policy loads before the model-facing filesystem tools so writes and edits +# require an observed file. Relative paths resolve from the process cwd. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/headless-agent/package.json b/examples/headless-agent/package.json new file mode 100644 index 0000000000..c331af0f05 --- /dev/null +++ b/examples/headless-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "headless-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable demo: one complete headless coding-agent turn" +} diff --git a/examples/coding-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts similarity index 95% rename from examples/coding-agent/tests/fixtures/cli-mock-llm.ts rename to examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 6447ba1e18..5238e67374 100644 --- a/examples/coding-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,7 +1,7 @@ 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. */ +/** Keyless headless-agent adapter: one real bash call followed by a final answer. */ class CliMockAdapter extends LlmAdapter { async * stream(options: GenerateOptions): AsyncIterable { const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml new file mode 100644 index 0000000000..7e042789b2 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -0,0 +1,17 @@ +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + model: cli-mock + persistenceRoot: './.sessions' + persona: 'Keyless headless-agent smoke.' diff --git a/examples/acp-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts similarity index 98% rename from examples/acp-agent/tests/headless.snapshot.ts rename to examples/headless-agent/tests/headless.snapshot.ts index a38931a8ef..ff27347246 100644 --- a/examples/acp-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -14,7 +14,7 @@ const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const scenarioDir = join(snapshotsDir, 'advanced-toolchain') const sessionFixture = join(scenarioDir, 'session.jsonl') const streamGolden = join(scenarioDir, 'stream-json.golden.jsonl') -const configPath = fileURLToPath(new URL('../advanced-headless.cordis.snapshot.yml', import.meta.url)) +const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' diff --git a/examples/coding-agent/tests/cli-keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts similarity index 93% rename from examples/coding-agent/tests/cli-keyless-smoke.e2e.ts rename to examples/headless-agent/tests/keyless-smoke.e2e.ts index c2d0f3f946..57b8660c03 100644 --- a/examples/coding-agent/tests/cli-keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -8,12 +8,12 @@ const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src 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', () => { +describe('headless-agent 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-', + label: 'headless-agent', + tempDirPrefix: 'headless-agent-smoke-', binScript, configPath, binArgs: ['--config', configPath, '--output-format', 'stream-json', 'prove the tool path'], diff --git a/examples/coding-agent/tests/cli.e2e.ts b/examples/headless-agent/tests/real-model.e2e.ts similarity index 83% rename from examples/coding-agent/tests/cli.e2e.ts rename to examples/headless-agent/tests/real-model.e2e.ts index a05e19fc06..653f5ab624 100644 --- a/examples/coding-agent/tests/cli.e2e.ts +++ b/examples/headless-agent/tests/real-model.e2e.ts @@ -5,16 +5,16 @@ 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 configPath = fileURLToPath(new URL('../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', () => { +describe.skipIf(!hasKey)('headless-agent 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-', + label: 'headless-agent real model', + tempDirPrefix: 'headless-agent-real-', binScript, configPath, binArgs: [ diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json new file mode 100644 index 0000000000..41072a211a --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK." } + ] +} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl new file mode 100644 index 0000000000..95924ff6c6 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl new file mode 100644 index 0000000000..5e4387ccb7 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl new file mode 100644 index 0000000000..a1785105cd --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless"} +{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} +{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} +{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} +{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl similarity index 92% rename from examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl rename to examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl index ea5eca3765..35aad5fc89 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl @@ -1,5 +1,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -34,13 +34,13 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -54,11 +54,11 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"ADVANCED_ACP_OK","reason":{"kind":"completed"},"usage":{"inputTokens":18,"outputTokens":18}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"ADVANCED_HEADLESS_OK","reason":{"kind":"completed"},"usage":{"inputTokens":18,"outputTokens":18}} diff --git a/knip.json b/knip.json index 0d367d84a6..348500575c 100644 --- a/knip.json +++ b/knip.json @@ -9,7 +9,8 @@ "examples/echo-agent/src/*.ts", "examples/echo-agent/tests/**/*.e2e.ts", "examples/coding-agent/tests/**/*.e2e.ts", - "examples/coding-agent/tests/fixtures/*.ts", + "examples/headless-agent/tests/**/*.e2e.ts", + "examples/headless-agent/tests/fixtures/*.ts", "examples/cordis-agent/tests/**/*.e2e.ts", "examples/acp-agent/tests/**/*.e2e.ts", "examples/*/tests/**/*.snapshot.ts" diff --git a/package.json b/package.json index 2bf8506bf8..b3e13479f0 100644 --- a/package.json +++ b/package.json @@ -74,7 +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:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/coding-agent/cli.cordis.yml", + "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/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", diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 38c38cf69c..5334a5bcd9 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -1,6 +1,6 @@ # @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. +Headless one-shot app and bin for running one 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. @@ -25,7 +25,7 @@ dsh-cli-demo [--config path] [--output-format text|json|stream-json] `--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: +The root headless-agent example supplies its leaf: ```sh pnpm run demo:headless -- "inspect the failing test and fix it" @@ -45,7 +45,7 @@ The task turn is explicitly flushed before final output. Session logs remain und ## 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. +The headless-agent leaf supplies local bash, filesystem, skill, subagent, workflow, and todo capabilities. A task can therefore mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. Run the CLI from the intended project directory, review the leaf's capability and sandbox configuration, and do not treat non-interactive execution as an approval boundary. ## Model Experience diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index 3268f9733f..5f6d5d04c2 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-cli-demo", - "description": "Headless one-shot coding-agent app with text and DSH-native JSON output", + "description": "Headless one-shot agent app with text and DSH-native JSON output", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 847064b160..704173cf52 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -2,7 +2,7 @@ A replay LLM plugin for keyless snapshot tests. It installs a single `llm/stream` waterfall listener that short-circuits the waterfall (never calls `next()`) and yields model streams reconstructed from a recorded **session JSONL** fixture — so a test can boot the real agent against a fixed model transcript with no API key. -Its consumer is the ACP snapshot harness in `examples/acp-agent`, which loads this plugin (via `cordis.snapshot.yml`) in place of a real LLM adapter. The package exists so its derive/parse/replay logic falls under the per-file 100% coverage gate on `packages/*/src` (the same logic, while it lived under `examples/`, was outside the gate). +Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. The package exists so its derive/parse/replay logic falls under the per-file 100% coverage gate on `packages/*/src` (the same logic, while it lived under `examples/`, was outside the gate). ## How the fixture works diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 4527be6430..7514babb8a 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -4,7 +4,7 @@ Shared subprocess harness for keyless example smokes that boot a real app bin an 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. -This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`. +This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,headless-agent,cordis-agent}`. ## Model Experience diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5996cd203e..505921ab40 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -409,6 +409,14 @@ const APP_EXAMPLES = [ config: 'examples/coding-agent/cordis.yml', summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', }, + { + id: 'headless', + rel: 'examples/headless-agent/composition.md', + title: 'Headless Agent App Composition', + label: 'examples/headless-agent', + config: 'examples/headless-agent/cordis.yml', + summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted main session.', + }, { id: 'cordis', rel: 'examples/cordis-agent/composition.md', @@ -944,6 +952,7 @@ function renderIndex(docs: GraphDoc[]): string { 'docs/capability-seams.md': 'capability seams and core services', 'examples/echo-agent/composition.md': 'echo-agent app composition', 'examples/coding-agent/composition.md': 'coding-agent app composition', + 'examples/headless-agent/composition.md': 'headless-agent app composition', 'examples/cordis-agent/composition.md': 'cordis-agent app composition', 'examples/acp-agent/composition.md': 'acp-agent app composition', 'docs/event-producer-consumer.md': 'event producer/consumer matrix', @@ -955,6 +964,7 @@ function renderIndex(docs: GraphDoc[]): string { 'docs/capability-seams.md': 'hybrid generated', 'examples/echo-agent/composition.md': 'hybrid generated', 'examples/coding-agent/composition.md': 'hybrid generated', + 'examples/headless-agent/composition.md': 'hybrid generated', 'examples/cordis-agent/composition.md': 'hybrid generated', 'examples/acp-agent/composition.md': 'hybrid generated', 'docs/event-producer-consumer.md': 'hybrid generated', diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index dbc51eae41..772bbf3f72 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,10 +1,10 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Replay is the keyless default: boot the real ACP subprocess from recorded model scripts and diff -// normalized transcript plus persisted-log goldens. `record` calls the real API and updates fixtures -// and goldens; `refresh` replays committed scripts and updates only current goldens. Replay/refresh -// never load `.env`; only record reads a key from the environment or gitignored root `.env`. +// Replay is the keyless default: boot real example subprocesses from recorded model scripts and diff +// normalized protocol/event output plus persisted-log goldens. ACP `record` calls the real API and +// updates fixtures and goldens; `refresh` replays committed scripts and updates current goldens. +// Replay/refresh never load `.env`; only record reads a key from the environment or root `.env`. if (process.env.DSH_SNAPSHOT === 'record') { try { process.loadEnvFile(new URL('.env', import.meta.url).pathname) From 89987f0ad4615ef4b4f8db3854dc30c542dbe614 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:25:30 +0800 Subject: [PATCH 06/13] docs: align one-shot demo prose --- AGENTS.md | 4 +-- docs/config-catalog.md | 6 ++--- docs/testing.md | 2 +- examples/README.md | 2 +- examples/coding-agent/README.md | 9 +------ examples/headless-agent/README.md | 2 +- examples/headless-agent/composition.md | 4 +-- examples/headless-agent/cordis.yml | 1 - packages/examples/cli-demo/README.md | 16 ++++++------ packages/examples/cli-demo/src/cli.ts | 25 ++++++++----------- packages/examples/cli-demo/src/index.ts | 10 ++++---- .../session-persistence-jsonl/src/index.ts | 2 +- packages/support/llm-replay/README.md | 2 +- packages/support/loader-smoke/src/index.ts | 2 -- scripts/gen-doc-graphs.ts | 4 +-- 15 files changed, 38 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb4d1973ce..efaa29bbac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends ui/ ACP/stdio/TUI/JSON-RPC bridges; boot, approval, interaction plugins - examples/ demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) leaves load + examples/ demo bundles (agent-spine + stdio/CLI/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) @@ -47,7 +47,7 @@ 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:snapshot # keyless ACP/headless replay vs goldens; filter: -t +pnpm run test:snapshot # keyless ACP/headless/TUI replay vs goldens; filter: -t pnpm run test:snapshot:record # re-record goldens (needs key) pnpm run typecheck pnpm run lint diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bd68554d6a..adbd0eaf4a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -209,11 +209,11 @@ Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-san ## `@deepseek-ai/dsh-cli-demo` ```ts config-catalog -/** App config forwarded to the spine, pre-created agent, and JSONL backend. */ +/** App config forwarded to the spine, configured agent, and JSONL backend. */ export interface Config { - /** Provider route for the `main` agent. */ + /** Provider route for the configured agent. */ provider: string - /** Model name for the `main` agent; a matching adapter must be registered. */ + /** Model name for the configured agent; a matching adapter must be registered. */ model: string /** Deployment persona forwarded to the system-prompt plugin. */ persona?: string diff --git a/docs/testing.md b/docs/testing.md index 2fb0e5b2c1..cd3b1aa9e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized stdout plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); `examples/headless-agent` independently pins its `stream-json` event surface through the real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state goldens; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and golden diff. System-prompt/tool-schema content is pinned by ONE ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state goldens; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and golden diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here diff --git a/examples/README.md b/examples/README.md index 7badb5599e..ef3abfdd85 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,7 +25,7 @@ Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. -Run with: `pnpm run demo:headless -- "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the wire contract, mutation and token risks, and the headless-owned snapshot suite. +Run with: `pnpm run demo:headless -- "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. ## tui-agent diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 470872dedf..986608b3af 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -13,14 +13,6 @@ pnpm run demo:repl Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`. -``` -> 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] - … -``` - The REPL renders reasoning, tool calls/results, and the latest todo list as line-oriented output suitable for terminals and pipes. Use `pnpm run demo:tui` for the interactive Markdown/card interface. ### Resuming a prior session @@ -60,6 +52,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf | | `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`) | +| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | | `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 5c21855afe..285263146f 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -13,7 +13,7 @@ pnpm run demo:headless --output-format json -- "summarize the implementation" pnpm run demo:headless --output-format stream-json -- "run the focused tests" ``` -Exactly one nonblank positional task is required; quote tasks containing spaces. 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. Child sessions surface only through parent tool events and results. +Exactly one nonblank positional task is required; quote tasks containing spaces. 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 top-level session's canonical task-turn events before that record. Child sessions surface only through parent tool events and results. Each invocation creates and persists a fresh session, runs all model and tool steps in one turn, flushes, disposes, and exits. This is non-interactive automation: there is no prompt, approval, resume, second turn, or stdin context. The configured tools can mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 9a734fa2f0..9533af28d3 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -3,7 +3,7 @@ # Headless Agent App Composition -The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted main session. +The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session. ```mermaid flowchart LR @@ -16,7 +16,7 @@ flowchart LR cfg --> plugin_headless_cli_agent plugin_headless_cli_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_headless_cli_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_headless_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
pre-created main agent"] + plugin_headless_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
fresh top-level agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 11136eeb86..b119ccf0de 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -13,7 +13,6 @@ - deepseek-v4-pro - deepseek-v4-flash -# Local executor for the app bundle's bash tool. - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index bbe867487f..5197e3bcd0 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-cli-demo -Headless one-shot app and bin for running one 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. +Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. @@ -8,8 +8,8 @@ The package mounts no console logger, readline UI, user-interaction service, or | Key | Default | Routed to | |---|---|---| -| `provider` | required | the pre-created `main` agent's provider route | -| `model` | required | the pre-created `main` agent's model | +| `provider` | required | the configured agent's provider route | +| `model` | required | the configured 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` | @@ -17,8 +17,6 @@ The package mounts no console logger, readline UI, user-interaction service, or | `persistenceRoot` | `./.sessions` | JSONL session root | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | -Each process creates a new session whose workspace cwd is the launch directory. The app has no resume setting. - ## CLI contract ```sh @@ -39,7 +37,7 @@ Loader configs with bare package specifiers require `node --expose-internals` or - `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. +- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. @@ -53,12 +51,12 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl ### 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 configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn. +**What the model sees**: The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn. **Token effect**: The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total. ## Known Limitations and Deferred Work -- **One fresh main session per process** — there is no resume, second prompt, stdin context, or concurrent top-level session in this app. +- **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app. - **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy. -- **Streaming is main-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn. +- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn. diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 7b5a907e43..1f19e3ddf5 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -1,6 +1,6 @@ /** - * Covered command parser and one-turn driver for `dsh-cli-demo`. The executable - * entry only installs process signal handlers and delegates here. + * Command parser and one-turn driver for `dsh-cli-demo`. The executable wrapper + * owns process signals; this module owns output, durability, and cleanup. * @module @deepseek-ai/dsh-cli-demo/cli */ @@ -44,9 +44,9 @@ export interface CliResult { export interface OneShotOptions { /** Exactly one nonblank user task. */ readonly task: string - /** Optional cancellation signal owned by the process wrapper. */ + /** Optional signal that cancels the selected agent. */ readonly signal?: AbortSignal - /** Synchronous observer for each canonical event in the selected task turn. */ + /** Synchronous task-turn observer; a throw cancels the agent and fails the run after flush. */ readonly onEvent?: (sessionId: string, event: SessionEvent) => void } @@ -91,12 +91,10 @@ class CliInterruptedError extends Error { } } -/** 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) } @@ -145,7 +143,6 @@ export function parseCliArgs(args: readonly string[]): CliCommand { } } -/** 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, @@ -157,7 +154,6 @@ function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage { return next } -/** Select the text blocks from an assistant message, or undefined when it has none. */ function assistantText(event: Extract): string | undefined { const blocks = event.data.content.filter(block => block.type === 'text') return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('') @@ -189,8 +185,11 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise = z.object({ }) /** - * Compose the UI-less spine, a fresh `main` agent rooted at the process cwd, + * Compose the UI-less spine, a fresh top-level agent rooted at the process cwd, * and JSONL persistence. Swappable adapters, executors, and product tools stay * in the leaf `cordis.yml`. * @param ctx - app context that owns the composed child plugins. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 40a054ceb0..4e52cb0b9e 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -57,7 +57,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi private root: string private coordinator: PersistenceCoordinator - /** Runtime-only host-platform seam for directory-sync compatibility tests. */ + /** Runtime host platform used to decide whether directory sync is supported. */ readonly internals: { platform: NodeJS.Platform } = { platform: process.platform } constructor(ctx: Context, public config: Config) { diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index bd738e4641..b256ef9a59 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -2,7 +2,7 @@ A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is visible to clients such as ACP editors; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery. -Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. The package exists so its derive/parse/replay logic falls under the per-file 100% coverage gate on `packages/*/src` (the same logic, while it lived under `examples/`, was outside the gate). +Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`. ## How the fixture works diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index ca3396542e..36ab137f32 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -7,8 +7,6 @@ * zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths` * map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an * installed consumer does, while Node type-strips relative example-local TypeScript plugins). - * Consolidating that spawn glue here retires the copies in the ACP snapshot harness and the example - * e2e drivers (the `TODO(acp-test-harness)`). * * @module @deepseek-ai/dsh-loader-smoke */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 46e67b1056..4875160d81 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -448,7 +448,7 @@ const APP_EXAMPLES = [ title: 'Headless Agent App Composition', label: 'examples/headless-agent', config: 'examples/headless-agent/cordis.yml', - summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted main session.', + summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.', }, { id: 'cordis', @@ -483,7 +483,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string : 'dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent' lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`) } else if (pluginName === '@deepseek-ai/dsh-cli-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
pre-created main agent"]`) + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
fresh top-level agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"]`) } From f4a30da10df2e6b227d6213926982bb108cf65e7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:32:04 +0800 Subject: [PATCH 07/13] chore: align one-shot demo workspace metadata --- examples/package.json | 1 + knip.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 5 insertions(+) diff --git a/examples/package.json b/examples/package.json index 8d77731a05..76349b2ea4 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", + "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", diff --git a/knip.json b/knip.json index 8dedeb2f4c..2c3465ab38 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "examples": { "entry": [ "echo-agent/src/*.ts", + "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0b312a484..d7649345fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:* version: link:../packages/bash/bash-sandbox + '@deepseek-ai/dsh-cli-demo': + specifier: workspace:* + version: link:../packages/examples/cli-demo '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:* version: link:../packages/code-runtime/code-runtime-worker From 692ef8e61f1ea34cc30e0736c48a951f3dcdb693 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:37:27 +0800 Subject: [PATCH 08/13] test: align headless replay with model routing --- examples/headless-agent/cordis.yml | 4 ++-- .../snapshots/advanced-toolchain/session.1.jsonl | 4 ++-- .../snapshots/advanced-toolchain/session.2.jsonl | 4 ++-- .../snapshots/advanced-toolchain/session.jsonl | 14 +++++++------- .../advanced-toolchain/stream-json.golden.jsonl | 14 +++++++------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index b119ccf0de..7f48c529c4 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -10,8 +10,8 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - - deepseek-v4-pro - - deepseek-v4-flash + - id: deepseek-v4-pro + - id: deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 95924ff6c6..b4e8cd6340 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,12 +2,12 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 5e4387ccb7..d8d2e59e6e 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,12 +2,12 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index a1785105cd..e3849110ce 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,13 +2,13 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} {"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} @@ -18,7 +18,7 @@ {"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} {"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} {"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} {"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} @@ -29,7 +29,7 @@ {"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} {"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} {"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} {"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} @@ -39,7 +39,7 @@ {"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} {"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} {"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} @@ -49,7 +49,7 @@ {"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} {"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} {"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} @@ -59,6 +59,6 @@ {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} {"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl index 35aad5fc89..44577f1fe0 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl @@ -1,13 +1,13 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}} @@ -17,7 +17,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":21,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"}} @@ -28,7 +28,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}} @@ -38,7 +38,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}} @@ -48,7 +48,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}} @@ -58,7 +58,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"ADVANCED_HEADLESS_OK","reason":{"kind":"completed"},"usage":{"inputTokens":18,"outputTokens":18}} From 2ef9590f3323ebf7293b49d73394ca0fb28c2f09 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:51:41 +0800 Subject: [PATCH 09/13] test(loader-smoke): declare argv lib fixture --- packages/support/loader-smoke/tests/loader-smoke.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index ec5d01db2b..e8f6554691 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -50,6 +50,7 @@ describe('runLoaderSmoke', () => { label: 'argv fixture', tempDirPrefix: 'loader-smoke-argv-', binScript: fixture('success'), + libBinScript: fixture('success'), configPath, binArgs: ['--config', configPath, '--output-format', 'json', 'task with spaces'], tsconfigPath, From ae419fb692322bdb48facc1394807ad25e8b1e3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:27:10 +0800 Subject: [PATCH 10/13] fix(cli-demo): interrupt Loader boot on signals Race Loader startup with the process abort signal so SIGINT and SIGTERM can settle the one-shot CLI even when initialization has not returned a Context. If boot settles after cancellation, dispose the late context asynchronously instead of recreating the wait. Contain late boot rejection and report a late disposal failure on stderr. Cover prompt interruption, late context disposal, late boot rejection, and cleanup failure with focused CLI regressions. --- packages/examples/cli-demo/src/cli.ts | 55 +++++++++++++- packages/examples/cli-demo/tests/cli.spec.ts | 77 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 1f19e3ddf5..63c08ae39f 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -294,6 +294,53 @@ function renderResult(outputFormat: OutputFormat, result: CliResult): string { return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n` } +/** + * Race Loader boot with cancellation without abandoning a context that becomes + * available after the caller has been released. Waiting for that late context + * would recreate the signal hang, so its disposal and diagnostics run detached. + */ +async function bootInterruptibly( + start: () => Promise, + signal: AbortSignal | undefined, + disposeLateContext: (ctx: Context) => Promise, + reportLateDisposalFailure: (error: unknown) => void, +): Promise { + if (signal === undefined) return await start() + if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal)) + + let onAbort!: () => void + const interruptedBoot = new Promise((_resolve, reject) => { + onAbort = (): void => { + reject(new CliInterruptedError(interruptionReason(signal))) + } + signal.addEventListener('abort', onAbort, { once: true }) + /* v8 ignore next -- closes registration against a non-standard synchronously mutating signal */ + if (signal.aborted) onAbort() + }) + const booting = Promise.resolve().then(start) + try { + return await Promise.race([booting, interruptedBoot]) + } catch (error: unknown) { + // The awaited race permits the signal to change after the preflight check. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (signal.aborted) { + void booting.then( + async (lateContext) => { + try { + await disposeLateContext(lateContext) + } catch (error: unknown) { + reportLateDisposalFailure(error) + } + }, + () => {}, + ) + } + throw error + } finally { + signal.removeEventListener('abort', onAbort) + } +} + /** * Render a non-completed turn reason for stderr. * @param reason - durable turn ending to describe. @@ -349,8 +396,12 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime = 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)) + ctx = await bootInterruptibly( + () => bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd)), + runtime.signal, + disposeContext, + error => writeStderr(`${CLI_NAME}: dispose after interrupted boot failed: ${toError(error).message}\n`), + ) const result = await runOneShot(ctx, { task: command.task, ...runtime.signal === undefined ? {} : { signal: runtime.signal }, diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 4daaea7e03..fcdf8a3004 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -199,6 +199,83 @@ describe('runOneShot and executeCli', () => { expect(stderr).toContain('boot exploded') }) + it('interrupts Loader boot and contains every late boot outcome', async () => { + const abort = new AbortController() + const lateContext = new Context() + liveContexts.push(lateContext) + const boot = Promise.withResolvers() + const disposed = Promise.withResolvers() + let disposeCalls = 0 + let stderr = '' + const running = executeCli(['task'], { + signal: abort.signal, + boot: () => boot.promise, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: (chunk) => { stderr += chunk }, + dispose: async (ctx) => { + disposeCalls += 1 + await ctx.fiber.dispose() + disposed.resolve(undefined) + }, + }) + abort.abort('received SIGTERM') + await expect(running).resolves.toBe(1) + expect(stderr).toContain('received SIGTERM') + expect(disposeCalls).toBe(0) + boot.resolve(lateContext) + await disposed.promise + expect(disposeCalls).toBe(1) + + const rejectedBoot = Promise.withResolvers() + const rejectedAbort = new AbortController() + const rejected = executeCli(['task'], { + signal: rejectedAbort.signal, + boot: () => rejectedBoot.promise, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: () => {}, + }) + rejectedAbort.abort('stop rejected boot') + await expect(rejected).resolves.toBe(1) + rejectedBoot.reject(new Error('late boot rejection')) + await Promise.resolve() + + let ordinaryBootStderr = '' + const ordinaryBootFailure = await executeCli(['task'], { + signal: new AbortController().signal, + boot: async () => { throw new Error('ordinary boot failure') }, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: (chunk) => { ordinaryBootStderr += chunk }, + }) + expect(ordinaryBootFailure).toBe(1) + expect(ordinaryBootStderr).toContain('ordinary boot failure') + + const failedCleanupBoot = Promise.withResolvers() + const failedCleanupAbort = new AbortController() + const cleanupFailure = Promise.withResolvers() + const failedCleanupContext = new Context() + liveContexts.push(failedCleanupContext) + const failedCleanup = executeCli(['task'], { + signal: failedCleanupAbort.signal, + boot: () => failedCleanupBoot.promise, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: (chunk) => { + if (chunk.includes('dispose after interrupted boot failed: late cleanup')) cleanupFailure.resolve(undefined) + }, + dispose: async (ctx) => { + await ctx.fiber.dispose() + throw new Error('late cleanup') + }, + }) + failedCleanupAbort.abort('stop failed cleanup boot') + await expect(failedCleanup).resolves.toBe(1) + failedCleanupBoot.resolve(failedCleanupContext) + await cleanupFailure.promise + }) + it('renders text, flushes a persisted fresh session, and disposes the context', async () => { const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')]) const output = await invoke(ctx, ['task']) From ba693f0355f378b289e536436f774aa914cbd721 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:30:39 +0800 Subject: [PATCH 11/13] fix(cli-demo): forward the complete spine config Align the one-shot app with the shared agent-spine contract that landed on master after this branch began. Expose maxParallelToolCalls, dshHome, toolBash, and toolTasks through the Loader schema and route them with pickSpineConfig(). This restores deployment control over tool-call concurrency, the shared Harness home, background bash, and task_output wait bounds instead of silently retaining owner defaults. Exercise all four fields through the composed runtime, document the package-level contract, and regenerate the config catalog from the owning interface. --- docs/config-catalog.md | 8 ++++ packages/examples/cli-demo/README.md | 4 ++ packages/examples/cli-demo/src/index.ts | 27 +++++++---- .../examples/cli-demo/tests/cli-demo.spec.ts | 48 +++++++++++++++++-- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fb099937e8..ed8597832a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -215,16 +215,24 @@ export interface Config { provider: string /** Model name for the configured agent; a matching adapter must be registered. */ model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number /** Deployment persona forwarded to the system-prompt plugin. */ persona?: string /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ toolOrder?: string[] /** Tool-registry presentation config forwarded through agent-spine-demo. */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** Skill registry, local-provider, and model-facing consumer config. */ skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-spine-demo. */ + toolTasks?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 5197e3bcd0..c938f6c583 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -10,10 +10,14 @@ The package mounts no console logger, readline UI, user-interaction service, or |---|---|---| | `provider` | required | the configured agent's provider route | | `model` | required | the configured agent's model | +| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap; `1` is serial | | `persona` | — | the deployment persona in `dsh-system-prompt` | | `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` | | `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | | `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | +| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | +| `toolTasks` | owner defaults | generic `task_output` wait bounds | | `persistenceRoot` | `./.sessions` | JSONL session root | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index e58bcdad06..1308209681 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -24,31 +24,47 @@ export interface Config { provider: string /** Model name for the configured agent; a matching adapter must be registered. */ model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number /** Deployment persona forwarded to the system-prompt plugin. */ persona?: string /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ toolOrder?: string[] /** Tool-registry presentation config forwarded through agent-spine-demo. */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** Skill registry, local-provider, and model-facing consumer config. */ skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-spine-demo. */ + toolTasks?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } +// Each front door keeps a complete Loader schema so its deployment contract is +// readable without a cross-package config facade. +/* jscpd:ignore-start */ export const Config: z = z.object({ provider: z.string().required(), model: z.string().required(), + maxParallelToolCalls: z.number().step(1).min(1), persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), persona: z.string(), + dshHome: z.string(), skills: agentCore.SkillConfigSchema, // Absent means lexicographic order; schemastery's native array default is []. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, + toolBash: agentCore.ToolBashConfigSchema, + toolTasks: agentCore.ToolTasksConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) +/* jscpd:ignore-end */ /** * Compose the UI-less spine, a fresh top-level agent rooted at the process cwd, @@ -58,14 +74,9 @@ export const Config: z = z.object({ * @param config - validated app configuration. */ export function apply(ctx: Context, config: Config): void { - const spineConfig: agentCore.Config = { + ctx.plugin(agentCore, { + ...agentCore.pickSpineConfig(config), agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }], - workspaceContext: config.workspaceContext, - } - 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 }) } diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 52433abe81..343d6184d7 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -4,9 +4,10 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import type { Message } from '@deepseek-ai/dsh-llm' +import { CallId, 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 type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { afterEach, describe, expect, it, vi } from 'vitest' import * as cliDemo from '../src/index.ts' const contexts: Context[] = [] @@ -19,8 +20,9 @@ async function skillConfig(catalogDescriptionMaxLength?: number): Promise { +async function mount(config: cliDemo.Config, withBash = false): Promise { const ctx = new Context() + if (withBash) ctx.provide('bash', { sandboxMode: undefined }) contexts.push(ctx) await ctx.plugin(cliDemo, config) await new Promise(resolve => setTimeout(resolve, 80)) @@ -104,6 +106,46 @@ describe('dsh-cli-demo app composition', () => { ]) }) + it('forwards the complete shared spine configuration', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-home-')) + const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-agents-')) + const ctx = await mount({ + provider: 'mock', + model: 'mock', + maxParallelToolCalls: 3, + dshHome, + skills: { local: { agentsHome } }, + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + workspaceContext: false, + }, true) + + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) + const execution: ToolExecution = { + token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'], + callId: CallId('cli-demo-dsh-home'), + name: 'bash', + arguments: { command: 'true' }, + } + expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: dshHome }) + const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') + expect(Object.keys((bash!.parameters as { properties: Record }).properties)) + .not.toContain('run_in_background') + + const id = ctx.tasks.start({ + kind: 'bash', + label: 'config forwarding probe', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + }) + const wait = vi.spyOn(ctx.tasks, 'wait') + await ctx.tools.execute({ + callId: CallId('cli-demo-task-config'), + name: 'task_output', + arguments: { task_id: id, wait: true }, + }) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + }) + it('exposes the Loader-safe namespace plugin shape and schema', () => { expect(cliDemo.name).toBe('cli-demo') expect(cliDemo.Config).toBeDefined() From 1698f0baa6226a945bc1c459a2a320e389f2cec2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:31:33 +0800 Subject: [PATCH 12/13] fix(cli-demo): preserve disposal diagnostics Report context-disposal failure as an independent outcome even when argument, boot, task, or output handling has already produced a primary diagnostic. Keep the primary error first, append the cleanup error, and retain the nonzero exit status so operators can see both the initiating failure and the possibility that teardown or persistence did not complete. Add a regression that combines an invalid app composition with a failing disposer and asserts both ordered stderr lines. --- packages/examples/cli-demo/src/cli.ts | 5 +++-- packages/examples/cli-demo/tests/cli.spec.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 63c08ae39f..3a8fecc7a8 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -361,7 +361,8 @@ export function formatTurnFailure(reason: TurnEndReason): string { /** * Execute one CLI invocation. Argument and boot failures never write stdout; - * every booted context is disposed before this promise resolves. + * context disposal is awaited before return, and its failure does not replace + * an earlier diagnostic. * @param args - arguments after the executable name. * @param runtime - optional injected process boundaries for tests and embedding. * @returns the ordinary process exit code; the thin bin overrides it for Unix signals. @@ -421,7 +422,7 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime = try { await disposeContext(ctx) } catch (error: unknown) { - diagnostic ??= `${CLI_NAME}: dispose failed: ${toError(error).message}\n` + diagnostic = `${diagnostic ?? ''}${CLI_NAME}: dispose failed: ${toError(error).message}\n` exitCode = 1 } } diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index fcdf8a3004..8c41236829 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -398,6 +398,18 @@ describe('runOneShot and executeCli', () => { expect(disposalOutput.stderr).toContain('dispose exploded') }) + it('reports disposal failure alongside an earlier run failure', async () => { + const ctx = new Context() + liveContexts.push(ctx) + const output = await invoke(ctx, ['task'], { failDispose: true }) + expect(output).toEqual({ + code: 1, + stdout: '', + stderr: 'dsh-cli-demo: config must create exactly one top-level agent, found 0\n' + + 'dsh-cli-demo: dispose failed: dispose exploded\n', + }) + }) + it('cancels startup work and queued work before the correlated turn begins', async () => { const startup = await harness(['hang']) let started!: () => void From 306dd2b1fef2d1df141b6acf268ee4a0da3af598 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:32:41 +0800 Subject: [PATCH 13/13] fix(cli-demo): make failure rendering total Contain arbitrary plugin and runtime failures even when a thrown Proxy traps instanceof checks or its string coercion throws. Fall back to a stable diagnostic instead of letting executeCli reject outside its exit-code contract. Route abort reasons through the same total renderer so cancellation cannot escape containment through an exotic reason value. Add a focused regression that exercises both hostile inspection paths and verifies stdout remains empty, stderr remains labelled, and the CLI resolves with exit code 1. --- packages/examples/cli-demo/src/cli.ts | 19 ++++++++++++++++-- packages/examples/cli-demo/tests/cli.spec.ts | 21 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 3a8fecc7a8..68c9598e6c 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -91,12 +91,27 @@ class CliInterruptedError extends Error { } } +/** Render an arbitrary value without trusting its type traps or string coercion. */ +function renderUnknown(value: unknown): string { + try { + return String(value) + } catch { + return '[unrenderable thrown value]' + } +} + +/** Normalize an arbitrary thrown value without letting inspection escape containment. */ function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)) + try { + if (error instanceof Error) return error + } catch { + // A hostile proxy may throw during instanceof; use the total renderer below. + } + return new Error(renderUnknown(error)) } function interruptionReason(signal: AbortSignal): string { - return signal.reason === undefined ? 'interrupted' : String(signal.reason) + return signal.reason === undefined ? 'interrupted' : renderUnknown(signal.reason) } /** diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 8c41236829..fe61a42304 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -199,6 +199,27 @@ describe('runOneShot and executeCli', () => { expect(stderr).toContain('boot exploded') }) + it('contains a thrown value whose inspection and coercion both fail', async () => { + const hostile = new Proxy({}, { + getPrototypeOf: () => { throw new Error('prototype trap escaped') }, + get: (target, key, receiver) => { + if (key === Symbol.toPrimitive) throw new Error('coercion escaped') + return Reflect.get(target, key, receiver) as unknown + }, + }) + let stdout = '' + let stderr = '' + const code = await executeCli(['task'], { + boot: async () => { throw hostile }, + loadEnv: () => {}, + writeStdout: (chunk) => { stdout += chunk }, + writeStderr: (chunk) => { stderr += chunk }, + }) + expect(code).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe('dsh-cli-demo: [unrenderable thrown value]\n') + }) + it('interrupts Loader boot and contains every late boot outcome', async () => { const abort = new AbortController() const lateContext = new Context()