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:
21 files changed
+834
-45
No files matched your search
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user