Files
LLeMbas-CLI/tests/prefix.test.ts
T
HomerandClaude Opus 5.5 6ec95fbd66
ci / check (push) Waiting to run
Teams in the CLI (harness spec 3.2.0, team.md)
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>
2026-11-09 09:08:52 +00:00

235 lines
13 KiB
TypeScript

// The prefix (harness spec 3.1.0, context.md §10): the system prompt and the tool list are made once
// per session and resent byte for byte; what changes during it goes as a note on the next user
// message; compaction asks for its summary as the next turn of the same conversation; every
// request's cached input tokens are kept. These drive the real engine against a scripted server.
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 { TOOL_SPECS } from "../src/harness.ts"
import { Store } from "../src/session/store.ts"
import { isNote, NOTE, noteFor, PREFIX_META, summaryOf, toldAt, type Kept } from "../src/session/prefix.ts"
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
const once = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
function setup(script: Parameters<typeof fakeProvider>[0], extra = "") {
fake = fakeProvider(script)
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: { context: 32768 }\n fake/n: { context: 32768 }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\ntitles: prompt\n${extra}`)
const dir = mkdtempSync(join(tmpdir(), "lembas-prefix-"))
Bun.spawnSync(["git", "init", "-q", dir])
writeFileSync(join(dir, "AGENTS.md"), "# Demo\nBuild with make.\n")
return dir
}
const reply = (text: string) => ({ chunks: [delta({ content: text }, "stop")] })
const system = (i: number) => fake!.requests.at(i)!.messages[0].content as string
const lastUser = (i: number) => {
const m = fake!.requests.at(i)!.messages
return [...m].reverse().find((x: { role: string }) => x.role === "user").content as string
}
const names = (i: number) => (fake!.requests.at(i)!.tools as { function: { name: string } }[]).map((t) => t.function.name)
test("no clock in the prompt: two requests across a date change send the same system bytes, and the date goes as a note", async () => {
const dir = setup([reply("one"), reply("two"), reply("three")])
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
let day = "2026-11-04"
app.engine.o.today = () => day
await app.engine.prompt("first")
await app.engine.prompt("second")
day = "2026-11-05"
await app.engine.prompt("third")
expect(system(0)).not.toContain("Today")
expect(system(0)).not.toContain("2026-11-0")
expect(system(1)).toBe(system(0))
expect(system(2)).toBe(system(0))
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
// 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.
expect(fake!.requests[2].messages[1].content).toBe(lastUser(0))
})
test("a tool step carries no note; the note waits for the next user message", async () => {
const dir = setup([{ chunks: [toolCall(0, "c1", "read", '{"path":"AGENTS.md"}')] }, reply("read it"), reply("ok")])
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
let day = "2026-11-04"
app.engine.o.today = () => day
app.bus.on((e) => {
// The clock passes midnight while the tool runs.
if (e.type === "tool_start") day = "2026-11-05"
})
await app.engine.prompt("look")
expect(fake!.requests[1].messages.at(-1).role).toBe("tool")
expect(system(1)).toBe(system(0))
await app.engine.prompt("and now")
expect(lastUser(2)).toBe(`and now\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
})
test("the person's changes come as one note in the spec's order; the model's own do not", async () => {
const dir = setup([
{ chunks: [toolCall(0, "w1", "plan_write", JSON.stringify({ title: "Fix", phases: [{ title: "P", tasks: ["a"] }] }))] },
{ chunks: [toolCall(0, "e1", "edit", JSON.stringify({ path: "AGENTS.md", old: "Build with make.", new: "Build with make; test with make test." }))] },
reply("done"),
reply("ok"),
reply("fine"),
])
const app = createApp({ cwd: dir, asker: once, mode: "edit", store: false, snapshots: false })
app.engine.o.today = () => "2026-11-05"
// The model reads AGENTS.md before it may edit it: read-before-edit is the tool's rule.
app.engine.o.toolCtx.readFiles.add(join(dir, "AGENTS.md"))
await app.engine.prompt("plan and fix")
// Its own plan and its own edit: their results told it.
await app.engine.prompt("thanks")
expect(lastUser(3)).toBe("thanks")
// Between turns the person changes the mode, the plan, an instruction file, and the personality.
app.engine.mode = "manual"
app.engine.setPlan(null)
writeFileSync(join(dir, "AGENTS.md"), "# Demo\nBuild with ninja.\n")
app.settings.set("personality", "concise", "session")
await app.engine.prompt("again")
const note = lastUser(4).slice("again\n".length)
expect(isNote(note)).toBe(true)
const order = ["The permission mode is now manual:\nPermission mode: manual.", "The person changed the plan; it is now:\n(none)", `${join(dir, "AGENTS.md")} changed; it now reads:\n# Demo\nBuild with ninja.`, "The person chose a different personality:\nBe brief."]
for (const line of order) expect(note).toContain(line)
expect(order.map((l) => note.indexOf(l))).toEqual([...order.map((l) => note.indexOf(l))].sort((a, b) => a - b))
expect(system(4)).toBe(system(0))
})
test("plan_submit is in the list in every mode, and answers its unavailable text outside plan mode", async () => {
const dir = setup([{ chunks: [toolCall(0, "x1", "plan_submit", "{}")] }, reply("carrying on")])
const app = createApp({ cwd: dir, asker: once, mode: "manual", store: false, snapshots: false })
await app.engine.prompt("submit it")
expect(names(0)).toContain("plan_submit")
expect(fake!.requests[1].messages.at(-1).content).toBe(`[#0001]\n${TOOL_SPECS.plan_submit!.unavailable}`)
// A terminal session can never show a file: show is not in its list at all.
expect(names(0)).not.toContain("show")
})
test("compaction: the same system and tools, every message, then the compaction text; a call in the reply is not run, and the request is no turn", async () => {
const dir = setup([
reply("The answer is 42."),
{ chunks: [delta({ content: "## What we are doing\nFinding the answer." }), toolCall(0, "c1", "bash", '{"command":"rm -rf x","description":"x"}'), usage(900, 40)] },
reply("ok"),
])
const store = new Store(":memory:")
const app = createApp({ cwd: dir, asker: { ask: async () => { throw new Error("nothing may ask") } }, store, snapshots: false })
await app.engine.prompt("what is the answer")
const sid = app.engine.sessionId!
const summary = await app.engine.compact()
const [first, compaction] = [fake!.requests[0], fake!.requests[1]]
expect(compaction.messages[0]).toEqual(first.messages[0])
expect(compaction.tools).toEqual(first.tools)
expect(compaction.messages.slice(1, -1).map((m: { content: string }) => m.content)).toEqual([first.messages[1].content, "The answer is 42."])
expect(compaction.messages.at(-1).content).toStartWith("Summarise the conversation")
expect(compaction.messages.at(-1).content).not.toContain("User: what is the answer")
// The text before the call is the summary; the call was not run.
expect(summary).toStartWith("## What we are doing\nFinding the answer.")
expect(store.messages(sid).map((m) => (m.role === "tool" ? "tool" : m.parts.map((p) => (p.type === "text" ? p.text : p.type)).join("|")))).not.toContainEqual(expect.stringContaining("Summarise"))
expect(store.messages(sid).some((m) => m.role === "tool")).toBe(false)
// Its usage is kept, as the compaction's.
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")
// 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 () => {
const dir = setup([reply("one"), reply("two")])
const store = new Store(":memory:")
const a = createApp({ cwd: dir, asker: once, store, snapshots: false })
await a.engine.prompt("first")
const sid = a.engine.sessionId!
const kept = store.meta<Kept>(sid, PREFIX_META)!
expect(kept.system).toBe(system(0))
await a.close()
writeFileSync(join(dir, "AGENTS.md"), "# Demo\nBuild with ninja.\n")
const b = createApp({ cwd: dir, asker: once, store, snapshots: false, resuming: sid })
b.resume(sid)
await b.engine.prompt("second")
expect(system(1)).toBe(system(0))
expect(system(1)).toContain("Build with make.")
expect(lastUser(1)).toContain(`${join(dir, "AGENTS.md")} changed; it now reads:\n# Demo\nBuild with ninja.`)
// Shown as typed: the note is not the person's words.
expect(b.engine.messages.filter((m) => m.role === "user").length).toBe(2)
await b.close()
})
test("a model switch makes the prompt again for the new model; the date already said holds", async () => {
const dir = setup([reply("one"), reply("two")])
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
await app.engine.prompt("first")
app.switchModel("fake/n")
await app.engine.prompt("second")
expect(system(0)).toContain("Model: fake/m")
expect(system(1)).toContain("Model: fake/n")
expect(lastUser(1)).toBe("second")
})
test("cached input tokens are kept with every request's usage and shown by /context", async () => {
const dir = setup([
{ chunks: [delta({ content: "one" }), { choices: [], usage: { prompt_tokens: 1000, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 0 } } }] },
// llama.cpp says it in its timings when the usage does not.
{ chunks: [delta({ content: "two" }), usage(1100, 5), { choices: [], timings: { cache_n: 1000, prompt_n: 100, predicted_n: 5 } }] },
])
const store = new Store(":memory:")
const app = createApp({ cwd: dir, asker: once, store, snapshots: false })
await app.engine.prompt("first")
await app.engine.prompt("second")
expect(store.requests(app.engine.sessionId!).map((r) => [r.kind, r.input, r.cached])).toEqual([
["step", 1000, 0],
["step", 1100, 1000],
])
const { contextBreakdown, renderBreakdown } = await import("../src/session/context.ts")
expect(renderBreakdown(contextBreakdown(app.engine, app.engine.systemPrompt(), [], undefined, app.engine.o.tools))).toContain("last request: 1.1k in, 1.0k of it cached (91%)")
await app.close()
})
test("noteFor: nothing changed says nothing; an MCP server that connected later is said by its own instructions", () => {
const now = { date: "2026-11-05", mode: "manual" as const, plan: "", memory: "", personality: "", files: {}, mcp: { docs: "Search the docs." } }
const told = { ...toldAt(now), date: "2026-11-05" }
expect(noteFor(told, now, () => "").note).toBe("")
const later = { ...now, mcp: { ...now.mcp, tickets: "Name the ticket." } }
expect(noteFor(told, later, () => "").note).toBe(`${NOTE.open}\nInstructions from an MCP server that connected since the session began:\n<server name="tickets">\nName the ticket.\n</server>\n${NOTE.close}`)
expect(summaryOf([{ type: "reasoning", text: "x" }, { type: "text", text: " S " }, { type: "tool_call" }, { type: "text", text: "after" }])).toBe("S")
})
test("the first request waits for the configured MCP servers (up to loop.json's mcp_wait_ms): their tools and instructions are in the first list and prompt", async () => {
const BUN = process.execPath
const SERVER = join(import.meta.dir, "fixtures", "mcp", "server.ts")
const dir = setup([reply("one")], `mcp:\n fix:\n command: ["${BUN}", "${SERVER}"]\n`)
const app = createApp({ cwd: dir, asker: once, store: false, snapshots: false })
// Not waiting for app.mcpReady here: the engine does, before it makes the prompt.
await app.engine.prompt("first")
expect(names(0)).toContain("mcp__fix__echo")
expect(system(0)).toContain("<mcp_instructions>")
await app.close()
writeFileSync(join(paths.config, "config.yaml"), "")
})
test("an empty value in a note reads loop.json's context.note.empty", () => {
const now = { date: "2026-11-05", mode: "manual" as const, plan: "", memory: "", personality: "", files: {}, mcp: {} }
const told = { ...toldAt({ ...now, memory: "Prefers tabs." }), date: "2026-11-05" }
expect(noteFor(told, now, () => "").note).toBe(`${NOTE.open}\nThe person changed what is remembered; it is now:\n${NOTE.empty}\n${NOTE.close}`)
})
test("compaction's ask has no previous summary and no transcript heading", async () => {
const { compactAsk } = await import("../src/session/prefix.ts")
const ask = compactAsk()
expect(ask).not.toContain("## Transcript")
expect(ask).not.toContain("Summary of even earlier turns")
expect(ask).not.toMatch(/\n{3,}/)
expect(ask).toEndWith("stay under 600 words.")
})