Files
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

270 lines
12 KiB
TypeScript
Raw Permalink 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.
// Background jobs in the TUI (harness spec 2.0.0, loop.json `jobs`): the status bar's count, the
// /jobs picker — rows that fit the terminal at 80×24 and 120×40, filtering, redrawing on events —
// a command's live output, entering a subagent and messaging it, the quit prompt with jobs running,
// and ctrl+b moving the foreground command to the background. All inside the real Root.
import { afterEach, expect, test } from "bun:test"
import { Bus } from "../../src/bus/index.ts"
import type { App } from "../../src/app.ts"
import type { Job, SubagentHandle } from "../../src/jobs/registry.ts"
import { fitColumns, jobRow } from "../../src/tui/commands.ts"
import { jobsWord } from "../../src/tui/components/status.tsx"
import { delta, toolCall } from "../fake-provider.ts"
import { tui } from "./harness.tsx"
let t: Awaited<ReturnType<typeof tui>> | undefined
afterEach(() => {
if (!t) return
// Quietly: a stop's note would wake a session whose fake endpoint is gone.
t.app.registry.stopAll(t.app.jobsSessionId(), true)
t.stop()
t = undefined
})
// Bun's per-test timeout is 5 s here (bunfig's `[test] timeout` is not read by bun 1.4.2): a test
// that renders a few screens and waits for a process needs longer, and says so.
const SLOW = 15_000
/** What the model says when a job's end note wakes the session. */
const noted = () => ({ chunks: [delta({ content: "Noted." })] })
const widest = (frame: string) => Math.max(...frame.split("\n").map((l) => Bun.stringWidth(l)))
const lineWith = (frame: string, text: string) => frame.split("\n").find((l) => l.includes(text)) ?? ""
function command(app: App, command: string, description?: string): Job {
return app.registry.start({ sessionId: app.jobsSessionId(), command, cwd: process.cwd(), env: process.env, description })
}
/** A subagent job with a stub handle: what the TUI sends to it is recorded, nothing runs. */
function subagent(app: App, description = "find the bug") {
const bus = new Bus()
const sent: string[] = []
const restarted: string[] = []
const handle: SubagentHandle = { bus, engine: { messages: [] } as never, send: (x) => void sent.push(x), restart: async (x) => void restarted.push(x), cancel: () => {} }
const job = app.registry.startSubagent({ sessionId: app.jobsSessionId(), agent: "explore", model: "fake/coder", description, handle })
return { job, bus, sent, restarted }
}
const untilJob = async (job: Job, state: string, ms = 5000) => {
const end = Date.now() + ms
while (Date.now() < end && job.state !== state) await Bun.sleep(20)
return job.state
}
test("a row is exactly as wide as asked, whichever icon set; the description gives", () => {
const info = { id: "job12", kind: "command" as const, state: "running" as const, description: "a long build of everything that takes a while to finish and then some more words", started: Date.now() - 72_000, unread: 0 }
for (const icon of ["💻", "$"]) {
const row = jobRow(info, 70, icon)
expect(Bun.stringWidth(row)).toBe(70)
expect(row).toContain("job12 ")
expect(row).toContain("…")
expect(row).toContain(" running 1m 12s ")
}
// A subagent row names its agent; a finished one its state; unread output is marked.
const sub = jobRow({ id: "job3", kind: "subagent", state: "done", agent: "explore", description: "find it", started: 0, ended: 4000, unread: 12 }, 60, "🤖")
expect(Bun.stringWidth(sub)).toBe(60)
expect(sub).toContain("explore: find it")
expect(sub).toMatch(/done {8}4s !$/)
expect(fitColumns("héllo wörld", 6)).toBe("héllo…")
expect(fitColumns("short", 10)).toBe("short")
// The status bar's word.
expect(jobsWord([])).toBe("")
expect(jobsWord([{ state: "running", unread: 0 }])).toBe("1 job")
expect(jobsWord([{ state: "running", unread: 0 }, { state: "running", unread: 5 }])).toBe("2 jobs!")
expect(jobsWord([{ state: "done", unread: 0, unseen: true }])).toBe("0 jobs!")
expect(jobsWord([{ state: "done", unread: 0 }])).toBe("")
})
for (const size of [
{ width: 80, height: 24 },
{ width: 120, height: 40 },
]) {
test(`${size.width}×${size.height}: the status bar counts jobs; /jobs lists them within the terminal; filter; esc; the unseen marker clears`, async () => {
t = await tui([noted()], size)
const job = command(t.app, "sleep 30", "a long build of everything that takes a while to finish and then some more words to cut")
expect(await t.until("1 job ·")).toBe(true)
await t.input.typeText("/jobs")
t.input.pressEnter()
expect(await t.until("enter open · k stop")).toBe(true)
const f = t.frame()
expect(widest(f)).toBeLessThanOrEqual(size.width)
const row = lineWith(f, "job1")
expect(row).toContain("running")
expect(row).toContain("…")
// The row ends inside the box: its right border is the last thing on the line.
expect(row.trimEnd().endsWith("│")).toBe(true)
// Type to filter: nothing matches "zzz"; the row is back once that is gone.
await t.input.typeText("zzz")
expect(await t.until("nothing matches")).toBe(true)
for (let i = 0; i < 3; i++) t.input.pressBackspace()
expect(await t.until("job1")).toBe(true)
t.input.pressEscape()
await t.settle()
expect(t.frame()).not.toContain("enter open · k stop")
// Stopped: the end is news until the list is looked at.
t.app.registry.stop(job)
expect(await untilJob(job, "stopped")).toBe("stopped")
expect(await t.until("0 jobs! ·")).toBe(true)
await t.input.typeText("/jobs")
t.input.pressEnter()
expect(await t.until("stopped")).toBe(true)
t.input.pressEscape()
await t.settle()
expect(t.frame()).not.toContain("jobs!")
}, SLOW)
}
test("the picker redraws itself on job events while open, and the cursor stays", async () => {
t = await tui([])
command(t.app, "sleep 30", "first")
command(t.app, "sleep 30", "second")
await t.input.typeText("/jobs")
t.input.pressEnter()
expect(await t.until("enter open")).toBe(true)
t.input.pressArrow("down")
await t.settle()
command(t.app, "sleep 30", "third one")
expect(await t.until("third one", 2000)).toBe(true)
// The second row is still the current one: enter opens job2.
t.input.pressEnter()
expect(await t.until("job2 · running")).toBe(true)
t.input.pressEscape()
await t.settle()
}, SLOW)
test("a command job opens on its live output; k stops it; esc goes back", async () => {
t = await tui([noted()])
const job = command(t.app, "echo hello-from-job; sleep 30", "say hello, then wait")
await t.input.typeText("/jobs job1")
t.input.pressEnter()
expect(await t.until("job1 · running")).toBe(true)
expect(await t.until("hello-from-job")).toBe(true)
const f = t.frame()
expect(f).toContain("k stop")
expect(f).toContain("say hello, then wait · $ echo hello-from-job; sleep 30")
expect(widest(f)).toBeLessThanOrEqual(100)
t.input.pressKey("k")
expect(await untilJob(job, "stopped")).toBe("stopped")
expect(await t.until("stopped ·")).toBe(true)
expect(t.frame()).not.toContain("k stop")
t.input.pressEscape()
await t.settle()
expect(t.frame()).not.toContain("esc back")
// The end note woke the session: the stop is a fact the model is told.
expect(await t.until("Noted.")).toBe(true)
expect(t.frame()).toContain("⏎ job1 (echo hello-from-job; sleep 30) stopped (SIGTERM) after")
}, SLOW)
test("entering a subagent: its header and transcript; what is typed goes to it; esc comes back; a finished one is started again", async () => {
t = await tui([{ chunks: [delta({ content: "Noted the report." })] }, noted()])
const { job, bus, sent, restarted } = subagent(t.app)
expect(await t.until("1 job ·")).toBe(true)
await t.input.typeText("/jobs")
t.input.pressEnter()
expect(await t.until("explore: find the bug")).toBe(true)
t.input.pressEnter()
expect(await t.until("subagent explore · job1 · running")).toBe(true)
expect(t.frame()).toContain("type to message")
bus.emit({ type: "step", n: 1 })
bus.emit({ type: "text", text: "I am reading calc.py now." })
expect(await t.until("I am reading calc.py now.")).toBe(true)
await t.input.typeText("also check the tests")
t.input.pressEnter()
expect(await t.until("sent to job1")).toBe(true)
expect(sent).toEqual(["also check the tests"])
// Esc leaves the subagent, nothing else: the main model was idle and stays so.
t.input.pressEscape()
expect(await t.until("shift+tab mode · /help")).toBe(true)
expect(t.frame()).not.toContain("subagent explore")
// It ends: the note wakes the session (a machine turn), and the status bar says so.
t.app.registry.finishSubagent(job, { reason: "stop", report: "The bug is on line 2." })
expect(await t.until("Noted the report.")).toBe(true)
expect(t.frame()).toContain("⏎ job1 (explore: find the bug) finished after")
// Entered again once finished: typing starts it again with the message, under the same id.
await t.input.typeText("/jobs job1")
t.input.pressEnter()
expect(await t.until("subagent explore · job1 · done")).toBe(true)
await t.input.typeText("and the other file?")
t.input.pressEnter()
expect(await t.until("running again with your message")).toBe(true)
expect(restarted).toEqual(["and the other file?"])
expect(job.state).toBe("running")
expect(t.frame()).toContain("and the other file?")
t.input.pressEscape()
await t.settle()
t.app.registry.finishSubagent(job, { reason: "stop", report: "Nothing more." })
expect(await t.until("Noted.")).toBe(true)
}, SLOW)
test("an approval from a subagent says which one asked", async () => {
t = await tui([])
const reply = t.view.asker.ask({
tool: "bash",
args: { command: "rm -r build" },
request: { permission: "bash", class: "execute", patterns: ["rm -r build"], command: "rm -r build" },
decision: { action: "ask", reason: "a command", always: [] },
source: { jobId: "job7", agent: "explore", description: "clean up" },
})
expect(await t.until("explore (job7): bash")).toBe(true)
await t.input.typeText("n")
expect(await reply).toEqual({ kind: "deny" })
})
test("quitting with a job running asks first: n keeps going, y stops the job and quits", async () => {
t = await tui([noted()])
const job = command(t.app, "sleep 30", "server")
await t.input.typeText("/quit")
t.input.pressEnter()
expect(await t.until("1 job running — stop it and quit?")).toBe(true)
t.input.pressKey("n")
await t.settle()
expect(t.quits()).toBe(0)
expect(t.frame()).not.toContain("stop it and quit")
expect(job.state).toBe("running")
// Ctrl+D on the empty box goes the same way (ctrl+c twice too: the smoke test drives that in a PTY).
t.input.pressKey("d", { ctrl: true })
expect(await t.until("stop it and quit?")).toBe(true)
t.input.pressKey("y")
await t.settle()
expect(t.quits()).toBe(1)
expect(await untilJob(job, "stopped")).toBe("stopped")
}, SLOW)
test("ctrl+d on an empty box quits at once when nothing runs", async () => {
t = await tui([])
t.input.pressKey("d", { ctrl: true })
await t.settle()
expect(t.quits()).toBe(1)
})
test("ctrl+b moves the foreground command to the background: the call returns, the job runs on, the task line follows it", async () => {
t = await tui([
{ chunks: [toolCall(0, "c1", "bash", '{"command":"echo early; sleep 20; echo late","description":"wait a while"}')] },
{ chunks: [delta({ content: "Moved on." })] },
])
await t.input.typeText("wait")
t.input.pressEnter()
expect(await t.until("Allow once")).toBe(true)
await t.input.typeText("y")
expect(await t.until("wait a while …")).toBe(true)
await Bun.sleep(300)
t.input.pressKey("b", { ctrl: true })
expect(await t.until("Moved on.")).toBe(true)
const result = t.fake.requests[1].messages.at(-1).content as string
expect(result).toContain("early")
expect(result).toContain("the user moved it to the background after")
expect(result).toContain("as job1")
const jobs = t.app.jobs()
expect(jobs.map((j) => [j.id, j.kind, j.state])).toEqual([["job1", "command", "running"]])
expect(await t.until("1 job ·")).toBe(true)
expect(t.frame()).toContain("→ job1 (wait a while) running…")
// Esc now stops nothing: the command is a job, not the turn's.
t.input.pressEscape()
await t.settle()
expect(jobs[0]!.state).toBe("running")
}, SLOW)
test("ctrl+b with nothing in the foreground says so", async () => {
t = await tui([])
t.input.pressKey("b", { ctrl: true })
expect(await t.until("no command is running in the foreground")).toBe(true)
})