Retire the readline front door and the repl-agent example

Delete packages/ui/stdio and examples/repl-agent; rename stdio-demo to
@deepseek-ai/dsh-tui-demo (TUI-only, refuses pipes before Loader boot).
tui-agent owns the coding composition inline; echo-agent and the CI demo
smoke move to the one-shot cli-demo bin, which gains -p/--prompt. The
UI-independent with-key e2es move verbatim to tui-agent. SDK wizard's
'stdio' interface becomes 'tui'. PTY testing stays confined to TUI
surfaces; all other subprocess tests ride pipes.

See .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md
This commit is contained in:
Turtle
2026-07-22 10:55:16 +08:00
parent 4267076407
commit 0c9a4d7c28
52 changed files with 2613 additions and 1128 deletions

View File

@@ -1,23 +1,77 @@
# tui-agent
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).
The interactive coding agent: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + compaction + JSONL persistence, on the full-screen `dsh-tui` front door, loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC.
## 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:tui
```
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`.
Both the demo script and the installable `dsh` CLI ([`apps/cli`](../../apps/cli/README.md)) boot this example's `cordis.yml` as the shipped default config; `dsh` additionally applies the personal overlay from `~/.dsh` and uses the invoking directory as the workspace.
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
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`.
Run `pnpm run demo:code-mode tui` for the Code Mode overlay.
The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it.
## Composition
The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model <model>` and `/model <provider>/<model>` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options.
[`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.
### Resuming a prior session
Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, pass its id to the installed `dsh` CLI — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history:
```sh
dsh --resume <prior-session-id>
```
The TUI prints this exact command on exit and lists it under `/resume`, so resuming is copy-paste. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set 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.
## 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 TUI (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 pi-tui channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-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:tui` 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 |
| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend |
| `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`)
The UI-independent with-key suites assemble the full stack programmatically through `tests/harness.ts` (no PTY, no Loader):
- `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 the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction.
- `tests/todo-write.e2e.ts` — loads the opt-in `todo_write` tool, then a real model drives it and the test verifies the resulting `todo/write` session event.
- `tests/code-mode.e2e.ts` — 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.
These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path.
## 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 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.
`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.

View File

@@ -9,9 +9,10 @@
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
model: deepseek-v4-pro
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
resumeCommand: 'dsh --resume {session}'
workspaceContext:
maxBytes: 65536
tools:

View File

@@ -1,52 +1,75 @@
# 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`.
# Full-screen TUI coding agent with swappable DeepSeek and local-bash backends.
# `dsh-tui-demo` supplies the agent spine, workspace instructions, generic
# task controls, JSONL persistence, the pi-tui front door, and `main`.
# HMR remains a leaf because it requires Loader internals; `demo:tui` 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. Shipped default: full thinking at max effort on
# every request (wire-only defaults; they never enter the request header).
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
# 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 TUI's `main` agent.
- id: tui-agent
name: '@deepseek-ai/dsh-tui-demo'
config:
provider: deepseek
model: deepseek-v4-flash
model: deepseek-v4-pro
# 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'
# Printed on exit and listed by `/resume`; `{session}` fills the live id.
# `dsh --resume <id>` sets RESUME_SESSION_ID above, so run it from this cwd.
resumeCommand: 'dsh --resume {session}'
workspaceContext:
maxBytes: 65536
welcome: 'TUI agent ready. Give it a coding task.'
ui:
showReasoning: true
maxToolOutputLines: 6
# Rename the terminal after the first message with a short model-made title.
autoTitle: true
# 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'
- 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'
@@ -72,6 +95,9 @@
provider: fork
toolName: subagent_fork
# 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:
@@ -85,9 +111,8 @@
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
- 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:
@@ -99,12 +124,24 @@
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the
# local bash executor above — 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'

View File

@@ -27,3 +27,4 @@
persona: 'Scripted model {{model}}.'
ui:
showReasoning: true
autoTitle: true

View File

