Teams in the CLI (harness spec 3.2.0, team.md)
ci / check (push) Waiting to run

The four team tools (tool/team_tools.ts) on the rules of team/team.ts,
driven from real turns by team/runtime.ts:

- Members run on the subagent job machinery (app.ts spawn with `member`):
  background, unattended, their own prompt from blocks/team_member.md
  (the whole of team_lead.md for one with lead: true), team_send and
  team_read (team_add and team_stop for one that leads). Not jobs to the
  model: job_list and the job_* tools skip them; a client over the link
  still sees them as subagent jobs named "<name>: <role>".
- The room: messages and reports with their marks, delivered at the next
  step boundary or waking a finished member or the idle lead; report
  posts to the manager.
- A manager's work calls answer the refusal before the permission check,
  by risk class (an MCP tool by its readOnlyHint); the tool list is
  unchanged. job_stop and reading bash stay.
- Slots: the lead waits for one before its turn (Esc stops the wait);
  members wait in order; task helpers in a team count toward the size
  and hold a slot; job_send restarting a helper is refused when full.
- The offer is decided with the prompt and kept with it: a resumed
  session takes it from its kept tool list.
- The size: agents.min/max settings, refused outside 1 <= min <= max <=
  cap, kept across /new and resume; said in a note on the first message,
  after a change, after a compaction and a rewind.
- TUI: /team (tree and room, size, stop a member, dialogs for both), an
  "agents" row beside the effort in /effort, "team n/max" in the status
  bar. /team works over ACP too.

tests/team.test.ts runs a lead, a member and its report through the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 09:08:52 +00:00
1 parent fbefe7f844
commit 6ec95fbd66
21 files changed
+834 -45

No files matched your search

