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>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-20 16:17:00 +00:00
1 parent aaa3e3a44f
commit 2183040db8
75 files changed
+3614 -437

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
// 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)
})