@@ -1,44 +1,247 @@
import { spawn } from 'node:child_process'
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { runTuiPtySmoke } from './pty-harness.ts'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const codeModeConfigPath = fileURLToPath(new URL('../code-mode.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))
describe('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 runTuiPtySmoke({
label: 'tui-agent boot',
tempDirPrefix: 'tui-agent-smoke-',
binScript,
configPath,
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, boot_marker = 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
opened_selector = False
selected_model = 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 opened_selector and b"scripted TUI ready." in output:
os.write(fd, b"/model\r")
opened_selector = True
if scenario == "conversation" and opened_selector and not selected_model and b"Select model" in output:
os.write(fd, b"\x1b[B\r")
selected_model = True
if scenario == "conversation" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." 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 == "skill" and not selected_model and b"scripted TUI ready." in output:
os.write(fd, b"/model tui-scripted/tui-scripted-model-pro\r")
selected_model = True
if scenario == "skill" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output:
os.write(fd, b"/skill:scripted-skill\r")
sent_prompt = True
if scenario == "skill" and sent_prompt and not sent_exit and b"Scripted skill body received." in output:
os.write(fd, b"/exit\r")
sent_exit = True
if scenario == "boot" and not sent_exit and boot_marker.encode() 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)
elif scenario == "skill":
if not sent_prompt:
sys.stderr.write("TUI did not render the scripted welcome marker before typing /skill:\n")
sys.exit(132)
if b"Scripted skill body received." not in output:
sys.stderr.write("TUI did not deliver the loaded skill body to the model before timeout\n")
sys.exit(133)
if not sent_exit:
sys.stderr.write("TUI did not reach idle to accept /exit after the skill turn\n")
sys.exit(134)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
sys.stderr.write("TUI skill scenario did not exit cleanly\n")
sys.exit(135)
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' | 'skill'
/** Welcome text the boot scenario waits for before sending `/exit`. */
bootMarker?: string
/** Bin to boot; defaults to the tui-demo bin (the dsh CLI tests override). */
srcBin?: string
/** Argument vector for the bin; defaults to `[config]`. */
configArgs?: string[]
/** Files written into the isolated Harness home (`$DSH_HOME`) before launch. */
personalFiles?: Record<string, string>
/** Skill bundles written under the isolated agents home (`.agents/skills/`) before launch, keyed by path below that root. */
skillFiles?: Record<string, string>
/** Runs against the workspace `cwd` after a clean exit, before it is removed. */
inspect?: (cwd: string) => Promise<void>
}
async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-'))
try {
// Personal config is always isolated from the developer's real ~/.dsh;
// a test opts into an overlay by supplying files under the Harness home.
const dshHome = join(cwd, '.dsh')
for (const [name, content] of Object.entries(options.personalFiles ?? {})) {
await mkdir(dshHome, { recursive: true })
await writeFile(join(dshHome, name), content)
}
// The child chdirs to this cwd and the scripted config roots fs-local here,
// so a skill dropped under DSH_AGENTS_HOME's `skills/` root is discoverable
// and its body readable through the same tree the model-facing stack uses.
const skillsRoot = join(cwd, '.agents', 'skills')
for (const [name, content] of Object.entries(options.skillFiles ?? {})) {
const file = join(skillsRoot, name)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
}
const launch = resolveExampleLaunch({
srcBin: options.srcBin ?? binScript,
configArgs: options.configArgs ?? [options.config ?? configPath],
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
actions: [{ waitFor: 'TUI agent ready.', send: '/exit\r' }],
exposeInternals: true,
env: {
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
DSH_HOME: dshHome,
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',
// With no configured welcome the borderless banner sweeps in; its
// detail line's session id (`main-session-<uuid>`) renders only once
// the sweep reaches it, so it marks a settled banner.
options.bootMarker ?? 'main-session-',
], { 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) {
reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
return
}
// Inspect the workspace before `finally` removes it (e.g. the session log).
void (options.inspect?.(cwd) ?? Promise.resolve()).then(() => { resolve(stdout) }, reject)
})
})
} finally {
await rm(cwd, { recursive: true, force: true })
}
}
/** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */
async function readLoggedSystemPrompt(cwd: string): Promise<string> {
const sessionsDir = join(cwd, '.sessions')
const entries = await readdir(sessionsDir, { recursive: true })
// A single keyless run writes one session log; the source section is global, so any log carries it.
const logRelPath = entries.find(name => name.endsWith('.jsonl'))
if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
for (const line of lines) {
const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } }
if (event.type === 'request/header') return event.data.header?.system ?? ''
}
throw new Error(`session log ${logRelPath} has no request/header event`)
}
describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
it('boots pi-tui, sweeps the borderless banner in, accepts /exit, and restores the terminal', async () => {
const output = await runTuiLoaderSmoke()
// With no configured welcome the borderless banner sweeps in left-to-right;
// the boot scenario waits for the detail line's session id, which renders
// only once the sweep reaches it.
expect(output).toContain('DEEPSEEK')
expect(output).toContain('TUI agent ready.')
expect(output).toContain('HARNESS')
expect(output).toContain('main-session-')
// Borderless: no box-drawing frame around the banner.
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
const output = await runTuiPtySmoke({
label: 'tui-agent conversation',
tempDirPrefix: 'tui-agent-conversation-',
binScript,
configPath: scriptedConfigPath,
tsconfigPath,
actions: [
{ waitFor: 'scripted TUI ready.', send: '/model\r' },
{ waitFor: 'Select model', send: '\x1b[B\r' },
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
],
})
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' })
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`)
@@ -46,23 +249,125 @@ 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.')
// Auto-title: the first user message drives a tool-less title call that the
// scripted adapter answers, and the TUI sets it via OSC 0.
expect(output).toContain('\u001B]0;scripted session title\u0007')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
// The whole manual-invocation path in one keyless boot: `ctx.get('skills')`
// resolves in the shipped tree, the client-side `/skill:` command parses,
// the local provider loads `scripted-skill` from the agents home, and the
// rendered `<skill name="…">` block reaches the model — proven by the
// scripted adapter echoing the fixture's body marker only when it arrives.
const output = await runTuiLoaderSmoke({
config: scriptedConfigPath,
scenario: 'skill',
skillFiles: {
'scripted-skill/SKILL.md': [
'---',
'name: scripted-skill',
'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
'---',
'',
'SCRIPTED SKILL BODY MARKER',
'',
].join('\n'),
},
})
expect(output).toContain('Scripted skill body received.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => {
// The overlay's only keyless composition proof: the include+patch tree,
// worker code runtime, and one-tool registry all mount before the banner.
const output = await runTuiLoaderSmoke({
config: codeModeConfigPath,
bootMarker: 'TUI Code Mode ready.',
})
expect(output).toContain('TUI Code Mode ready.')
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 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,
})
const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' })
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
it('boots the shipped default config with no arguments and no personal overlay', async () => {
const output = await runTuiLoaderSmoke({ srcBin: dshBinScript, configArgs: [] })
expect(output).toContain('DEEPSEEK')
expect(output).toContain('main-session-')
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => {
// The whole personal-config chain in one boot: the personal .env supplies
// the variable, config.yaml patches the tui-agent entry with a `!!js`
// reference to it, and the banner renders the patched welcome verbatim.
const output = await runTuiLoaderSmoke({
srcBin: dshBinScript,
configArgs: [],
bootMarker: 'PERSONAL OVERLAY READY.',
personalFiles: {
'.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
'config.yaml': [
'- id: tui-agent',
" name: '@deepseek-ai/dsh-tui-demo'",
' config:',
' provider: deepseek',
' model: deepseek-v4-flash',
' workspaceContext: false',
' welcome: !!js process.env.DSH_PERSONAL_WELCOME',
'',
].join('\n'),
},
})
expect(output).toContain('PERSONAL OVERLAY READY.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
await expect(runTuiLoaderSmoke({
srcBin: dshBinScript,
configArgs: [],
personalFiles: { 'config.yaml': 'id: not-a-list\n' },
})).rejects.toThrow('must be a top-level YAML array of loader patch entries')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => {
// The flag path end to end: apps/cli parses `--resume missing-session` and
// sets RESUME_SESSION_ID (the PTY driver does NOT here), the shipped
// config's `!!js` reads it, and the resume fails loud — proving the printed
// `dsh --resume <id>` hint reaches the same intake as the env var.
const output = await runTuiLoaderSmoke({
srcBin: dshBinScript,
configArgs: ['--resume', 'missing-session'],
scenario: 'resume-failure',
})
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('tells the model where its own source lives, in the system prompt it sends', async () => {
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
// this test file sits an equal depth under the same root, so the same hop applies.
const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
let loggedSystem = ''
await runTuiLoaderSmoke({
srcBin: dshBinScript,
configArgs: [scriptedConfigPath],
scenario: 'conversation',
inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) },
})
expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})