Files
LLeMbas-CLI/scripts/smoke.ts
T
HomerandClaude Fable 5.1 2183040db8 Harness spec 2.0.0: background work is a job — commands and subagents alike, seen and steered
One registry per process, every job owned by a session: bash's background commands (and a command
still running at its timeout, which now goes to the background instead of being killed) and
subagents started with task background: true. job_list, job_output, job_stop (the former
bash_list, bash_output, bash_kill) and the new job_send and report; a finished job wakes the owning
session in every front end with its marked note. The TUI counts jobs, lists them (/jobs), opens a
command's output, enters a subagent and messages it, moves a command to the background (ctrl+b)
and asks before quitting with jobs running. The link gains the jobs capability. Subagents get a
session row of their own, the spec's preamble and agent texts, and their errors reach the report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 16:17:00 +00:00

281 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
// Smoke test of the COMPILED binary — what `bun test` cannot see: that the bundle, OpenTUI's
// native library and bun:sqlite FTS5 all survive `bun build --compile`.
// bun run scripts/smoke.ts [path/to/binary]
import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { delta, fakeProvider, toolCall, usage } from "../tests/fake-provider.ts"
import { drive, fakeHome } from "./drive.ts"
const bin = resolve(process.argv[2] ?? "dist/lembas")
const home = mkdtempSync(join(tmpdir(), "lembas-smoke-"))
const env = { ...process.env, LEMBAS_HOME: home }
let failed = 0
const check = (name: string, ok: boolean, detail = "") => {
console.log(`${ok ? "✓" : "✗"} ${name}${ok ? "" : " " + detail}`)
if (!ok) failed++
}
const run = (args: string[], cwd = home) => {
const r = Bun.spawnSync([bin, ...args], { cwd, env, stdout: "pipe", stderr: "pipe" })
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }
}
// 1. --version
const v = run(["--version"])
check("--version", v.code === 0 && /^\d+\.\d+\.\d+/.test(v.out), v.err)
// 2. a headless run against a fake endpoint, with the session store on (FTS5 inside the binary)
const fake = fakeProvider([{ chunks: [delta({ content: "smoke ok" }), usage(5, 2)] }])
mkdirSync(join(home, ".config/lembas"), { recursive: true })
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n m: {}\n`,
{ mode: 0o600 },
)
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/m\nupdate: { auto: off }\n")
const r = await new Promise<{ code: number; out: string; err: string }>((res) => {
const p = Bun.spawn([bin, "run", "say hi"], { cwd: home, env, stdout: "pipe", stderr: "pipe" })
Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]).then(([out, err, code]) => res({ code, out, err }))
})
fake.stop()
check("run → streamed answer", r.code === 0 && r.out === "smoke ok\n", JSON.stringify(r))
const s = run(["sessions"])
check("sessions → recorded (sqlite + FTS5 in the binary)", s.code === 0 && s.out.includes("fake/m"), s.err || s.out)
// 2b. serve --stdio: the ACP agent inside the binary answers initialize, opens a session and
// streams a reply — the code the LLeMbas link runs too.
{
const f = fakeProvider([{ chunks: [delta({ content: "acp ok" })] }])
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${f.url}\n models:\n m: {}\n`,
{ mode: 0o600 },
)
const p = Bun.spawn([bin, "serve", "--stdio"], { cwd: home, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" })
const lines: any[] = []
const reader = (async () => {
let buf = ""
for await (const chunk of p.stdout as unknown as AsyncIterable<Uint8Array>) {
buf += new TextDecoder().decode(chunk)
let nl: number
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (line) lines.push(JSON.parse(line))
}
}
})()
const send = (m: unknown) => p.stdin.write(JSON.stringify(m) + "\n")
const answer = async (id: number) => {
for (let i = 0; i < 300; i++) {
const hit = lines.find((l) => l.id === id)
if (hit) return hit
await Bun.sleep(20)
}
return undefined
}
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientCapabilities: {} } })
const init = await answer(1)
send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: home, mcpServers: [] } })
const session = await answer(2)
send({ jsonrpc: "2.0", id: 3, method: "session/prompt", params: { sessionId: session?.result?.sessionId, prompt: [{ type: "text", text: "hi" }] } })
const done = await answer(3)
const text = lines.filter((l) => l.method === "session/update" && l.params.update.sessionUpdate === "agent_message_chunk").map((l) => l.params.update.content.text).join("")
p.stdin.end()
await Promise.race([p.exited, Bun.sleep(3000)])
p.kill()
await reader.catch(() => {})
f.stop()
check("serve --stdio → ACP session streams a reply", init?.result?.protocolVersion === 1 && done?.result?.stopReason === "end_turn" && text === "acp ok", JSON.stringify({ init, session, done, text }))
}
// 3. the real TUI in a real PTY, read back through a headless xterm, at two sizes
for (const [cols, rows] of [
[120, 40],
[80, 24],
] as const) {
const { fake: f, home: h, cwd } = fakeHome([
{ chunks: [delta({ content: "Let me check." }), toolCall(0, "c1", "bash", '{"command":"echo smoke > out.txt","description":"Write out.txt"}'), usage(9000, 40)] },
{ chunks: [delta({ content: "## Done\n\nWrote **out.txt**." }), usage(12000, 60)] },
])
const d = await drive({ cols, rows, bin, home: h, cwd })
// A fresh directory opens with the first-run question; trust it (the default), then work.
const firstRun = await d.waitFor("Trust this directory?")
await d.key("\r")
const drawn = await d.waitFor("shift+tab mode · /help")
await d.type("make a file")
await d.key("\r")
const asked = await d.waitFor("Allow once")
await d.key("y")
const answered = await d.waitFor("Wrote out.txt.")
await Bun.sleep(200)
const screen = d.screen()
const wrapped = d.wrapped()
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
f.stop()
const file = await Bun.file(join(cwd, "out.txt")).text().catch(() => "")
check(
`TUI ${cols}x${rows}: first run → prompt → approval → answer → quit`,
firstRun && drawn && asked && answered && file === "smoke\n" && code === 0,
`firstRun=${firstRun} drawn=${drawn} asked=${asked} answered=${answered} file=${JSON.stringify(file)} exit=${code}\n${screen.join("\n")}`,
)
check(`TUI ${cols}x${rows}: nothing wider than the terminal`, wrapped.length === 0, `wrapped rows: ${wrapped.join(", ")}`)
check(`TUI ${cols}x${rows}: status bar on the last row`, (screen[rows - 1] ?? "").includes("manual · fake/coder"), screen[rows - 1] ?? "")
}
// 3b. jobs, at 80×24: a command the model starts in the background is counted in the status bar;
// /jobs opens the list (the row fits the terminal) and esc closes it; quitting with it running asks
// first, and y stops it and leaves.
{
const { fake: f, home: h, cwd } = fakeHome([
{ chunks: [delta({ content: "Starting it." }), toolCall(0, "c1", "bash", '{"command":"sleep 60","background":true,"description":"A long server that keeps running"}'), usage(900, 40)] },
{ chunks: [delta({ content: "Started it in the background." }), usage(1200, 20)] },
])
const d = await drive({ cols: 80, rows: 24, bin, home: h, cwd })
await d.waitFor("Trust this directory?")
await d.key("\r")
await d.waitFor("shift+tab mode · /help")
await d.type("start a server")
await d.key("\r")
const asked = await d.waitFor("Allow once")
await d.key("y")
const started = await d.waitFor("Started it in the background.")
const counted = await d.waitFor("1 job ·")
await d.type("/jobs")
await d.key("\r")
const opened = await d.waitFor("enter open · k stop")
// The row: id, the description, the state, the time, and the box's border after them.
const row = d.screen().find((l) => l.includes("job1")) ?? ""
const listed = /job1 .*A long server.*running +\d+s +│/.test(row)
const wrapped = d.wrapped()
const screen = d.screen()
await d.key("\x1b")
await Bun.sleep(400)
const closed = !d.screen().some((l) => l.includes("enter open · k stop"))
await d.key("\x03")
await d.key("\x03")
const confirm = await d.waitFor("1 job running — stop it and quit?")
await d.key("y")
const code = await d.close()
f.stop()
check(
"TUI 80x24: a background job is counted; /jobs opens and closes; quitting with it running asks, y stops it and quits",
asked && started && counted && opened && listed && closed && confirm && code === 0,
`asked=${asked} started=${started} counted=${counted} opened=${opened} listed=${listed} closed=${closed} confirm=${confirm} exit=${code}\n${screen.join("\n")}`,
)
check("TUI 80x24: the jobs list is no wider than the terminal", wrapped.length === 0, `wrapped rows: ${wrapped.join(", ")}`)
}
// 4. a fresh install: no model yet. A screen that says what to add (not an exception with the
// terminal left in mouse-reporting mode); r once it is added opens the session; the terminal is
// given back as it was found.
{
const { fake: f, home: h, cwd } = fakeHome([{ chunks: [delta({ content: "hello after setup" }), usage(5, 2)] }])
const cfgDir = join(h, ".config/lembas")
const connections = await Bun.file(join(cfgDir, "connections.yaml")).text()
await Bun.write(join(cfgDir, "connections.yaml"), "connections: {}\n")
await Bun.write(join(cfgDir, "config.yaml"), "mode: manual\nupdate: { auto: off }\n")
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd })
await d.waitFor("Trust this directory?")
await d.key("\r")
const setup = await d.waitFor("no model to talk to yet")
await Bun.write(join(cfgDir, "connections.yaml"), connections)
await Bun.write(join(cfgDir, "config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
// The screen opens on the sign-in, its address focused: ctrl+r tries again from there.
const focused = await d.waitFor("Log in to a LLeMbas instance")
await d.key("\x12")
const opened = await d.waitFor("fake/coder")
await d.type("hi")
await d.key("\r")
const answered = await d.waitFor("hello after setup")
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
const modes = d.modes()
f.stop()
check("fresh install: no model → setup screen on the sign-in; ctrl+r after adding one → a session", setup && focused && opened && answered && code === 0, `setup=${setup} focused=${focused} opened=${opened} answered=${answered} exit=${code}\n${d.screen().join("\n")}`)
check("fresh install: the terminal is given back (mouse reporting off, normal screen)", modes.mouse === "none" && !modes.altScreen, JSON.stringify(modes))
}
// 5. /reload: the same process starts again (execv) on the same session — the conversation is
// there, the terminal is the TUI's again, and quitting leaves it as it was found.
{
const { fake: f, home: h, cwd } = fakeHome([
{ chunks: [delta({ content: "first answer" }), usage(5, 2)] },
{ chunks: [delta({ content: "second answer" }), usage(9, 2)] },
])
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd })
await d.waitFor("Trust this directory?")
await d.key("\r")
await d.waitFor("fake/coder")
await d.type("hello")
await d.key("\r")
const answered = await d.waitFor("first answer")
await d.type("/reload")
await d.key("\r")
// The new program draws its welcome box again, with the conversation loaded under it.
await Bun.sleep(300)
const back = await d.waitFor("shift+tab mode · /help")
// The new program's next request carries the conversation from before the reload.
await d.type("again")
await d.key("\r")
await d.waitFor("second answer")
const history = JSON.stringify(f.requests[1]?.messages ?? [])
const kept = history.includes("hello") && history.includes("first answer")
const screen = d.screen()
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
const modes = d.modes()
f.stop()
check("/reload: started again, back on the same session", answered && back && kept && code === 0, `answered=${answered} back=${back} kept=${kept} exit=${code}\n${screen.join("\n")}`)
check("/reload: the terminal is given back on quit", modes.mouse === "none" && !modes.altScreen, JSON.stringify(modes))
}
// 6. local mode (-l): everything in the project, nothing written anywhere else — the user's
// directories, HOME and TMPDIR are checked file by file before and after a whole session.
{
const { fake: f, home: h, cwd } = fakeHome([
{ chunks: [delta({ content: "Writing it." }), toolCall(0, "c1", "bash", '{"command":"echo local > out.txt","description":"Write out.txt"}'), usage(900, 40)] },
{ chunks: [delta({ content: "local done" }), usage(1200, 20)] },
])
const tmp = mkdtempSync(join(tmpdir(), "lembas-smoke-tmp-"))
const listing = (dir: string): string[] => {
const out: string[] = []
for (const e of readdirSync(dir, { withFileTypes: true, recursive: true })) out.push(join(e.parentPath, e.name))
return out.sort()
}
const before = { home: listing(h), tmp: listing(tmp) }
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd, args: ["-l"], env: { HOME: h, TMPDIR: tmp, XDG_CONFIG_HOME: join(h, ".config"), XDG_DATA_HOME: join(h, ".local/share"), XDG_STATE_HOME: join(h, ".local/state") } })
const firstRun = await d.waitFor("Trust this directory?")
await d.key("\r")
const drawn = await d.waitFor("shift+tab mode · /help", 15000)
await d.type("make a file")
await d.key("\r")
const asked = await d.waitFor("Allow once")
await d.key("y")
const answered = await d.waitFor("local done")
await d.type("/remote")
await d.key("\r")
const refused = await d.waitFor("off in local mode")
const screen = d.screen()
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
f.stop()
const after = { home: listing(h), tmp: listing(tmp) }
const grew = [...after.home.filter((p) => !before.home.includes(p)), ...after.tmp.filter((p) => !before.tmp.includes(p))]
const kept = ["data/sessions.db", "state/trust.json"].every((p) => existsSync(join(cwd, ".agent/local/lembas", p)))
check(
"local mode: a whole session works from the project",
firstRun && drawn && asked && answered && refused && kept && code === 0 && (screen[29] ?? "").includes("local"),
`firstRun=${firstRun} drawn=${drawn} asked=${asked} answered=${answered} refused=${refused} kept=${kept} exit=${code}\n${screen.join("\n")}`,
)
check("local mode: nothing written outside the project (home, XDG dirs, TMPDIR)", grew.length === 0, grew.join(", "))
}
process.exit(failed ? 1 : 0)