Merge branch 'codex/goal-commands' into codex/ralph-tool

# Conflicts:
#	examples/repl-agent/README.md
#	examples/repl-agent/composition.md
#	examples/repl-agent/cordis.yml
This commit is contained in:
Tianyi Cui
2026-07-20 22:25:16 +08:00
249 changed files with 2921 additions and 4563 deletions

View File

@@ -11,9 +11,7 @@ Each example has both:
- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md).
Mock-only examples require only the keyless tier; state that exception in the test.
Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke`; tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples/<agent>/` leaf. Map a package-owned config to `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`.
Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples/<agent>/` leaf. Map a package-owned config to `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`.
Do not inventory example tests here; the `tests/` trees and root scripts are authoritative.

View File

@@ -1,37 +1,18 @@
# 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-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
A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-demo`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates:
- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-demo` app
- Registering a mock `LlmAdapter` (streaming scripted responses)
- Registering a tool via `ctx.tools.register()`
- "Swap the backend, keep the app" — the only difference from `repl-agent` is the adapter
Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigger a tool call round-trip.
## repl-agent
A coding agent with DeepSeek V4, the `read`/`write`/`edit` filesystem tools, the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door.
Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [repl-agent/README.md](repl-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](repl-agent/README.md#code-mode) for its composition and a sample task.
Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin.
## 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 output contract, safety boundaries, and 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
The full-screen terminal sibling of `repl-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios.
The interactive coding agent: DeepSeek V4, filesystem and bash tools, subagents, workflows, `todo_write`, compaction, and the full-screen TUI. It is also the home of TUI PTY and snapshot scenarios.
Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition.
Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). Run its Code Mode overlay with `pnpm run demo:code-mode`. See [tui-agent/README.md](tui-agent/README.md) for controls and composition.
## jsonrpc-agent

View File

@@ -13,6 +13,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: 'none'
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -11,6 +11,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -15,6 +15,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: 'none'
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -13,6 +13,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -14,6 +14,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: 'none'
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -11,6 +11,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -15,6 +15,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: 'none'
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -14,6 +14,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -41,12 +41,14 @@
# The ACP server app: the agent-spine-demo spine + JSONL persistence + the ACP bridge.
# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it
# (so it can harvest / isolate the log), else ./.sessions for the demo.
# Snapshot modes use raw JSONL fixtures; ordinary runs keep the compressed default.
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
# Keep the persona to identity and behavior; tool plugins own tool guidance.

View File

@@ -10,7 +10,7 @@
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-de946378f5ae/dc816fc5677c-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2ef0a5f14624/b5e2b8c5e6a6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}

View File

@@ -131,8 +131,8 @@
{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"ccc9f362-f1e0-4ccc-bb8e-a54df8bba8da","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"ccc9f362-f1e0-4ccc-bb8e-a54df8bba8da","outcome":"allowed-once"}}
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","outcome":"allowed-once"}}
{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"}
{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}}

View File

@@ -155,8 +155,8 @@
{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"}
{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e0679c4e-2486-402d-928d-dd11c737f0b8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e0679c4e-2486-402d-928d-dd11c737f0b8","outcome":"rejected"}}
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}}
{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"}
{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}}

View File

@@ -89,8 +89,8 @@
{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
{"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}
{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"5d605db6-a94b-4466-b426-62938370f754","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}}
{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"5d605db6-a94b-4466-b426-62938370f754","outcome":"allowed-once"}}
{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}}
{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}}
{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
{"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}}

View File

@@ -55,8 +55,8 @@
{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}
{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}}
{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}}
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"1eb7e3e2-21c4-485f-b2d8-b0bb0577c884","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"1eb7e3e2-21c4-485f-b2d8-b0bb0577c884","outcome":"rejected"}}
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}}
{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"}
{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}}

View File

@@ -15,6 +15,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: 'none'
workspaceContext:
maxBytes: 65536
dshHome: !!js process.cwd() + '/.dsh'

View File

@@ -12,6 +12,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
dshHome: !!js process.cwd() + '/.dsh'

View File

@@ -1,6 +1,6 @@
# cordis-agent
The self-referential harness demo: the coding spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Run it

View File

@@ -20,11 +20,11 @@ flowchart LR
cfg --> plugin_cordis_web
plugin_cordis_web_fetch_local["web-fetch-local<br/>@deepseek-ai/dsh-web-fetch-local"]
cfg --> plugin_cordis_web_fetch_local
plugin_cordis_stdio_agent["stdio-agent<br/>@deepseek-ai/dsh-stdio-demo"]
cfg --> plugin_cordis_stdio_agent
plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent"]
plugin_cordis_tui_agent["tui-agent<br/>@deepseek-ai/dsh-tui-demo"]
cfg --> plugin_cordis_tui_agent
plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
plugin_cordis_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
plugin_cordis_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui<br/>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"]
@@ -41,7 +41,7 @@ flowchart LR
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` |
| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` |
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` |
Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml).

View File

@@ -1,4 +1,4 @@
# Self-referential stdio demo: the coding spine plus tools to inspect the live
# Self-referential TUI demo: the coding spine plus tools to inspect the live
# service/plugin/tool/mount/API/event state, mount a model-written plugin under
# `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored
# root `.env` before reading the required DeepSeek key and optional base URL.
@@ -45,8 +45,8 @@
name: '@deepseek-ai/dsh-web-fetch-local'
# The app bundle pre-creates the self-referential demo's `main` agent.
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash

