Files
deepseek-harness/apps/cli/tests/pty-harness.ts
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

215 lines
7.7 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execa } from 'execa'
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const POSIX_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"})
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
# banner to the per-letter gradient (one SGR per letter), breaking literal
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
env.pop("COLORTERM", None)
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
/** Config argument; ignored when {@link configArgs} is set. */
readonly configPath?: string
/** Full argument vector for the bin (e.g. `[]` for a bin with a built-in default config). */
readonly configArgs?: readonly string[]
readonly tsconfigPath: string
readonly actions?: readonly TuiPtyAction[]
readonly env?: Readonly<NodeJS.ProcessEnv>
readonly expectedExitCode?: number
readonly timeoutMs?: number
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
readonly prepare?: (cwd: string) => Promise<void>
/** Inspect the workspace after a passing run, before the temp dir is removed. */
readonly inspect?: (cwd: string) => Promise<void>
}
function definedEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
}
async function runPosixPtySmoke(
launch: ExampleLaunch,
cwd: string,
options: TuiPtySmokeOptions,
timeoutMs: number,
): Promise<string> {
// The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline
// only backstops a wedged python3 process itself.
const result = await execa('python3', [
'-c',
POSIX_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),
], {
stdin: 'ignore',
timeout: timeoutMs + 5_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return result.stdout
}
async function runWindowsPtySmoke(
launch: ExampleLaunch,
cwd: string,
options: TuiPtySmokeOptions,
timeoutMs: number,
): Promise<string> {
const pty = await import('node-pty')
return await new Promise((resolve, reject) => {
const actions = options.actions ?? []
const expectedExitCode = options.expectedExitCode ?? 0
let output = ''
let actionIndex = 0
let timedOut = false
const terminal = pty.spawn(launch.command, launch.args, {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd,
env: definedEnv({
...process.env,
...launch.env,
// Match the POSIX driver: no COLORTERM, so the banner never takes the
// truecolor gradient path under a developer's shell.
COLORTERM: undefined,
COLUMNS: '100',
LINES: '30',
}),
})
const timer = setTimeout(() => {
timedOut = true
terminal.kill()
}, timeoutMs)
terminal.onData((chunk) => {
output += chunk
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
terminal.write(actions[actionIndex]!.send)
actionIndex += 1
}
})
terminal.onExit(({ exitCode, signal }) => {
clearTimeout(timer)
if (timedOut) {
reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`))
} else if (actionIndex !== actions.length) {
reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`))
} else if (exitCode !== expectedExitCode) {
reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`))
} else {
resolve(output)
}
})
})
}
/**
* Boot an example in a real pseudo-terminal (ConPTY on Windows), drive
* marker-gated input, and return captured 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 {
await options.prepare?.(cwd)
const launch = resolveExampleLaunch({
srcBin: options.binScript,
// `configPath` is the dsh `--config <path>` tree override; `configArgs`
// is the raw-args escape (e.g. `['--resume', <id>]`) for other flags.
configArgs: options.configArgs !== undefined
? [...options.configArgs]
/* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
: options.configPath !== undefined ? ['--config', options.configPath] : [],
tsconfigPath: options.tsconfigPath,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
...options.env,
},
})
const output = process.platform === 'win32'
? await runWindowsPtySmoke(launch, cwd, options, timeoutMs)
: await runPosixPtySmoke(launch, cwd, options, timeoutMs)
// Inspect the workspace before `finally` removes it (e.g. the session log).
await options.inspect?.(cwd)
return output
} finally {
await rm(cwd, { recursive: true, force: true })
}
}