+1 -1
View File
@@ -54,7 +54,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", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
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", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
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
+4 -2
View File
@@ -16,7 +16,9 @@ export interface Fake {
/** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two
* placeholders). Any other GET — the instance's own description, its personalization — is a 404,
* which a client reads as "this server does not have it". */
export function fakeProvider(script: Scripted[], opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
/** `script` may be a function of the request (and its number), for requests that race: a team's
* agents ask at once, and each is answered by who is asking. */
export function fakeProvider(script: Scripted[] | ((body: any, n: number) => Scripted | undefined), opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
const requests: any[] = []
const calls: { path: string; headers: Record<string, string> }[] = []
let i = 0
@@ -34,7 +36,7 @@ export function fakeProvider(script: Scripted[], opts: { route?: (model: string)
if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
requests.push(body)
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) })
const r = script[i++]
const r = typeof script === "function" ? script(body, i++) : script[i++]
if (!r) return new Response("script exhausted", { status: 500 })
if ("status" in r) return new Response(r.body, { status: r.status })
// gapMs: a pause between chunks, to look at the screen mid-stream.
+4 -2
View File
@@ -52,7 +52,8 @@ test("no clock in the prompt: two requests across a date change send the same sy
expect(system(1)).toBe(system(0))
expect(system(2)).toBe(system(0))
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n${NOTE.close}`)
// A model with no capacity said (a hosted API) offers a team: its size is said once, last (spec 3.2.0).
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n\nThe team may have 1 to 4 agents, you included.\n${NOTE.close}`)
expect(lastUser(1)).toBe("second")
expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
// A note is part of its message from then on: the next request sends the first one as it was.
@@ -140,7 +141,8 @@ test("compaction: the same system and tools, every message, then the compaction
expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"])
// After it the prompt is made again, and the next user message says the date again.
await app.engine.prompt("go on")
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n<\/session-note>$/)
// The team's size too (spec 3.2.0): like the date, it is not in the prompt.
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n\nThe team may have 1 to 4 agents, you included\.\n<\/session-note>$/)
})
test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => {
+148
View File
@@ -0,0 +1,148 @@
// Teams (harness spec 3.2.0, team.md) through the whole app: the lead adds a member that runs on the
// subagent machinery, its tools that change things answer the manager's refusal before anything is
// asked, the member's final reply comes back as a marked report that wakes the idle lead, and the
// size setting is said in a note. The rules themselves are conformance/team.json's.
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp, type App } from "../src/app.ts"
import type { AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { LOOP } from "../src/harness.ts"
import { teamLabel, teamText } from "../src/session/commands.ts"
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
let fake: Fake | undefined
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
fake?.stop()
})
const T = LOOP.team
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
const system = (body: any): string => body.messages[0].content
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
const lastUser = (body: any): string => {
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
}
// A result as the model reads it, less its id line (context.md §1).
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
function setup(answer: (body: any, n: number) => Scripted | undefined, config = "", spec = "{ context: 32768, parallel: 2 }") {
fake = fakeProvider(answer)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\n${config}`)
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-team-")))
Bun.spawnSync(["git", "init", "-q", dir])
return dir
}
async function until(what: () => boolean, ms = 10000) {
const end = Date.now() + ms
while (!what()) {
if (Date.now() > end) throw new Error("timed out")
await Bun.sleep(20)
}
}
describe("a team at work (team.md)", () => {
test("the lead adds a member, is refused work, and is woken by the member's report", async () => {
let lead = 0
const dir = setup((body) => {
if (isMember(body, "parser")) return say("The parser is done: src/parse.ts.")
const n = lead++
if (n === 0) return call("a1", "team_add", { name: "parser", role: "the parser", task: "Write the parser; report the file.", goal: "a calculator" })
if (n === 1) return call("w1", "write", { path: "x.txt", content: "x" })
return say(n === 2 ? "parser is on it." : "All done.")
})
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("build a calculator")
// The lead's report arrives — at a step boundary, or as a turn of its own when it was idle.
await until(() => fake!.requests.some((r) => !isMember(r, "parser") && r.messages.some((m: any) => m.role === "user" && JSON.stringify(m.content).includes("[Team report from parser to lead:]"))))
await until(() => !app!.engine.busy && app!.team.team.get("lead")!.state === "idle")
const first = fake!.requests[0]
expect(first.tools.map((t: any) => t.function.name)).toEqual(expect.arrayContaining(["team_add", "team_send", "team_read", "team_stop"]))
expect(system(first)).toContain("team_add")
// The size is said once, in the note on the first message.
expect(lastUser(first)).toContain(LOOP.context.note.team.replace("{{min}}", "1").replace("{{max}}", "4"))
const leads = fake!.requests.filter((r) => !isMember(r, "parser"))
const results = toolResults(leads[leads.length - 1])
expect(results[0]).toContain("Added parser (the parser)")
expect(results[1]).toContain(T.texts.manager)
// The member: its own prompt, its brief as its first message, and the team tools a member has.
const member = fake!.requests.find((r) => isMember(r, "parser"))!
expect(system(member)).toContain("The team's goal: a calculator")
expect(system(member)).toContain('you report to "lead"')
expect(lastUser(member)).toContain("Write the parser; report the file.")
const memberTools = member.tools.map((t: any) => t.function.name)
expect(memberTools).toEqual(expect.arrayContaining(["team_send", "team_read"]))
expect(memberTools).not.toContain("team_add")
expect(memberTools).not.toContain("task")
// The room has the report; the lead manages the finished member; nothing was written.
const t = app.team.team
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
expect(t.get("parser")!.state).toBe("finished")
expect(t.isManager("lead")).toBe(true)
expect(teamLabel(t)).toBe("team 2/4")
expect(teamText(app, "")).toContain("- parser — the parser · finished")
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
// A member is not a job to the model.
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
})
test("max 1: the tools are listed but answer one_agent, and task too; the note says to work alone", async () => {
let n = 0
const dir = setup(() => (n++ === 0 ? call("a1", "team_add", { name: "x", role: "r", task: "t", goal: "g" }) : n === 2 ? call("t1", "task", { description: "d", prompt: "p" }) : say("alone")), "agents: { min: 1, max: 1 }\n")
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("go")
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_one)
const results = toolResults(fake!.requests[2])
expect(results).toEqual([T.unavailable.one_agent, T.unavailable.one_agent])
})
test("min 2: the lead is a manager from the start, and the note asks for members", async () => {
let n = 0
const dir = setup(() => (n++ === 0 ? call("e1", "bash", { command: "touch made.txt" }) : say("ok")), "agents: { min: 2, max: 4 }\n")
app = createApp({ cwd: dir, mode: "auto", asker: yes, store: false })
await app.engine.prompt("go")
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_lead.replace("{{members}}", "1"))
expect(toolResults(fake!.requests[1])).toEqual([T.texts.manager])
expect(await Bun.file(join(dir, "made.txt")).exists()).toBe(false)
})
test("a model that serves one request at a time offers no team: no team tools, /team says why", async () => {
const dir = setup(() => say("ok"), "", "{ context: 32768, parallel: 1 }")
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("go")
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => n.startsWith("team_"))).toEqual([])
expect(lastUser(fake!.requests[0])).not.toContain("The team may have")
expect(teamText(app, "")).toContain("serves one request at a time")
})
test("the size is refused outside its bounds, whole, and a change is said in the next note", async () => {
let n = 0
const dir = setup(() => say(`ok ${n++}`))
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("one")
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32")
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32")
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
expect(teamText(app, "size 2 6")).toContain("2 to 6")
expect(app.settings.get("agents.max").value).toBe(6)
await app.engine.prompt("two")
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
await app.engine.prompt("three")
expect(lastUser(fake!.requests[2])).toBe("three")
})
})