View File

@@ -1,27 +1,23 @@
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 { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts'
/**
* Keyless Loader-path smoke for examples/cordis-agent: boot the real tree,
* including tool-cordis resolved by package name, then close stdin without a
* prompt and assert the banner. The dummy key never reaches a model call.
*/
const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => {
const { stdout } = await runLoaderSmoke({
describe('cordis-agent keyless smoke (real Loader tree in a PTY)', () => {
it('boots the full tool-cordis tree and exits cleanly through the TUI', async () => {
const output = await runTuiPtySmoke({
label: 'cordis-agent',
tempDirPrefix: 'cordis-smoke-',
tempDirPrefix: 'cordis-agent-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
actions: [{ waitFor: 'cordis-agent ready.', send: '/exit\r' }],
})
expect(stdout).toContain('cordis-agent ready.')
expect(output).toContain('cordis-agent ready.')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -1,34 +0,0 @@
# echo-agent
Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-mock skeleton — "swap the backend, keep the app".
## What it shows
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, JSONL persistence, the TTY-selected `dsh-tui`/`dsh-stdio` front doors, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`:
- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo <something>". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`.
- `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased.
Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `repl-agent` — the same app, a different backend.
## Plugin files
| File | Role | Key patterns demonstrated |
|---|---|---|
| `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol |
| `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` |
| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-demo` entry carrying the app config |
The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-demo` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring.
## Run
```sh
pnpm run demo:echo
# or:
node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml
```
Type a message and press Enter. "echo <text>" triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it).
The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `<repo-root>/.sessions/cwd-<hash>/` (one `.jsonl` log per session). Clean up with: `rm -rf .sessions`

View File

@@ -1,43 +0,0 @@
<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
Run `pnpm run gen-doc-graphs` to regenerate. -->
# Echo Agent App Composition
The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.
```mermaid
flowchart LR
cfg["examples/echo-agent<br/>cordis.yml"]
plugin_echo_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_echo_hmr
plugin_echo_mock_llm["mock-llm<br/>./src/mock-llm.ts"]
cfg --> plugin_echo_mock_llm
plugin_echo_echo_tool["echo-tool<br/>./src/echo-tool.ts"]
cfg --> plugin_echo_echo_tool
plugin_echo_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_echo_bash
plugin_echo_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_echo_fs_local
plugin_echo_stdio_agent["stdio-agent<br/>@deepseek-ai/dsh-stdio-demo"]
cfg --> plugin_echo_stdio_agent
plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)<br/>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 id | Package / module |
| --- | --- |
| `hmr` | `@cordisjs/plugin-hmr` |
| `mock-llm` | `./src/mock-llm.ts` |
| `echo-tool` | `./src/echo-tool.ts` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` |
Source config: [`examples/echo-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.

View File

@@ -1,43 +0,0 @@
# Stdio agent with the network-free `mock-echo` adapter and example-local `echo`
# tool. The app bundle supplies the spine; this leaf selects backends, HMR, and app config.
# No API key: the `mock-echo` adapter never touches the network.
# Hot-module reload for the dev/demo loop (a leaf entry, not baked into
# dsh-stdio-demo — it needs `node --expose-internals`, which `demo:echo` passes).
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root: ['.']
# Example-local model and tool plugins resolve relative to this file.
- id: mock-llm
name: './src/mock-llm.ts'
- id: echo-tool
name: './src/echo-tool.ts'
# Local bash executor: agent-spine-demo ships the `tool-bash` consumer schema, so the
# leaf provides the executor it runs on (the echo demo doesn't drive bash, but
# the tool is part of the shared spine).
- id: bash
name: '@deepseek-ai/dsh-bash-local'
# Local filesystem provider for agent-spine-demo's workspace-context loader. This
# does not expose model-facing read/write/edit tools in the echo demo.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
# The app pre-creates `main` on the mock model and supplies persistence plus
# TTY-selected `dsh-tui`/`dsh-stdio` front doors; readline mode also owns logging.
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
config:
provider: mock
model: mock-echo
persona: 'You are echo-agent, a demo agent.'
welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).'
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536

View File

@@ -1,7 +0,0 @@
{
"name": "echo-agent-example",
"private": true,
"version": "0.0.1",
"type": "module",
"description": "Runnable demo: stdin chat with a scripted mock model + echo tool"
}

View File

@@ -1,19 +0,0 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'echo-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'echo',
description: 'Echo the given text back, uppercased.',
parameters: {
text: { type: 'string', required: true },
},
async execute(args) {
// args is typed: { text: string }
return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }]
},
}))
}

View File

@@ -1,59 +0,0 @@
import type { Context } from 'cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
/**
* Demo adapter for the `mock-echo` model.
*
* Behavior: if the last user text starts with "echo ", it calls the `echo`
* tool with the rest of the line (exercising the tool round-trip), otherwise
* it streams a canned reply quoting the input.
*/
class MockEchoAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const lastUserText = [...options.messages].reverse()
.filter(message => message.role === 'user')
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.find(text => !text.startsWith('<')) ?? ''
const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result')
if (lastUserText.startsWith('echo ') && !hasToolResult) {
const payload = lastUserText.slice(5)
const args = JSON.stringify({ text: payload })
yield { type: 'block-start', index: 0, blockType: 'text' }
for (const char of 'Let me echo that for you.') {
yield { type: 'text-delta', index: 0, text: char }
await new Promise(resolve => setTimeout(resolve, 2))
}
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Let me echo that for you.' } }
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 1, id: CallId('call-echo'), name: 'echo', argumentsDelta: args }
yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-echo'), name: 'echo', arguments: args } }
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
const reply = hasToolResult
? 'The echo tool has spoken.'
: `You said: "${lastUserText}". Try "echo <something>" to see a tool call.`
yield { type: 'block-start', index: 0, blockType: 'text' }
for (const char of reply) {
yield { type: 'text-delta', index: 0, text: char }
await new Promise(resolve => setTimeout(resolve, 2))
}
yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: reply.length } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'mock-llm'
export const inject = ['llm']
export function apply(ctx: Context) {
ctx.llm.registerAdapter(['mock'], new MockEchoAdapter())
}

View File

@@ -1,43 +0,0 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/**
* Keyless-by-nature Loader-path coverage for examples/echo-agent. The real
* tree uses its deterministic mock model, so this suite is both the boot smoke
* and the complete behavior proof for the example.
*/
const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
async function runEcho(stdinLines: readonly string[]): Promise<string> {
const { stdout } = await runLoaderSmoke({
label: 'echo-agent',
tempDirPrefix: 'echo-smoke-',
binScript,
configPath,
tsconfigPath,
stdinLines,
})
return stdout
}
describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => {
expect(await runEcho([])).toContain('echo-agent ready.')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('runs the echo tool round-trip for an "echo …" line', async () => {
const stdout = await runEcho(['echo hello world'])
expect(stdout).toContain('[tool call] echo')
expect(stdout).toContain('[tool result] ECHO: HELLO WORLD')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('streams a direct canned reply for a non-echo line', async () => {
const stdout = await runEcho(['just chatting'])
expect(stdout).toContain('just chatting')
expect(stdout).not.toContain('[tool call]')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -8,7 +8,7 @@ Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools +
# 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 "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"
```

View File

@@ -10,6 +10,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -25,6 +25,7 @@
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
persona: |

View File

@@ -1,6 +1,6 @@
# Test-only composition: create one goal through a Loader-mounted lifecycle consumer.
- id: mock-llm
name: '../../../../src/mock-llm.ts'
# Test-only composition: create one goal through a Loader-mounted step consumer.
- id: cli-mock-llm
name: '../cli-mock-llm.ts'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
@@ -13,12 +13,12 @@
- id: seed-goal
name: './seed-goal.ts'
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: mock
model: mock-echo
provider: cli-mock
model: cli-mock
persona: 'Test the persisted goal domain.'
welcome: 'goal-domain e2e ready.'
persistenceRoot: './.sessions'
persistenceCompression: none
workspaceContext: false

View File

@@ -1,4 +1,4 @@
/** Test-only Loader plugin that creates a goal at the real session-start edge. */
/** Test-only Loader plugin that creates a goal at the first real step edge. */
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-goal'
@@ -7,7 +7,8 @@ export const name = 'seed-goal'
export const inject = ['goals']
export function apply(ctx: Context): void {
ctx.on('agent/session-start', (agent) => {
ctx.on('agent/pre-step', (agent) => {
if (ctx.goals.get(agent) !== undefined) return
ctx.goals.create(agent, {
objective: 'Prove the composed goal survives in the session log',
maxGoalRounds: 7,

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
/** Test driver that sends two turns through one Headless Loader composition. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('time-context driver requires a config path')
const ctx = await boot('time-context-e2e', resolveConfigPath(configPath, undefined))
try {
await runOneShot(ctx, { task: 'first' })
await runOneShot(ctx, { task: 'second' })
} finally {
await ctx.fiber.dispose()
}

View File

@@ -0,0 +1,22 @@
import type { Context } from 'cordis'
import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
/** Deterministic one-step adapter for the time-context Loader fixture. */
class TimeContextMockAdapter extends LlmAdapter {
async * stream(): AsyncIterable<StreamChunk> {
const text = 'time context sampled'
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text }
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'time-context-mock-llm'
export const inject = ['llm']
/** Register the test-only `time-context-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['time-context-mock'], new TimeContextMockAdapter())
}

View File

@@ -1,6 +1,6 @@
# Test-only composition: keep time-context opt-in while exercising its real Loader/app path.
- id: mock-llm
name: '../../../../src/mock-llm.ts'
- id: time-context-mock-llm
name: './time-context-mock-llm.ts'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
@@ -8,12 +8,12 @@
- id: time-context
name: '@deepseek-ai/dsh-time-context'
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: mock
model: mock-echo
provider: time-context-mock
model: time-context-mock
persona: 'Test the time-context plugin.'
welcome: 'time-context e2e ready.'
persistenceRoot: './.sessions'
persistenceCompression: 'none'
workspaceContext: false

View File

@@ -15,7 +15,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
/**
* Shared harness for the repl-agent e2e suites: the full plugin stack
* Shared harness for the headless-agent e2e suites: the full plugin stack
* with the real DeepSeek adapter and the real bash + todo_write tools. Lives
* outside the *.e2e.ts pattern so importing it never re-registers another
* file's tests.

View File

@@ -1,4 +1,7 @@
import { readdir } from 'node:fs/promises'
import { readFile, readdir } from 'node:fs/promises'
import { zstdDecompress } from 'node:zlib'
import { promisify } from 'node:util'
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'
@@ -7,10 +10,11 @@ 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))
const decompress = promisify(zstdDecompress)
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
let persistedHeader: Record<string, unknown> | undefined
const { stdout, stderr } = await runLoaderSmoke({
label: 'headless-agent',
tempDirPrefix: 'headless-agent-smoke-',
@@ -20,7 +24,11 @@ describe('headless-agent keyless smoke', () => {
tsconfigPath,
inspect: async (cwd) => {
const files = await readdir(cwd, { recursive: true })
persisted = files.some(file => file.endsWith('.jsonl'))
const relativePath = files.find(file => file.endsWith('.jsonl.zstd'))
if (relativePath === undefined) return
const compressed = await readFile(join(cwd, relativePath))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
persistedHeader = JSON.parse((await decompress(compressed)).toString()) as Record<string, unknown>
},
})
const lines = stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
@@ -38,6 +46,6 @@ describe('headless-agent keyless smoke', () => {
usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 },
})
expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP')
expect(persisted).toBe(true)
expect(persistedHeader).toMatchObject({ type: 'session' })
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -1,14 +1,17 @@
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { describe, expect, it } from 'vitest'
const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
const decompress = promisify(zstdDecompress)
function waitForLine(
lines: string[],
@@ -152,6 +155,13 @@ describe('jsonrpc-agent keyless smoke', () => {
} else {
expect(child.exitCode, stderr).toBe(0)
}
const sessionsRoot = join(root, '.sessions')
const files = await readdir(sessionsRoot, { recursive: true })
const log = files.find(file => file.endsWith('.jsonl.zstd'))
expect(log).toBeDefined()
const compressed = await readFile(join(sessionsRoot, log!))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
} finally {
if (child.exitCode === null) child.kill('SIGKILL')
await new Promise<void>(resolve => modelServer.close(() => { resolve() }))

View File

@@ -9,6 +9,7 @@
"@cordisjs/plugin-include": "workspace:*",
"@deepseek-ai/dsh-acp-demo": "workspace:*",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-bash-local": "workspace:*",
"@deepseek-ai/dsh-bash-sandbox": "workspace:*",
"@deepseek-ai/dsh-cli-demo": "workspace:*",
@@ -33,7 +34,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-stdio-demo": "workspace:*",
"@deepseek-ai/dsh-tui-demo": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",

View File

@@ -1,68 +0,0 @@
# repl-agent
The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal 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: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`.
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
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:
```sh
RESUME_SESSION_ID=<prior-session-id> pnpm run demo:repl
```
The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero, while readline reports any dropped queued input and allows piped EOF to finish. Unset it or choose an existing session id.
## Code Mode
[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract.
```sh
pnpm run demo:code-mode # this overlay under the REPL (default UI)
pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay
```
Try a task that spans several tool calls, e.g.:
> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt.
and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output.
## What each leaf entry demonstrates
This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the selected terminal channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools:
| Entry | Demonstrates |
|---|---|
| `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` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice |
| `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 |
| `token-meter`, `tool-result-prune`, `compact-basic` | replay-aware pressure, model-free oversized tool-result pruning, and LLM summary compaction. Pruning runs only after a compaction trigger qualifies and can avoid the summarization call |
| `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`, `tool-ralph` | the worker-thread engine, general model-written `workflow` tool, and separate fixed fresh-agent `ralph` consumer, with children routed through spawn |
| `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 |
## End-to-end tests (`pnpm run test:e2e`, key-gated)
- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer.
- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted.
- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log.
- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so automatic pruning or summary compaction fires mid-session. It verifies the world: a replayable surface replacement lands, summary brackets are complete when summarization is needed, the surface shrinks, and the agent still produces a correct final answer.
- `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 Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` and `tests/code-mode-keyless-smoke.e2e.ts`.

View File

@@ -1,33 +0,0 @@
# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool,
# `run_code`, plus a generated SDK for bash/read/write/edit/subagent/todo_write.
# `demo:code-mode` selects this overlay; the ACP example has the same UI-specific
# shape. A config patch replaces the whole app config, so unchanged base fields
# are restated; only `tools`, `welcome`, and the persona's second paragraph differ.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
config:
provider: deepseek
model: deepseek-v4-flash
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
tools:
mode: code
welcome: 'code-mode agent ready. Give it a multi-tool task.'
ui:
mode: readline
persona: |
You are a coding agent powered by the {{model}} model.
You work by writing TypeScript programs for run_code: batch related
tool work into one program, loop and branch where it helps, and print
or return ONLY the findings that matter.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'

View File

@@ -1,94 +0,0 @@
<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
Run `pnpm run gen-doc-graphs` to regenerate. -->
# REPL Agent App Composition
The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package.
```mermaid
flowchart LR
cfg["examples/repl-agent<br/>cordis.yml"]
plugin_repl_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_repl_hmr
plugin_repl_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_repl_llm_deepseek
plugin_repl_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_repl_bash
plugin_repl_stdio_agent["stdio-agent<br/>@deepseek-ai/dsh-stdio-demo"]
cfg --> plugin_repl_stdio_agent
plugin_repl_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
plugin_repl_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
plugin_repl_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio<br/>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_repl_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
cfg --> plugin_repl_token_meter
plugin_repl_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
cfg --> plugin_repl_tool_result_prune
plugin_repl_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_repl_compact_basic
plugin_repl_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
cfg --> plugin_repl_subagent
plugin_repl_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
cfg --> plugin_repl_subagent_spawn
plugin_repl_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_repl_subagent_fork
plugin_repl_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_repl_tool_subagent
plugin_repl_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_repl_tool_subagent_fork
plugin_repl_workflow_workerthread["workflow-workerthread<br/>@deepseek-ai/dsh-workflow-workerthread"]
cfg --> plugin_repl_workflow_workerthread
plugin_repl_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
cfg --> plugin_repl_tool_workflow
plugin_repl_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_repl_tool_ralph
plugin_repl_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_repl_tool_todo
plugin_repl_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_repl_fs_local
plugin_repl_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
cfg --> plugin_repl_fs_policy
plugin_repl_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"]
cfg --> plugin_repl_tool_fs
plugin_repl_tool_fs_search["tool-fs-search<br/>@deepseek-ai/dsh-tool-fs-search"]
cfg --> plugin_repl_tool_fs_search
plugin_repl_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-timeout-policy"]
cfg --> plugin_repl_timeout_policy
plugin_repl_spill_local["spill-local<br/>@deepseek-ai/dsh-spill-local"]
cfg --> plugin_repl_spill_local
plugin_repl_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_repl_spill_policy
```
| Plugin id | Package / module |
| --- | --- |
| `hmr` | `@cordisjs/plugin-hmr` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
| `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-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `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` |
| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
Source config: [`examples/repl-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.

View File

@@ -1,148 +0,0 @@
# Readline coding REPL with swappable DeepSeek and local-bash backends.
# `dsh-stdio-demo` supplies the agent spine, workspace instructions, generic
# task controls, JSONL persistence, the line-oriented front door, and `main`.
# HMR remains a leaf because it requires Loader internals; `demo:repl` passes
# `--expose-internals`. The app bin loads the gitignored root `.env`; this file
# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`.
# Hot-module reload for the dev/demo loop (needs `node --expose-internals`).
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root: ['.']
# The native DeepSeek adapter.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# 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 the REPL's `main` agent.
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
config:
provider: deepseek
model: deepseek-v4-flash
# Set RESUME_SESSION_ID to continue a prior persisted session (the ids live
# under ./.sessions); unset starts a fresh session each run.
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
welcome: 'agent REPL ready. Give it a coding task.'
ui:
mode: readline
# Keep the persona to identity and behavior; tool plugins own tool guidance.
# The loop resolves {{model}} from this agent's configuration.
persona: |
You are a coding agent powered by the {{model}} model.
Verify your work by running the code or tests. Keep answers brief and
factual.
# Replay-aware request pressure with one service-wide context window.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
# Prune oversized tool output without a model call before summary compaction.
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
# Summarize an older range after measured pressure or a canonical provider overflow.
# Service-wide policy provides pressure, retention, and one overflow-retry default.
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
# Expose fresh-child `spawn` and completed-prefix `fork` through independent
# in-process backends. Each tool instance needs a distinct `toolName`; the registry
# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`.
- 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
maxDepth: 1
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
maxDepth: 1
# The worker-thread workflow engine fans a model-written JavaScript script's
# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model.
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
# `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan.
- 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. This single-session app resolves relative paths 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'
# Bash-backed discovery tools (glob/grep): if the local bash executor above
# can find rg, register fixed ripgrep commands — not ctx.fs. Capped results
# save the complete formatted list through the spill backend below
# (ctx.spillStore, optional).
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs
# (the search tools above declare 30s) as a deadline on exec.signal. Without
# it a declared budget is advisory and only the bash executor's own timeout
# backstop applies.
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
# Tool-output spill stack: a local backend that saves oversized tool text under
# a private session-scoped dir, and the tools/post-execute policy that replaces
# an over-budget plain-text result with a preview + the spill locator/retrieval
# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until
# a tool returns more than maxInlineBytes of plain text.
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000

View File

@@ -1,7 +0,0 @@
{
"name": "repl-agent-example",
"private": true,
"version": "0.0.1",
"type": "module",
"description": "Runnable demo: an agent REPL UI with DeepSeek V4 and coding tools"
}

View File

@@ -1,27 +0,0 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/**
* Keyless Loader-path smoke for the Code Mode overlay: boot the real include
* tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without
* a prompt and assert the banner. No model or `run_code` turn runs.
*/
const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => {
it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => {
const { stdout } = await runLoaderSmoke({
label: 'code-mode overlay',
tempDirPrefix: 'code-mode-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
})
expect(stdout).toContain('code-mode agent ready.')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -1,28 +0,0 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/**
* Keyless Loader-path smoke for examples/repl-agent: boot the real example
* through the stdio-agent bin and its `cordis.yml`, then close stdin without a
* prompt and assert the banner. The dummy key satisfies adapter construction;
* immediate EOF guarantees there is no model call.
*/
const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
describe('repl-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => {
const { stdout } = await runLoaderSmoke({
label: 'repl-agent',
tempDirPrefix: 'repl-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
})
expect(stdout).toContain('agent REPL ready.')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -1,6 +1,6 @@
# tui-agent
The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door.
The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo).
## Run it
@@ -8,16 +8,16 @@ The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README.
pnpm run demo:tui
```
The command needs `DEEPSEEK_API_KEY` in the environment or the gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`.
The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay.
Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay.
Run `pnpm run demo:code-mode tui` for the Code Mode overlay.
## Composition
[`cordis.yml`](cordis.yml) includes the readline repl-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the repl-agent Code Mode overlay.
[`cordis.yml`](cordis.yml) owns the interactive coding composition directly. [`code-mode.cordis.yml`](code-mode.cordis.yml) includes that leaf and replaces the tool presentation mode while adding the code runtime. Non-interactive automation uses the sibling [headless-agent](../headless-agent/README.md) composition.
## Snapshot tests
`tests/snapshots/<scenario>/session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage.
`tests/snapshots/<scenario>/session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tools, then compares readable terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix.

View File

@@ -1,12 +1,12 @@
# Code Mode keeps the TUI front door while reusing the repl-agent overlay's
# worker runtime and one-tool registry composition.
# Code Mode keeps the TUI composition while adding the worker runtime and
# reducing the model-facing registry to the `run_code` transport.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ../repl-agent/code-mode.cordis.yml
path: ./cordis.yml
patches:
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
@@ -18,13 +18,14 @@
mode: code
welcome: 'TUI Code Mode ready. Give it a multi-tool task.'
ui:
mode: tui
tui:
showReasoning: true
maxToolOutputLines: 12
showReasoning: true
maxToolOutputLines: 12
persona: |
You are a coding agent powered by the {{model}} model.
You work by writing TypeScript programs for run_code: batch related
tool work into one program, loop and branch where it helps, and print
or return ONLY the findings that matter.
- insert:
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'

View File

@@ -3,25 +3,91 @@
# TUI Agent App Composition
The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.
The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.
```mermaid
flowchart LR
cfg["examples/tui-agent<br/>cordis.yml"]
plugin_tui_base["base<br/>@deepseek-ai/dsh-stdio-demo"]
cfg --> plugin_tui_base
plugin_tui_base --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
plugin_tui_base --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
plugin_tui_base --> frontdoor_stdio["@deepseek-ai/dsh-tui<br/>pre-created main agent"]
plugin_tui_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_tui_hmr
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_tui_llm_deepseek
plugin_tui_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_tui_bash
plugin_tui_tui_agent["tui-agent<br/>@deepseek-ai/dsh-tui-demo"]
cfg --> plugin_tui_tui_agent
plugin_tui_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"]
plugin_tui_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"]
plugin_tui_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui<br/>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_tui_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
cfg --> plugin_tui_token_meter
plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
cfg --> plugin_tui_tool_result_prune
plugin_tui_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_tui_compact_basic
plugin_tui_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
cfg --> plugin_tui_subagent
plugin_tui_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
cfg --> plugin_tui_subagent_spawn
plugin_tui_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_tui_subagent_fork
plugin_tui_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent
plugin_tui_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent_fork
plugin_tui_workflow_workerthread["workflow-workerthread<br/>@deepseek-ai/dsh-workflow-workerthread"]
cfg --> plugin_tui_workflow_workerthread
plugin_tui_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
cfg --> plugin_tui_tool_workflow
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_tui_tool_ralph
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
cfg --> plugin_tui_tool_todo
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_tui_fs_local
plugin_tui_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
cfg --> plugin_tui_fs_policy
plugin_tui_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"]
cfg --> plugin_tui_tool_fs
plugin_tui_tool_fs_search["tool-fs-search<br/>@deepseek-ai/dsh-tool-fs-search"]
cfg --> plugin_tui_tool_fs_search
plugin_tui_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-timeout-policy"]
cfg --> plugin_tui_timeout_policy
plugin_tui_spill_local["spill-local<br/>@deepseek-ai/dsh-spill-local"]
cfg --> plugin_tui_spill_local
plugin_tui_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_tui_spill_policy
```
| Plugin id | Package / module |
| --- | --- |
| `base` | `@deepseek-ai/dsh-stdio-demo` |
| `hmr` | `@cordisjs/plugin-hmr` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
| `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-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `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` |
| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
Source config: [`examples/tui-agent/cordis.yml`](cordis.yml).

View File

@@ -1,28 +1,114 @@
# Full-screen TUI front door over the same repl-agent composition used by the
# readline REPL. The include keeps backends and optional tools aligned; the
# patch owns only the terminal-specific app config.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ../repl-agent/cordis.yml
patches:
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
config:
provider: deepseek
model: deepseek-v4-flash
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
welcome: 'TUI agent ready. Give it a coding task.'
ui:
mode: tui
tui:
showReasoning: true
maxToolOutputLines: 12
persona: |
You are a coding agent powered by the {{model}} model.
# Full-screen coding agent with swappable DeepSeek and local capability backends.
# `dsh-tui-demo` supplies the spine, workspace instructions, generic task controls,
# JSONL persistence, the TUI front door, and `main`. HMR remains a leaf because
# it requires Loader internals; `demo:tui` passes `--expose-internals`.
Verify your work by running the code or tests. Keep answers brief and
factual.
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root: ['.']
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 60000
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
welcome: 'TUI agent ready. Give it a coding task.'
ui:
showReasoning: true
maxToolOutputLines: 12
persona: |
You are a coding agent powered by the {{model}} model.
Verify your work by running the code or tests. Keep answers brief and
factual.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- 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
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
# A separate fixed consumer demonstrates fresh-agent Ralph iteration without
# changing the workflow tool or same-session goal behavior.
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
- 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'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000

View File

@@ -12,8 +12,8 @@
config:
cwd: !!js process.cwd()
- id: stdio-agent
name: '@deepseek-ai/dsh-stdio-demo'
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: tui-scripted
model: tui-scripted-model
@@ -22,6 +22,4 @@
maxBytes: 65536
welcome: 'scripted TUI ready.'
ui:
mode: tui
tui:
showReasoning: true
showReasoning: true

View File

@@ -0,0 +1,127 @@
import { spawn } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
env.update({"COLUMNS": "100", "LINES": "30"})
actions = json.loads(actions_json)
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
output = bytearray()
action_index = 0
deadline = time.monotonic() + float(timeout_seconds)
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
if chunk:
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if action_index != len(actions):
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n")
sys.exit(124)
actual_exit = os.waitstatus_to_exitcode(status)
if actual_exit != int(expected_exit):
sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n")
sys.exit(125)
`
/** One terminal action sent after its marker has rendered. */
interface TuiPtyAction {
readonly waitFor: string
readonly send: string
}
/** Inputs for a keyless real-Loader TUI process smoke. */
export interface TuiPtySmokeOptions {
readonly label: string
readonly tempDirPrefix: string
readonly binScript: string
readonly configPath: string
readonly tsconfigPath: string
readonly actions?: readonly TuiPtyAction[]
readonly env?: Readonly<NodeJS.ProcessEnv>
readonly expectedExitCode?: number
readonly timeoutMs?: number
}
/**
* Boot an example in a real pseudo-terminal, drive marker-gated input, and
* return the captured terminal bytes after the expected process exit.
* @param options - launch paths, environment, actions, and expected exit code.
* @returns complete pseudo-terminal output.
*/
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const timeoutMs = options.timeoutMs ?? 25_000
try {
const launch = resolveExampleLaunch({
srcBin: options.binScript,
configArgs: [options.configPath],
tsconfigPath: options.tsconfigPath,
exposeInternals: true,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
...options.env,
},
})
return await new Promise((resolve, reject) => {
const child = spawn('python3', [
'-c',
PTY_DRIVER,
launch.command,
JSON.stringify(launch.args),
JSON.stringify(launch.env),
cwd,
JSON.stringify(options.actions ?? []),
String(options.expectedExitCode ?? 0),
String(timeoutMs / 1_000),
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, timeoutMs + 5_000)
child.once('error', (error) => { clearTimeout(timer); reject(error) })
child.once('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve(stdout)
else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
})
})
} finally {
await rm(cwd, { recursive: true, force: true })
}
}

View File

@@ -1,157 +1,43 @@
import { spawn } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { runTuiPtySmoke } from './pty-harness.ts'
const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
env.update({
"COLUMNS": "100",
"LINES": "30",
})
if resume_session_id:
env["RESUME_SESSION_ID"] = resume_session_id
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
output = bytearray()
answered_question = False
sent_prompt = False
sent_exit = False
deadline = time.monotonic() + 25
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
if chunk:
output.extend(chunk)
if scenario == "conversation" and not sent_prompt and b"scripted TUI ready." in output:
os.write(fd, b"exercise the TUI\r")
sent_prompt = True
if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output:
os.write(fd, b"\r")
answered_question = True
if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output:
os.write(fd, b"/exit\r")
sent_exit = True
if scenario == "boot" and not sent_exit and b"TUI agent ready." in output:
os.write(fd, b"/exit\r")
sent_exit = True
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if scenario == "resume-failure":
if b'ui-tui: session "missing-session" failed to start:' not in output:
sys.stderr.write("TUI did not render the startup failure before timeout\n")
sys.exit(126)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1:
sys.stderr.write("TUI startup failure did not exit with status 1\n")
sys.exit(127)
elif scenario == "conversation":
if not sent_prompt:
sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n")
sys.exit(128)
if not answered_question:
sys.stderr.write("TUI did not render the user-question dialog before timeout\n")
sys.exit(129)
if not sent_exit:
sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n")
sys.exit(130)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
sys.stderr.write("TUI scripted conversation did not exit cleanly\n")
sys.exit(131)
else:
if not sent_exit:
sys.stderr.write("TUI did not render its welcome marker before timeout\n")
sys.exit(124)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
sys.stderr.write("TUI child did not exit cleanly\n")
sys.exit(125)
`
interface TuiLoaderSmokeOptions {
config?: string
resumeSessionId?: string
scenario?: 'boot' | 'conversation' | 'resume-failure'
}
async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-'))
try {
const launch = resolveExampleLaunch({
srcBin: binScript,
configArgs: [options.config ?? configPath],
tsconfigPath,
exposeInternals: true,
env: {
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
})
return await new Promise((resolve, reject) => {
const child = spawn('python3', [
'-c',
PTY_DRIVER,
launch.command,
JSON.stringify(launch.args),
JSON.stringify(launch.env),
cwd,
options.resumeSessionId ?? '',
options.scenario ?? 'boot',
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
child.once('error', reject)
child.once('exit', (code) => {
if (code === 0) resolve(stdout)
else reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
})
})
} finally {
await rm(cwd, { recursive: true, force: true })
}
}
describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
// The Python PTY driver imports the POSIX-only pty and termios modules.
describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => {
const output = await runTuiLoaderSmoke()
const output = await runTuiPtySmoke({
label: 'tui-agent boot',
tempDirPrefix: 'tui-agent-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
actions: [{ waitFor: 'TUI agent ready.', send: '/exit\r' }],
})
expect(output).toContain('DEEPSEEK')
expect(output).toContain('TUI agent ready.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => {
const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' })
const output = await runTuiPtySmoke({
label: 'tui-agent conversation',
tempDirPrefix: 'tui-agent-conversation-',
binScript,
configPath: scriptedConfigPath,
tsconfigPath,
actions: [
{ waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
],
})
expect(output).toContain('I need one decision before I continue.')
expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
@@ -159,14 +45,23 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007')
expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
expect(output).not.toContain('\u009B31mMODEL_C1')
expect(output).toContain('How should the scripted run proceed?')
expect(output).toContain('Safe')
expect(output).toContain('Decision received. Scripted TUI run complete.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => {
const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' })
const output = await runTuiPtySmoke({
label: 'tui-agent resume failure',
tempDirPrefix: 'tui-agent-resume-',
binScript,
configPath,
tsconfigPath,
env: {
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
RESUME_SESSION_ID: 'missing-session',
},
expectedExitCode: 1,
})
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})