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
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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