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:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
127
examples/tui-agent/tests/pty-harness.ts
Normal file
127
examples/tui-agent/tests/pty-harness.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user