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

+180
View File
@@ -0,0 +1,180 @@
// The `jobs` capability over ACP (harness/acp.md, spec 2.0.0): the capability is listed; the
// `job` and `job_note` events and a note's `machine` prompt are forwarded; `_lembas/jobs`,
// `_lembas/job/output`, `/stop`, `/send`, `/transcript` answer as the spec says, with -32602 where it
// says; `jobs` rides on `_lembas/session/status`.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { AcpAgent } from "../src/acp/agent.ts"
import { Peer, RpcError, type Transport } from "../src/acp/rpc.ts"
import { paths } from "../src/config/paths.ts"
import { MARKS } from "../src/jobs/note.ts"
import { setTrust } from "../src/project/root.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
let other: Fake | undefined
afterEach(() => {
fake?.stop()
other?.stop()
other = undefined
})
function pair(): [Transport, Transport] {
const make = () => ({ msg: (_: string) => {}, end: () => {} })
const a = make()
const b = make()
const side = (me: typeof a, o: typeof a): Transport => ({
send: (t) => queueMicrotask(() => o.msg(t)),
onMessage: (fn) => (me.msg = fn),
onClose: (fn) => (me.end = fn),
close: () => (me.end(), o.end()),
})
return [side(a, b), side(b, a)]
}
/** The parent's model on `f`; a second connection `g` for a subagent's model, when given. */
function config(url: string, childUrl?: string) {
mkdirSync(paths.config, { recursive: true })
writeFileSync(
join(paths.config, "connections.yaml"),
`connections:\n f:\n dialect: openai-chat\n base_url: ${url}\n models: { m: {} }\n${childUrl ? ` g:\n dialect: openai-chat\n base_url: ${childUrl}\n models: { m: {} }\n` : ""}`,
{ mode: 0o600 },
)
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nmode: auto\n")
}
function project(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "ph-acp-jobs-")))
setTrust(dir, "trusted")
writeFileSync(join(dir, "calc.py"), "def average(v):\n return sum(v) / (len(v) - 1)\n")
return dir
}
async function client(open = true) {
const [ta, tb] = pair()
new AcpAgent(new Peer(ta))
const c = new Peer(tb)
const events: any[] = []
c.on("_lembas/event", (p) => void events.push(p.event))
c.handle("session/request_permission", () => ({ outcome: { outcome: "selected", optionId: "once" } }))
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
const s: any = open ? await c.request("session/new", { cwd: project(), mcpServers: [] }) : {}
return { c, events, init, sid: s.sessionId as string }
}
// Bun's per-test timeout is 5 s (bunfig's `[test] timeout` is not read by bun 1.4.2); a session, a
// prompt and a process take longer on a small machine.
const SLOW = 20_000
const code = (p: Promise<unknown>) => p.then(() => undefined, (e) => (e instanceof RpcError ? e.code : e))
const until = async (ok: () => boolean, ms = 6000) => {
const end = Date.now() + ms
while (Date.now() < end && !ok()) await Bun.sleep(20)
return ok()
}
test("initialize lists the jobs capability", async () => {
fake = fakeProvider([])
config(fake.url)
const { init } = await client(false)
expect(init._meta.lembas.capabilities).toContain("jobs")
}, SLOW)
test("a background command: listed, read from `since`, in status; its events and its note's machine turn are forwarded", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "echo out; sleep 2; echo later", background: true, description: "later" }))] },
{ chunks: [delta({ content: "Started it." })] },
{ chunks: [delta({ content: "Seen the end." })] },
])
config(fake.url)
const { c, events, sid } = await client()
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] })
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
expect(listed.jobs).toHaveLength(1)
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "command", state: "running", description: "later", command: "echo out; sleep 2; echo later" })
expect(typeof listed.jobs[0].started).toBe("number")
const out: any = await c.request("_lembas/job/output", { sessionId: sid, id: "job1" })
expect(out.text).toBe("out\n")
expect(out).toMatchObject({ length: 4, state: "running" })
// Reading here moves no mark of the model's (the start result had already handed it "out").
expect((await c.request<any>("_lembas/jobs", { sessionId: sid })).jobs[0].unread).toBe(listed.jobs[0].unread)
const status: any = await c.request("_lembas/session/status", { sessionId: sid })
expect(status.held).toBe("here")
expect(status.jobs).toHaveLength(1)
expect(status.jobs[0]).toMatchObject({ id: "job1", state: "running" })
// The end: a `job` end event, the note given as a `job_note`, and the turn it starts marked machine.
expect(await until(() => events.some((e) => e.type === "prompt" && e.machine === true))).toBe(true)
const machine = events.find((e) => e.type === "prompt" && e.machine)
expect(machine.text).toStartWith("⏎ job1 (echo out; sleep 2; echo later) exited with 0")
expect(events.some((e) => e.type === "job" && e.state === "start" && e.job.id === "job1")).toBe(true)
expect(events.some((e) => e.type === "job" && e.state === "end" && e.job.id === "job1" && e.job.state === "done" && e.job.exit === 0)).toBe(true)
const note = events.find((e) => e.type === "job_note")
expect(note).toMatchObject({ jobId: "job1" })
expect(note.text).toStartWith(MARKS.command)
expect(note.text).toContain("later")
await until(() => fake!.requests.length >= 3)
const later: any = await c.request("_lembas/job/output", { sessionId: sid, id: "job1", since: out.length })
expect(later).toMatchObject({ text: "later\n", length: 10, state: "done" })
expect((await c.request<any>("_lembas/jobs", { sessionId: sid })).jobs[0]).toMatchObject({ id: "job1", state: "done", exit: 0 })
}, SLOW)
test("job/stop stops a command and answers once it has; the -32602 cases: a command sent to or transcribed, an id the session lacks", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "sleep 30", background: true, description: "a server" }))] },
{ chunks: [delta({ content: "Running." })] },
{ chunks: [delta({ content: "Noted the stop." })] },
])
config(fake.url)
const { c, events, sid } = await client()
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] })
expect(await c.request<any>("_lembas/job/stop", { sessionId: sid, id: "job1" })).toEqual({ state: "stopped" })
// Again: the state it already had.
expect(await c.request<any>("_lembas/job/stop", { sessionId: sid, id: "job1" })).toEqual({ state: "stopped" })
// The person's stop is told to the model as any end is.
expect(await until(() => events.some((e) => e.type === "job_note" && e.jobId === "job1"))).toBe(true)
expect(events.find((e) => e.type === "job_note").text).toContain("stopped (SIGTERM)")
expect(await code(c.request("_lembas/job/send", { sessionId: sid, id: "job1", text: "hello" }))).toBe(-32602)
expect(await code(c.request("_lembas/job/transcript", { sessionId: sid, id: "job1" }))).toBe(-32602)
expect(await code(c.request("_lembas/job/output", { sessionId: sid, id: "job9" }))).toBe(-32602)
expect(await code(c.request("_lembas/job/stop", { sessionId: sid, id: "job9" }))).toBe(-32602)
expect(await code(c.request("_lembas/job/send", { sessionId: sid, id: "job9", text: "x" }))).toBe(-32602)
expect(await code(c.request("_lembas/job/transcript", { sessionId: sid, id: "job9" }))).toBe(-32602)
await until(() => fake!.requests.length >= 3)
}, SLOW)
test("a background subagent: its transcript as history turns; send to a finished one starts it again under the same id", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find the bug", prompt: "Find why average is wrong", model: "g/m", background: true }))] },
{ chunks: [delta({ content: "Started it." })] },
{ chunks: [delta({ content: "Got the report." })] },
{ chunks: [delta({ content: "Got the second report." })] },
])
other = fakeProvider([{ chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }, { chunks: [delta({ content: "The tests cover it." })] }])
config(fake.url, other.url)
const { c, events, sid } = await client()
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] })
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
const tr: any = await c.request("_lembas/job/transcript", { sessionId: sid, id: "job1" })
expect(tr.id).toBe("job1")
expect(tr.turns).toEqual([
{ role: "user", text: "Find why average is wrong" },
{ role: "assistant", text: "calc.py:2 divides by len(v) - 1.", reasoning: "", tools: [] },
])
// Its progress as the model would read it: what it wrote.
expect((await c.request<any>("_lembas/job/output", { sessionId: sid, id: "job1" })).text).toContain("calc.py:2 divides")
await until(() => fake!.requests.length >= 3)
// Finished: the message starts it again, a new `job` start event follows, and the transcript grows.
const starts = () => events.filter((e) => e.type === "job" && e.state === "start").length
expect(await c.request<any>("_lembas/job/send", { sessionId: sid, id: "job1", text: "Do the tests cover it?" })).toEqual({ state: "running" })
expect(await until(() => starts() === 2)).toBe(true)
expect(await until(() => events.filter((e) => e.type === "job" && e.state === "end").length === 2)).toBe(true)
const again: any = await c.request("_lembas/job/transcript", { sessionId: sid, id: "job1" })
expect(again.turns.map((t: any) => t.text)).toEqual(["Find why average is wrong", "calc.py:2 divides by len(v) - 1.", "Do the tests cover it?", "The tests cover it."])
expect(await code(c.request("_lembas/job/send", { sessionId: sid, id: "job1", text: "" }))).toBe(-32602)
await until(() => fake!.requests.length >= 4)
expect(fake!.requests[3].messages.at(-1).content).toContain("The tests cover it.")
}, SLOW)
+1 -1
View File
@@ -233,7 +233,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
config(fake.url)
const root = project()
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind"])
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs"])
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
+2
View File
@@ -19,6 +19,7 @@ import { resolveBase } from "../src/library/store.ts"
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
import { fillPrompt } from "../src/prompt/fill.ts"
import { applyOps, type Op, type Target } from "../src/memory/store.ts"
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
type Case = Record<string, unknown>
@@ -94,6 +95,7 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
const r = applyOps(c.target as Target, c.entries as string[], c.ops as Op[], c.limit as number)
return { ok: r.ok, entries: r.ok ? r.after : c.entries, message: r.message, usage: r.usage, shown: Boolean(r.entries) }
},
jobs: (c) => jobNote(c as unknown as NoteCase),
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
+1 -1
View File
@@ -50,7 +50,7 @@ describe("end to end", () => {
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "bash_output", "bash_list", "bash_kill", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings"])
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings"])
const ends = events.filter((e) => e.type === "tool_end")
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
const sid = store.sessions(1)[0]!.id
+7 -3
View File
@@ -128,13 +128,15 @@ test("prompts/index.json lists every prompt text, each with a scope", () => {
test("the execution tools are the CLI's alone; the web UI runs no agent", () => {
// LLeMbas removed its SSH workplace, so nothing that runs a command or touches a file is shared.
const exec = ["apply_patch", "bash", "bash_kill", "bash_list", "bash_output", "edit", "glob", "grep", "list", "multiedit", "read", "write"]
const exec = ["apply_patch", "bash", "edit", "glob", "grep", "list", "multiedit", "read", "write"]
for (const n of exec) expect(TOOL_SPECS[n]!.scope).toBe("cli")
// The plan is one structure both keep (spec 1.1.0): its three tools are shared, and todo is
// plan_write's former name rather than a tool.
expect(TOOL_SPECS.todo).toBeUndefined()
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
const shared = ["ask_user", "knowledge_get", "knowledge_search", "memory", "note_manage", "note_view", "notes_search", "plan_submit", "plan_update", "plan_write", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search"]
// Spec 2.0.0: the job tools cover both kinds of background work and are shared (the CLI's former
// bash_* names are their aliases); report is a subagent's.
const shared = ["ask_user", "job_list", "job_output", "job_send", "job_stop", "knowledge_get", "knowledge_search", "memory", "note_manage", "note_view", "notes_search", "plan_submit", "plan_update", "plan_write", "report", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search"]
for (const n of shared) expect(TOOL_SPECS[n]!.scope).toBe("shared")
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
})
@@ -180,7 +182,7 @@ test("every {{variable}} in a description is declared, and none reaches a model"
expect(JSON.stringify(toSpec(t).parameters)).not.toContain("{{")
}
const bash = app.engine.o.tools.find((t) => t.name === "bash")!
expect(bash.description).toContain("default 90 seconds, `timeout` up to 10 minutes). For a server")
expect(bash.description).toContain("default 90 seconds, up to 10 minutes) is not killed: it goes on in the background as a job, and the call returns what it had printed with the job id. For a server")
expect(PURPOSE_DEF.schema).toEqual({ type: "string", description: expect.stringContaining("what this call is for") })
// Nor through the assembled system prompt, in any mode, with or without a plan, with the
// instance's pieces or without them; the CLI's facts are in it and the bullets LLeMbas alone
@@ -194,6 +196,8 @@ test("every {{variable}} in a description is declared, and none reaches a model"
expect(system).toContain("- Your output is shown in a terminal and rendered as GitHub-flavoured markdown in a monospace font. Be concise")
expect(system).toContain("- Use the dedicated tools for files: read (not cat)")
expect(system).toContain("- A long-running command (a server, a watcher) runs with bash `background: true`")
expect(system).toContain("- For work with three or more steps, write a plan with plan_write")
expect(system).toContain("- When a decision is genuinely the user's")
expect(system).toContain("# Git\n- How git is used in this project")
expect(system).not.toContain("- \n")
if (p) expect(system).toContain("- ### The current plan\n\n# T\n## p1 P (pending)\n[t1] [ ] a\n\nThis is the plan as it stands now. Work to it, and quote the ids above rather than restating the plan in your answer.")
+61
View File
@@ -0,0 +1,61 @@
// `lembas run`: after the prompt the run waits for the jobs it started — their notes are turns of
// their own — then stops what is left; --no-wait stops them at once (the spec's lifecycle: nobody
// to ask). The terminal asker takes one question at a time.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import type { AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { settleJobs, terminalAsker } from "../src/headless.ts"
import { MARKS } from "../src/jobs/note.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
function app(script: Parameters<typeof fakeProvider>[0]) {
fake = fakeProvider(script)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
return createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-hl-")), mode: "auto", store: false, snapshots: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
}
test("the run waits for the prompt's jobs and gets their notes as turns", async () => {
const a = app([
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "sleep 0.4; echo late", background: true, description: "later" }))] },
{ chunks: [delta({ content: "Running." })] },
{ chunks: [delta({ content: "Seen late." })] },
])
await a.turns.prompt("go")
expect(fake!.requests.length).toBe(2)
await settleJobs(a, true)
expect(fake!.requests.length).toBe(3)
expect(fake!.requests[2].messages.at(-1).content).toStartWith(MARKS.command)
expect(a.registry.running(a.jobsSessionId())).toEqual([])
})
test("--no-wait stops what runs instead", async () => {
const a = app([{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "sleep 30", background: true, description: "server" }))] }, { chunks: [delta({ content: "Running." })] }])
await a.turns.prompt("go")
expect(a.registry.running(a.jobsSessionId())).toHaveLength(1)
await settleJobs(a, false)
const until = Date.now() + 3000
while (Date.now() < until && a.registry.running(a.jobsSessionId()).length) await Bun.sleep(20)
expect(a.registry.running(a.jobsSessionId())).toEqual([])
expect(fake!.requests.length).toBe(2)
})
test("the terminal asker answers one question at a time", async () => {
// --yes answers at once; the order of answers is the order of asks, not of whoever finished first.
const asker = terminalAsker({ yes: true })
const req = (n: string) => ({ tool: "bash", args: {}, request: { permission: "bash", class: "execute" as const, patterns: [n], command: n }, decision: { action: "ask" as const, reason: n, always: [] } })
const order: string[] = []
const a = asker.ask(req("one")).then((r) => (order.push("one"), r))
const b = asker.ask(req("two")).then((r) => (order.push("two"), r))
expect(await a).toEqual({ kind: "once" })
expect(await b).toEqual({ kind: "once" })
expect(order).toEqual(["one", "two"])
})
+60 -22
View File
@@ -1,15 +1,15 @@
// A background job that ends wakes the session (after LLeMbas's jobs): the exit is heard,
// the note says which job, how it ended and the output not yet read; mid-task the model gets it at
// its next step, and a model that had finished carries on with it rather than stopping.
// A background job that ends wakes the session that owns it (harness spec loop.json `jobs`): mid-task
// the model gets the note at its next step, under its mark; idle, the note starts a turn of its own,
// marked `machine`; a model that had finished carries on with a note that arrived meanwhile.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import type { AskReply } from "../src/bus/index.ts"
import type { AskReply, Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { JOB_MARK } from "../src/session/engine.ts"
import { jobNote, onJobExit, startJob, type Job } from "../src/tool/jobs.ts"
import { MARKS } from "../src/jobs/note.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
@@ -22,21 +22,12 @@ function app(url: string) {
return createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-wake-")), mode: "auto", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
}
test("a job's end is heard, with what it printed that nobody has read", async () => {
const ended = new Promise<Job>((resolve) => {
const stop = onJobExit((j) => (stop(), resolve(j)))
})
startJob("echo built; exit 3", tmpdir(), process.env)
const job = await ended
const note = jobNote(job)
expect(note).toContain(`Background job ${job.id}`)
expect(note).toContain("exited with 3")
expect(note).toContain("built")
job.read = job.output.length
expect(jobNote(job)).toContain("printed nothing you had not read")
test("the command mark is the spec's", () => {
expect(JOB_MARK).toBe(MARKS.command)
expect(JOB_MARK).toBe("[A background job you started has finished:]")
})
test("mid-task, the note reaches the model at its next step", async () => {
test("mid-task, the note reaches the model at its next step, with its mark", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "true", description: "check" }))] },
{ chunks: [delta({ content: "Seen it." })] },
@@ -46,10 +37,10 @@ test("mid-task, the note reaches the model at its next step", async () => {
a.bus.on((e) => {
if (e.type === "tool_start" && !noted) {
noted = true
a.engine.note("Background job job7 (bun run build) exited with 0 after 4s.")
a.wake(`${MARKS.command}\njob7 (bun run build) exited with 0 after 4s.`, "⏎ job7")
}
})
await a.engine.prompt("build it")
await a.turns.prompt("build it")
const second = fake.requests[1].messages as { role: string; content: string }[]
const told = second.find((m) => m.role === "user" && String(m.content).startsWith(JOB_MARK))
expect(told?.content).toContain("job7")
@@ -62,11 +53,58 @@ test("a model that had finished carries on with a job that ended meanwhile", asy
a.bus.on((e) => {
if (e.type === "text" && !noted) {
noted = true
a.engine.note("Background job job2 (bun test) exited with 0 after 30s.")
a.wake(`${MARKS.command}\njob2 (bun test) exited with 0 after 30s.`, "⏎ job2")
}
})
const reason = await a.engine.prompt("run the tests in the background")
const reason = await a.turns.prompt("run the tests in the background")
expect(reason).toBe("stop")
expect(fake.requests.length).toBe(2)
expect(a.engine.takeNotes()).toEqual([])
})
test("idle, a note is a turn of its own: marked machine on the prompt event and the stored message", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "Noted." })] }])
const a = app(fake.url)
const events: Event[] = []
a.bus.on((e) => events.push(e))
a.wake(`${MARKS.command}\njob1 (make) exited with 0 after 2s.`, "⏎ job1 (make) exited with 0 after 2s.")
while (a.turns.current) await Bun.sleep(20)
const prompt = events.find((e) => e.type === "prompt")
expect(prompt).toMatchObject({ type: "prompt", text: "⏎ job1 (make) exited with 0 after 2s.", machine: true })
expect(a.engine.messages[0]).toMatchObject({ role: "user", machine: true })
expect(fake.requests[0].messages.at(-1).content).toStartWith(JOB_MARK)
})
test("a real job: ended during the turn it goes in at the next step; ended after it, it starts a turn of its own", async () => {
fake = fakeProvider([
// Ends at once: the note is in the turn's next step.
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "echo built; exit 3", background: true, description: "build" }))] },
{ chunks: [delta({ content: "Seen the quick one." })] },
// Ends after the turn: a machine turn of its own.
{ chunks: [toolCall(0, "t2", "bash", JSON.stringify({ command: "sleep 0.4; echo late", background: true, description: "later" }))] },
{ chunks: [delta({ content: "Running in the background." })] },
{ chunks: [delta({ content: "It printed late." })] },
])
const a = app(fake.url)
const events: Event[] = []
a.bus.on((e) => events.push(e))
await a.turns.prompt("build in the background")
const quick = fake.requests[1].messages.filter((m: any) => m.role === "user").at(-1).content as string
expect(quick).toStartWith(JOB_MARK)
expect(quick).toContain("job1 (echo built; exit 3) exited with 3")
expect(quick).toContain("built")
await a.turns.prompt("and a slow one")
expect(fake.requests.length).toBe(4)
const until = Date.now() + 5000
while (Date.now() < until && (fake.requests.length < 5 || a.turns.current)) await Bun.sleep(20)
const note = fake.requests[4].messages.at(-1).content as string
expect(note).toStartWith(JOB_MARK)
expect(note).toContain("job2 (sleep 0.4; echo late) exited with 0")
expect(note).toContain("late")
// The prompt, the quick note, the second prompt, the late note.
expect(fake.requests[4].messages.filter((m: any) => m.role === "user").length).toBe(4)
expect(events.filter((e) => e.type === "prompt" && e.machine)).toHaveLength(1)
expect(events.filter((e) => e.type === "job_note").map((e) => e.type === "job_note" && e.jobId)).toEqual(["job1", "job2"])
expect(events.filter((e) => e.type === "job" && e.state !== "update").map((e) => e.type === "job" && `${e.job.id} ${e.state}`)).toEqual(["job1 start", "job1 end", "job2 start", "job2 end"])
expect(a.jobs().map((j) => [j.id, j.state, j.exit])).toEqual([["job1", "error", 3], ["job2", "done", 0]])
})
+223
View File
@@ -0,0 +1,223 @@
// The job registry (harness spec loop.json `jobs`, src/jobs/registry.ts): every lookup is the
// owner's; ids run per session and continue from the store; a job is persisted, and one the process
// died under is lost; a note for a session nobody holds waits for its resume; a foreground command at
// its timeout becomes a job and the call returns; the caps hold; a signal's end never reads "null".
import { afterEach, describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import { Bus, type AskReply, type Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { JobRegistry } from "../src/jobs/registry.ts"
import { jobNote, MARKS } from "../src/jobs/note.ts"
import { Store } from "../src/session/store.ts"
import { bashTool, ENV } from "../src/tool/bash.ts"
import { jobListTool, jobOutputTool, jobStopTool, stateWords } from "../src/tool/job_tools.ts"
import type { ToolContext } from "../src/tool/tool.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
const dir = () => mkdtempSync(join(tmpdir(), "ph-jobs-"))
const ctx = (registry: JobRegistry, sessionId: string): ToolContext => {
const root = dir()
return { root, cwd: root, signal: new AbortController().signal, readFiles: new Set(), fileStamps: new Map(), bashTimeoutMs: 5000, jobs: { registry, owner: () => ({ sessionId }) } }
}
const ended = async (registry: JobRegistry, sid: string, id: string) => {
const until = Date.now() + 5000
while (Date.now() < until && registry.get(sid, id)?.state === "running") await Bun.sleep(20)
return registry.get(sid, id)!
}
describe("the registry", () => {
test("every lookup is the owner's: one session never sees another's jobs", async () => {
const r = new JobRegistry()
const a = r.start({ sessionId: "s1", command: "echo a", cwd: tmpdir(), env: ENV })
expect(a.id).toBe("job1")
expect(r.list("s2")).toEqual([])
expect(r.get("s2", "job1")).toBeUndefined()
expect(r.start({ sessionId: "s2", command: "echo b", cwd: tmpdir(), env: ENV }).id).toBe("job1")
expect(r.list("s1").map((j) => j.id)).toEqual(["job1"])
await expect(jobOutputTool.run({ id: "job1" }, ctx(r, "s3"))).rejects.toThrow("No background job job1")
await ended(r, "s1", "job1")
await ended(r, "s2", "job1")
})
test("ids continue from the store; a job running when the process died is lost, with its tail", async () => {
const store = new Store(join(dir(), "s.db"))
const sid = store.createSession("/p", "f/m").id
const first = new JobRegistry()
first.attach({ sessionId: () => sid, bus: new Bus(), wake: () => {}, store })
const done = first.start({ sessionId: sid, command: "echo finished", cwd: tmpdir(), env: ENV })
const long = first.start({ sessionId: sid, command: "echo partial; sleep 30", cwd: tmpdir(), env: ENV })
await ended(first, sid, done.id)
while (!long.output) await Bun.sleep(20)
await Bun.sleep(50)
// Another process, the same store.
const second = new JobRegistry()
second.attach({ sessionId: () => sid, bus: new Bus(), wake: () => {}, store })
const jobs = second.list(sid)
expect(jobs.map((j) => [j.id, j.state])).toEqual([
["job1", "done"],
["job2", "lost"],
])
expect(jobs[1]!.output).toContain("partial")
expect(second.nextId(sid)).toBe("job3")
expect(store.jobs(sid).map((j) => j.state)).toEqual(["done", "lost"])
expect(stateWords(jobs[1]!)).toContain("lost")
first.stop(long)
await ended(first, sid, long.id)
})
test("the cap on running commands holds per session", async () => {
const r = new JobRegistry()
r.attach({ sessionId: () => "s1", bus: new Bus(), wake: () => {}, runningMax: () => 1 })
const c = ctx(r, "s1")
const one = await bashTool.run({ command: "sleep 30", background: true }, c)
await expect(bashTool.run({ command: "sleep 30", background: true }, c)).rejects.toThrow("already has 1 background command running (limits.jobs: 1)")
// Another session has its own cap.
const other = r.start({ sessionId: "s2", command: "sleep 30", cwd: tmpdir(), env: ENV })
r.stopAll("s1")
r.stop(other)
await ended(r, "s1", /as (job\d+)/.exec(one.output)![1]!)
await ended(r, "s2", other.id)
expect(r.running("s1")).toEqual([])
})
test("a signal's end reads stopped (SIGTERM) in the list, the output, the stop and the note — never null", async () => {
const r = new JobRegistry()
const c = ctx(r, "s1")
const started = await bashTool.run({ command: "sleep 30", background: true }, c)
const id = /as (job\d+)/.exec(started.output)![1]!
expect((await jobStopTool.run({ id }, c)).output).toBe(`Stopped ${id} (sleep 30).`)
const job = await ended(r, "s1", id)
expect(job.state).toBe("stopped")
expect(job.exit).toBeNull()
for (const text of [(await jobListTool.run({}, c)).output, (await jobOutputTool.run({ id }, c)).output, (await jobStopTool.run({ id }, c)).output, job.note!]) {
expect(text).toContain("stopped (SIGTERM)")
expect(text).not.toContain("null")
}
expect(jobNote({ kind: "command", id: "job1", command: "x", exit: null, seconds: 1, unread: "" }).text).toContain("stopped (SIGTERM) after 1s")
})
test("a foreground command at its timeout becomes a job: the call returns what it had, and a later cancel does not touch it", async () => {
const r = new JobRegistry()
const events: Event[] = []
const bus = new Bus()
bus.on((e) => events.push(e))
r.attach({ sessionId: () => "s1", bus, wake: () => {} })
const abort = new AbortController()
const c = { ...ctx(r, "s1"), signal: abort.signal, callId: "c9" }
const t0 = Date.now()
const res = await bashTool.run({ command: "echo early; sleep 1; echo late; sleep 1", timeout: 300 }, c)
expect(Date.now() - t0).toBeLessThan(900)
expect(res.isError).toBe(false)
expect(res.output).toContain("early")
expect(res.output).toContain("[still running after 0s; moved to the background as job1 — job_output to read on]")
expect(res.meta).toMatchObject({ job: "job1" })
const job = r.get("s1", "job1")!
expect(job.state).toBe("running")
expect(job.ownerCallId).toBe("c9")
expect(job.read).toBe(job.output.length)
// Esc on the turn after the hand-over: the job was the model's, and goes on.
abort.abort()
const later = await jobOutputTool.run({ id: "job1", wait: 5 }, { ...c, signal: new AbortController().signal })
expect(later.output).toContain("late")
await ended(r, "s1", "job1")
expect(r.get("s1", "job1")!.exit).toBe(0)
expect(events.filter((e) => e.type === "job").map((e) => e.type === "job" && e.state)).toContain("start")
})
test("the TUI's own commands (bashTimeline false) are still killed at their timeout", async () => {
const r = new JobRegistry()
const c = { ...ctx(r, "s1"), bashTimeline: false }
const res = await bashTool.run({ command: "sleep 5", timeout: 200 }, c)
expect(res.output).toContain("timed out after 0s")
expect(res.isError).toBe(true)
expect(r.list("s1")).toEqual([])
})
test("finished jobs beyond the cap are forgotten, oldest first; the store follows", async () => {
const store = new Store(join(dir(), "s.db"))
const sid = store.createSession("/p", "f/m").id
const r = new JobRegistry()
r.limits.finishedKept = 2
r.attach({ sessionId: () => sid, bus: new Bus(), wake: () => {}, store })
for (let i = 0; i < 4; i++) await ended(r, sid, r.start({ sessionId: sid, command: `echo ${i}`, cwd: tmpdir(), env: ENV }).id)
expect(r.list(sid).map((j) => j.id)).toEqual(["job3", "job4"])
expect(store.jobs(sid).map((j) => j.id)).toEqual(["job3", "job4"])
})
})
describe("in an app", () => {
function app(script: Parameters<typeof fakeProvider>[0], store?: Store) {
fake = fakeProvider(script)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\ntitles: prompt\n")
return createApp({ cwd: dir(), mode: "auto", store: store ?? false, snapshots: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
}
test("a job of a session nobody holds keeps its note until the session is resumed", async () => {
const store = new Store(join(dir(), "s.db"))
const a = app(
[
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "sleep 0.5; echo late", background: true, description: "later" }))] },
{ chunks: [delta({ content: "Running." })] },
{ chunks: [delta({ content: "Back, and it finished." })] },
],
store,
)
const first = a.engine.sessionId!
await a.turns.prompt("start it")
a.newSession()
const job = await ended(a.registry, first, "job1")
expect(job.note).toContain("late")
expect(a.registry.get(first, "job1")!.note).toStartWith(MARKS.command)
expect(store.jobs(first)[0]!.json).toContain("late")
expect(fake!.requests.length).toBe(2)
// Resumed: the note is a turn of its own on that session.
a.resume(first)
const until = Date.now() + 5000
while (Date.now() < until && (fake!.requests.length < 3 || a.turns.current)) await Bun.sleep(20)
expect(fake!.requests[2].messages.at(-1).content).toStartWith(MARKS.command)
expect(a.registry.get(first, "job1")!.note).toBeUndefined()
expect(store.messages(first).at(-2)).toMatchObject({ role: "user", machine: true })
})
test("the job tools see the open session's jobs, and /new starts a fresh sequence; the old session's run on", async () => {
const store = new Store(join(dir(), "s.db"))
const a = app(
[
{ chunks: [toolCall(0, "t1", "bash", JSON.stringify({ command: "sleep 30", background: true, description: "server" }))] },
{ chunks: [delta({ content: "Running." })] },
{ chunks: [toolCall(0, "t2", "job_list", "{}")] },
{ chunks: [delta({ content: "Nothing here." })] },
],
store,
)
const first = a.engine.sessionId!
await a.turns.prompt("start it")
expect(a.jobs().map((j) => j.id)).toEqual(["job1"])
a.newSession()
expect(a.jobs()).toEqual([])
await a.turns.prompt("list")
expect(fake!.requests[3].messages.at(-1).content).toBe("No background jobs.")
expect(a.registry.running(first)).toHaveLength(1)
// Deleting a session stops its jobs.
expect(a.registry.stopAll(first)).toBe(1)
await ended(a.registry, first, "job1")
expect(a.registry.running(first)).toEqual([])
})
test("limits.jobs and limits.subagent_steps are settings with the spec's defaults", async () => {
const a = app([])
expect(a.settings.get("limits.jobs").def).toMatchObject({ key: "limits.jobs", fallback: "8" })
expect(a.settings.get("limits.subagent_steps").def).toMatchObject({ key: "limits.subagent_steps", fallback: "50" })
expect(a.registry.runningMax(a.jobsSessionId())).toBe(8)
a.settings.set("limits.jobs", 2)
expect(a.registry.runningMax(a.jobsSessionId())).toBe(2)
})
})
+8
View File
@@ -35,6 +35,14 @@ test("a call under a former name runs the tool it now is, with its arguments", (
expect(canonicalToolNames(["read", "webfetch", "notes"])).toEqual(["read", "web_fetch", "notes_search", "note_view", "note_manage"])
})
test("spec 2.0.0: bash_output, bash_list and bash_kill are job_output, job_list and job_stop, in calls and in permission rules", () => {
expect(resolveCall("bash_output", { id: "job1" })).toEqual({ name: "job_output", raw: { id: "job1" } })
expect(resolveCall("bash_list", {}).name).toBe("job_list")
expect(resolveCall("bash_kill", { id: "job2" }).name).toBe("job_stop")
expect(canonicalToolNames(["bash", "bash_output"])).toEqual(["bash", "job_output"])
expect(renamePermissionKeys({ bash_output: "deny", bash_kill: "ask", job_list: "allow" })).toEqual({ job_output: "deny", job_stop: "ask", job_list: "allow" })
})
test("a todo call is a plan_write of one phase called Work, its statuses translated", () => {
const r = resolveCall("todo", { todos: [{ content: "Read the code", status: "completed" }, { content: "Fix it", status: "in_progress" }, "Test it", { content: "Old idea", status: "cancelled" }] })
expect(r.name).toBe("plan_write")
+1 -1
View File
@@ -32,7 +32,7 @@ test("every tool that can ask takes a purpose; bash says it in description; tool
for (const name of ["edit", "write", "read", "web_fetch", "skill_manage", "task"]) expect(spec(name).properties.purpose?.description).toContain("what this call is for")
expect(spec("bash").properties.purpose).toBeUndefined()
expect(spec("bash").properties.description?.description).toContain("what this command is for")
for (const name of ["ask_user", "plan_write", "plan_update", "plan_submit", "bash_output", "bash_list", "notes_search", "note_view", "note_manage"]) expect(spec(name).properties.purpose).toBeUndefined()
for (const name of ["ask_user", "plan_write", "plan_update", "plan_submit", "job_output", "job_list", "job_stop", "job_send", "report", "notes_search", "note_view", "note_manage"]) expect(spec(name).properties.purpose).toBeUndefined()
})
test("split: taken out of the arguments; a server's own `purpose` stays its own", () => {
+196 -4
View File
@@ -3,13 +3,39 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import { Bus, type AskReply, type Event } from "../src/bus/index.ts"
import { Bus, type Asker, type AskReply, type Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { createView } from "../src/tui/state.ts"
import { MARKS } from "../src/jobs/note.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
let other: Fake | undefined
afterEach(() => (fake?.stop(), other?.stop(), (other = undefined)))
/** Two scripted servers: the parent's model on `f`, the child's on `g` (task's `model: "g/m"`) —
* a shared script would hand a reply to whichever engine asked first. */
function pair(parent: Parameters<typeof fakeProvider>[0], child: Parameters<typeof fakeProvider>[0], o: { oneAtATime?: boolean; config?: string; asker?: Asker } = {}) {
fake = fakeProvider(parent)
other = fakeProvider(child)
mkdirSync(paths.config, { recursive: true })
writeFileSync(
join(paths.config, "connections.yaml"),
`connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: {} }\n g:\n dialect: openai-chat\n base_url: ${other.url}\n${o.oneAtATime ? " one_model_at_a_time: true\n" : ""} models: { m: {}, n: {} }\n`,
{ mode: 0o600 },
)
writeFileSync(join(paths.config, "config.yaml"), `model: f/m\n${o.config ?? ""}`)
const cwd = mkdtempSync(join(tmpdir(), "ph-sub-"))
writeFileSync(join(cwd, "calc.py"), "def average(v):\n return sum(v) / (len(v) - 1)\n")
const events: Event[] = []
const app = createApp({ cwd, mode: "edit", store: false, snapshots: false, asker: o.asker ?? { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
app.bus.on((e) => events.push(e))
return { app, events, cwd }
}
const settled = async (a: ReturnType<typeof createApp>, n: number) => {
const until = Date.now() + 8000
while (Date.now() < until && (fake!.requests.length < n || a.turns.current)) await Bun.sleep(20)
}
function app(script: Parameters<typeof fakeProvider>[0]) {
fake = fakeProvider(script)
@@ -40,7 +66,14 @@ describe("subagents", () => {
expect(child.messages[0].content).toContain('You are the "explore" subagent')
expect(child.messages[0].content).not.toContain("Permission mode: plan")
expect(child.messages.at(-1).content).toBe("Find why average is wrong in calc.py")
expect(names(child)).toEqual(["read", "glob", "grep", "list", "bash", "web_search", "web_fetch"])
// its own job tools for commands it starts, and report for a word to the main agent
expect(names(child)).toEqual(["read", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "web_search", "web_fetch", "report"])
// the preamble is the spec's text; a subagent is told nothing of plan_write or ask_user
expect(child.messages[0].content).toContain("Your final reply is your report to the main agent")
expect(child.messages[0].content).toContain("the report tool sends the main agent a message")
expect(child.messages[0].content).not.toContain("plan_write")
expect(child.messages[0].content).not.toContain("ask_user")
expect(child.messages[0].content).not.toContain("## Skills")
// the parent gets the report, not the reading
const result = fake!.requests[3].messages.at(-1).content as string
expect(result).toStartWith("calc.py:2 divides by len(v) - 1")
@@ -60,7 +93,7 @@ describe("subagents", () => {
])
await b.engine.prompt("review")
const child = fake!.requests.find((r: any) => r.messages[0].content.includes('"reviewer" subagent'))
expect(names(child)).toEqual(["read", "grep"])
expect(names(child)).toEqual(["read", "grep", "report"])
expect(child.messages[0].content).toContain("Review for bugs only.")
expect(child.messages[0].content).toContain("you cannot change anything")
const results = fake!.requests.at(-1).messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
@@ -152,3 +185,162 @@ test("a worktree subagent cannot touch the main checkout, even unrestricted", as
expect(denied).toMatch(/outside the project|denied/)
expect(git(root, ["branch", "--list", "lembas/*"]).out).toBe("")
})
describe("background subagents (spec 2.0.0)", () => {
test("task background: true returns at once; the report arrives as a marked note, a turn of its own", async () => {
const { app: a, events } = pair(
[
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find the bug", prompt: "Find why average is wrong", model: "g/m", background: true }))] },
{ chunks: [delta({ content: "Started it; carrying on." })] },
{ chunks: [delta({ content: "Got the report." })] },
],
[{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }],
)
await a.turns.prompt("look into it in the background")
const started = fake!.requests[1].messages.at(-1).content as string
expect(started).toStartWith("Started subagent explore as job1 (find the bug).")
await settled(a, 3)
const note = fake!.requests[2].messages.at(-1).content as string
expect(note).toStartWith(MARKS.subagent)
expect(note).toContain("job1 (explore: find the bug) finished after")
expect(note).toContain("calc.py:2 divides by len(v) - 1.")
expect(note).toContain("[explore subagent · 2 steps · 1 tool call]")
expect(events.find((e) => e.type === "sub_tool")).toMatchObject({ type: "sub_tool", callId: "t1", jobId: "job1", name: "read" })
expect(events.filter((e) => e.type === "job" && e.state !== "update").map((e) => e.type === "job" && `${e.job.kind} ${e.state}`)).toEqual(["subagent start", "subagent end"])
const job = a.jobs()[0]!
expect(job).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", model: "g/m", steps: 2, tools: 1 })
// Its transcript, for job_output and a panel: the tool line and what it wrote.
expect(job.output).toContain("[read]")
expect(job.output).toContain("calc.py:2 divides")
expect(job.subagent?.engine.messages.at(-1)).toMatchObject({ role: "assistant" })
})
test("job_send: to a running subagent at its next step under the to_subagent mark; to a finished one as a new prompt under the same id", async () => {
const { app: a, events } = pair(
[
// general, not explore: in plan mode `sleep` is refused at once, and the child would be at its next step before the message.
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "wait and look", prompt: "Wait, then look", model: "g/m", agent: "general", background: true }))] },
{ chunks: [toolCall(0, "s1", "job_send", JSON.stringify({ id: "job1", text: "Also check X" }))] },
{ chunks: [delta({ content: "Told it." })] },
{ chunks: [toolCall(0, "s2", "job_send", JSON.stringify({ id: "job1", text: "And now Y" }))] },
{ chunks: [delta({ content: "Asked for more." })] },
{ chunks: [delta({ content: "All done." })] },
],
[
{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "sleep 1", description: "wait" }))] },
{ chunks: [delta({ content: "First report." })] },
{ chunks: [delta({ content: "Second report." })] },
],
)
await a.turns.prompt("go")
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
await settled(a, 6)
const child = other!.requests
const steered = child[1].messages.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.to_subagent))
expect(steered?.content).toContain("Also check X")
expect(child[2].messages.at(-1).content).toBe("And now Y")
expect(fake!.requests[3].messages.at(-1).content).toContain("First report.")
expect(fake!.requests[4].messages.at(-1).content).toContain("is running again with your message")
expect(fake!.requests[5].messages.at(-1).content).toContain("Second report.")
expect(events.filter((e) => e.type === "job" && e.state !== "update").map((e) => e.type === "job" && e.state)).toEqual(["start", "end", "start", "end"])
expect(a.jobs()).toHaveLength(1)
})
test("report: a subagent's message reaches the main agent at its next step, marked; the final reply still comes", async () => {
const { app: a, events } = pair(
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }],
[{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }],
)
await a.turns.prompt("go")
const msgs = fake!.requests[1].messages
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?")
// The note goes in after the step's tool results, as a steer does.
expect(msgs.at(-2)).toMatchObject({ role: "tool" })
expect(msgs.at(-2).content).toContain("Final: the YAML one.")
expect(other!.requests[1].messages.at(-1).content).toContain("Sent to the main agent")
expect(events.find((e) => e.type === "job_note")).toMatchObject({ type: "job_note", jobId: "job1" })
// The main agent itself has no report tool.
expect(names(fake!.requests[0])).not.toContain("report")
})
test("an approval from a subagent says which one asked (Asker source)", async () => {
const seen: unknown[] = []
const asker: Asker = {
ask: async (req) => {
seen.push(req.source)
return { kind: "once" }
},
}
const { app: a } = pair(
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "run it", prompt: "Run ls", model: "g/m", agent: "general" }))] }, { chunks: [delta({ content: "ok" })] }],
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
{ asker },
)
await a.turns.prompt("go")
expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }])
})
test("capacity: a second subagent is refused beside one already running on a one-model-at-a-time server", async () => {
const { app: a } = pair(
[
{
chunks: [
toolCall(0, "t1", "task", JSON.stringify({ description: "first", prompt: "wait", model: "g/m", background: true })),
toolCall(1, "t2", "task", JSON.stringify({ description: "second", prompt: "wait too", model: "g/n", background: true })),
],
},
{ chunks: [delta({ content: "ok" })] },
{ chunks: [delta({ content: "done" })] },
],
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
{ oneAtATime: true },
)
await a.turns.prompt("go")
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
expect(results[0]).toStartWith("Started subagent explore as job1")
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
expect(other!.requests.map((r: any) => r.model)).toEqual(["m"])
await settled(a, 3)
})
test("a broken agent file is said at start, and the agent is still offered with its body", () => {
mkdirSync(join(paths.config, "agents"), { recursive: true })
writeFileSync(join(paths.config, "agents", "broken.md"), "---\ndescription: [unclosed\nmode: plan\n---\nDo the thing.\n")
const { app: a } = pair([], [])
expect(a.loaded.warnings.some((w) => w.includes("broken.md") && w.includes("frontmatter not read"))).toBe(true)
const broken = a.agents.find((x) => x.name === "broken")!
expect(broken.instructions).toBe("Do the thing.")
expect(broken.mode).toBeUndefined()
})
})
test("a cancelled worktree subagent keeps its branch, and the result names it", async () => {
const { git } = await import("../src/git/run.ts")
const { app: a, events } = pair(
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "fix average", prompt: "Fix calc.py", agent: "general", worktree: true, model: "g/m" }))] }],
[
{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] },
{ chunks: [toolCall(0, "w1", "write", JSON.stringify({ path: "calc.py", content: "def average(v):\n return sum(v) / len(v)\n" }))] },
{ chunks: [delta({ content: "slow" })], gapMs: 3000 },
],
)
const root = a.project.root
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args)
;(a.project as any).gitRoot = root
const turn = a.turns.prompt("fix it in a worktree")
const until = Date.now() + 8000
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
const write = events.find((e) => e.type === "sub_tool" && e.name === "write")
expect(write && write.type === "sub_tool" ? write.title : "").not.toMatch(/denied|refused|outside|not read/i)
await Bun.sleep(100)
a.cancel()
expect(await turn).toBe("cancelled")
const result = a.engine.messages.find((m) => m.role === "tool")!
expect(result.role === "tool" && result.content).toContain("ended: cancelled")
const branch = /branch (lembas\/general-fix-average-[a-z0-9]+)/.exec(result.role === "tool" ? result.content : "")?.[1]
expect(branch).toBeTruthy()
expect(git(root, ["log", "-1", "--format=%s", branch!]).out).toBe("general: fix average (stopped)")
expect(git(root, ["show", `${branch}:calc.py`]).out).toContain("sum(v) / len(v)")
expect(a.jobs()[0]).toMatchObject({ kind: "subagent", state: "stopped" })
})
+31 -9
View File
@@ -3,7 +3,9 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { applyPatchTool } from "../src/tool/apply_patch.ts"
import { bashKillTool, bashOutputTool, bashTool } from "../src/tool/bash.ts"
import { bashTool } from "../src/tool/bash.ts"
import { jobOutputTool, jobStopTool } from "../src/tool/job_tools.ts"
import { JobRegistry } from "../src/jobs/registry.ts"
import { editTool } from "../src/tool/edit.ts"
import { multieditTool } from "../src/tool/multiedit.ts"
import { formatAnswers, questionSchema, unattendedReply, type QuestionRequest } from "../src/tool/ask_user.ts"
@@ -188,19 +190,39 @@ describe("file tools", () => {
})
describe("background bash", () => {
test("start, read new output, see it finish; kill a long one", async () => {
const c = ctx()
const started = await bashTool.run({ command: "echo one; sleep 0.5; echo two", background: true }, c)
test("start, read new output, see it finish; stop a long one", async () => {
const registry = new JobRegistry()
const c = { ...ctx(), jobs: { registry, owner: () => ({ sessionId: "s1" }) } }
const started = await bashTool.run({ command: "echo one; sleep 0.3; echo two; sleep 0.5; echo three", background: true }, c)
const id = /as (job\d+)/.exec(started.output)![1]!
expect(started.output).toContain("one")
const later = await bashOutputTool.run({ id, wait: 3 }, c)
expect(started.output).toContain("job_output")
const later = await jobOutputTool.run({ id, wait: 3 }, c)
expect(later.output).toContain("two")
await Bun.sleep(200)
expect((await bashOutputTool.run({ id }, c)).output).toContain("exited with 0")
expect(later.output).toContain("still running")
await Bun.sleep(1200)
// Its end note took what was left (nobody held the session, so the note waits on the job), and
// job_output does not repeat it.
const job = registry.get("s1", id)!
expect(job.state).toBe("done")
expect(job.note).toContain("three")
expect(job.note).toStartWith("[A background job you started has finished:]")
expect((await jobOutputTool.run({ id }, c)).output).toBe("(no new output)\n\n[job1 exited with 0]")
const long = await bashTool.run({ command: "sleep 30", background: true }, c)
const id2 = /as (job\d+)/.exec(long.output)![1]!
expect((await bashKillTool.run({ id: id2 }, c)).output).toContain("Stopped")
expect((await bashOutputTool.run({ id: id2, wait: 3 }, c)).output).toMatch(/exited with (null|143|-?\d+)/)
expect((await jobStopTool.run({ id: id2 }, c)).output).toContain("Stopped")
// A signal's end never reads "null".
const after = (await jobOutputTool.run({ id: id2, wait: 3 }, c)).output
expect(after).toContain("stopped (SIGTERM)")
expect(after).not.toContain("null")
})
test("without a session to own jobs, background is refused and a timeout kills", async () => {
const c = ctx()
await expect(bashTool.run({ command: "sleep 1", background: true }, c)).rejects.toThrow("not available")
const r = await bashTool.run({ command: "sleep 5", timeout: 200 }, c)
expect(r.output).toContain("timed out after 0s")
expect(r.isError).toBe(true)
})
})
+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)
})