From b70cfe483be5008fe0072b6093a445985caa9df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 08:32:03 +0000 Subject: [PATCH] The link (spec 3.4.0): capabilities progress, tree and agents; the team event carries the tree; _lembas/team, /transcript, /findings, /stop; progress and tree in status; subagent on an approval; an agent chat's Min/Max and said read per turn acp.md: a turn without agents keeps the device's own setting (an older server), as before 3.4.0. Co-Authored-By: Claude Opus 5.5 --- harness/acp.md | 4 +- src/acp/agent.ts | 51 ++++++++- src/acp/jobs.ts | 46 +++++++++ src/acp/share.ts | 13 ++- src/app.ts | 2 +- src/bus/index.ts | 2 +- src/session/engine.ts | 6 +- src/tui/state.ts | 2 +- tests/acp.test.ts | 2 +- tests/subagents-v2.test.ts | 207 +++++++++++++++++++++++++++++++++++++ 10 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 tests/subagents-v2.test.ts diff --git a/harness/acp.md b/harness/acp.md index eead803..4b21dc1 100644 --- a/harness/acp.md +++ b/harness/acp.md @@ -271,8 +271,8 @@ subagents for the chat (the chat says so beside the setting). `_lembas/configure` when the person changes it between turns — the chat's setting as the server keeps it (`team.md` §2: 1 ≤ min ≤ max ≤ the instance's cap). The device takes the value as the session's setting for that turn: a change reaches the main agent as `team.md` §10's note, and - nothing else reads the device's own configuration for the chat. Absent (an older server): `{ min: - 1, max: 1 }` — no subagents. A value the device's own global cap is under is read as `team.md` §2 + nothing else reads the device's own configuration for the chat. Absent (an older server, or a + client that is not LLeMbas): the device's own setting, as before 3.4.0. A value the device's own global cap is under is read as `team.md` §2 says (lowered, never refused). - `subagents: false` in the same `_meta.lembas` when the instance has subagents switched off: the device offers none whatever `agents` says. diff --git a/src/acp/agent.ts b/src/acp/agent.ts index 97c52e0..287fd8c 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -42,7 +42,7 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts" import type { Message } from "../provider/types.ts" import type { Hub } from "./hub.ts" import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts" -import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts" +import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts" import { isNote } from "../session/prefix.ts" export const ACP_VERSION = 1 @@ -273,6 +273,12 @@ export const CAPABILITIES = [ // Harness spec 3.0.0: the show tool, offered in a session the client opened when the client // said it can show files; the device sends the file with `_lembas/show`. "show", + // Harness spec 3.4.0: the progress phases (the `phase` event, `progress` in status); the subagent + // tree (the `team` event with the tree, `_lembas/team*`, `subagent` on an approval); the chat's + // Subagents setting read from `_meta.lembas.agents` (an agent chat's subagents are this device's). + "progress", + "tree", + "agents", ] as const export const STOP: Record = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" } @@ -306,6 +312,8 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien ...(o.patient ? { patient: true } : {}), // Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle. ...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}), + // Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow. + ...(req.source?.subagent ? { subagent: req.source.subagent } : {}), }, }, } @@ -468,6 +476,10 @@ export class AcpAgent { peer.handle("_lembas/job/stop", (p) => this.job("stop", p)) peer.handle("_lembas/job/send", (p) => this.job("send", p)) peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p)) + peer.handle("_lembas/team", (p) => this.tree("team", p)) + peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p)) + peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p)) + peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p)) peer.on("_lembas/terminal/input", (p) => this.terminalInput(p)) peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p)) peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? ""))) @@ -675,6 +687,8 @@ export class AcpAgent { const effort = effortOf(p?.effort) // A level the model does not offer is read as its start level; the answer says the one used. if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort) + // Spec 3.4.0: the chat's Subagents setting, changed between turns. + if (p?.agents) this.takeAgents(s, { agents: p.agents }) return { model: s.app.engine.model.ref, effort: s.app.engine.effort } } @@ -724,6 +738,19 @@ export class AcpAgent { private takeContext(s: Session, p: any) { const ctx = contextOf(p?._meta) if (ctx) s.app.setContext(ctx, this.instanceConnection) + this.takeAgents(s, p?._meta?.lembas) + } + + /** Spec 3.4.0 (capability agents): the chat's Subagents setting, sent with every turn by the + * instance — absent (an older one, an editor on serve --stdio): the device's own, as before. + * `said`: the person's earlier words in the chat. */ + private takeAgents(s: Session, m: any) { + if (!this.lembas) return + const team = s.app.team.team + const a = m?.agents + if (Number.isInteger(a?.min) && Number.isInteger(a?.max) && a.min >= 1 && a.max >= 1) team.setSetting(Math.min(a.min, a.max), a.max) + if (m?.subagents === false) team.subagents = false + if (Array.isArray(m?.said) && !s.app.engine.saidBefore.length) s.app.engine.saidBefore = m.said.filter((x: unknown): x is string => typeof x === "string") } /** The session's plan, as a `plan` update — after session/new and after opening from the @@ -1067,6 +1094,8 @@ export class AcpAgent { ...(s.turns.last ? { lastTurnId: s.turns.last } : {}), plan: s.app.engine.plan ?? null, jobs: jobList(s.app), + progress: s.app.engine.progress.current, + tree: treeOf(s.app), } const row = (this.store ??= new Store()).session(id) // Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored. @@ -1091,6 +1120,24 @@ export class AcpAgent { return { jobs: jobList(this.get(p).app) } } + /** `_lembas/team`, `/transcript`, `/findings`, `/stop` (spec 3.4.0, capability tree): the + * session's subagent tree — the terminal's through the hub, as the jobs go. */ + private async tree(op: "team" | "team_transcript" | "team_findings" | "team_stop", p: any) { + const t = this.terminalFor(p) + if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since }, 15_000) + const app = this.get(p).app + switch (op) { + case "team": + return teamView(app, p?.since) + case "team_transcript": + return teamTranscript(app, p?.id) + case "team_findings": + return teamFindings(app, p?.id) + case "team_stop": + return teamStop(app, p?.id) + } + } + /** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */ private async job(op: "output" | "stop" | "send" | "transcript", p: any) { const t = this.terminalFor(p) @@ -1291,7 +1338,7 @@ export class AcpAgent { private forward(s: Session, e: Event) { // A model change says whether the model is this link's instance's: its ref is the // provider's now (`deepseek/x`), so the ref alone no longer tells. - const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e + const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e.type === "team" ? { ...e, tree: treeOf(s.app) } : e if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event }) switch (e.type) { case "text": diff --git a/src/acp/jobs.ts b/src/acp/jobs.ts index 9fff80c..20525aa 100644 --- a/src/acp/jobs.ts +++ b/src/acp/jobs.ts @@ -5,6 +5,7 @@ import type { App } from "../app.ts" import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts" import type { Store } from "../session/store.ts" +import type { TreeInfo } from "../team/team.ts" import { historyTurns } from "./agent.ts" import { INVALID_PARAMS, RpcError } from "./rpc.ts" @@ -79,3 +80,48 @@ export function storedJobs(store: Store, sessionId: string): JobInfo[] { } return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")] } + +// ── Spec 3.4.0 (acp.md capability `tree`): the subagent tree of a session ──────────────────── + +/** The tree (subagents.md §12), or null for a session with no subagents. */ +export function treeOf(app: App): TreeInfo | null { + const t = app.team.team + if (!t.offered && !t.hasMembers() && !t.ended.length) return null + return t.info() +} + +/** `_lembas/team`: the tree and the whole room above `since`. */ +export function teamView(app: App, since: unknown) { + const n = typeof since === "number" && since >= 0 ? since : 0 + const t = app.team.team + return { tree: treeOf(app), room: t.room.filter((m) => m.n > n).map((m) => ({ n: m.n, from: m.from, to: m.to, recipients: m.recipients, text: m.text, time: m.time, report: m.report, kind: m.kind })) } +} + +function subagentOf(app: App, id: unknown) { + const key = String(id ?? "") + const a = app.team.team.find(key) + if (!a || a.id !== key || a.id === "lead") throw new RpcError(INVALID_PARAMS, `this session has no subagent ${key}`) + return a +} + +/** `_lembas/team/transcript`: a subagent's session, read-only. */ +export function teamTranscript(app: App, id: unknown) { + const a = subagentOf(app, id) + const job = a.jobId ? app.registry.get(app.jobsSessionId(), a.jobId) : undefined + const messages = job?.subagent?.engine.messages ?? (job?.childSessionId && app.store ? app.store.messages(job.childSessionId) : []) + return { id: a.id, state: a.state, turns: historyTurns(messages, app.engine.o.toolCtx.cwd) } +} + +/** `_lembas/team/findings`. */ +export function teamFindings(app: App, id: unknown) { + const a = subagentOf(app, id) + return { id: a.id, findings: a.findings.map((f) => ({ ...f })) } +} + +/** `_lembas/team/stop`: the person stops a live subagent and its subtree. */ +export function teamStop(app: App, id: unknown) { + const a = subagentOf(app, id) + if (a.endState) throw new RpcError(INVALID_PARAMS, `${a.id} has already ended`) + const gone = app.team.personStop(a.name) + return { stopped: app.team.team.ended.slice(-gone.length).map((x) => x.id) } +} diff --git a/src/acp/share.ts b/src/acp/share.ts index 942c583..3223f23 100644 --- a/src/acp/share.ts +++ b/src/acp/share.ts @@ -16,7 +16,7 @@ import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts" import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts" import { readLevel } from "../provider/effort.ts" import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts" -import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts" +import { jobList, jobOutput, jobSend, jobStop, jobTranscript, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts" import type { SessionContext } from "../app.ts" import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts" import { dropAttachments, preparePrompt } from "./prompt.ts" @@ -454,7 +454,7 @@ export class Share { return } if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) { - this.hub.notify("hub/event", { sessionId: this.sharedId, event: e }) + this.hub.notify("hub/event", { sessionId: this.sharedId, event: e.type === "team" ? { ...e, tree: treeOf(this.app) } : e }) if (e.type === "task" && e.state === "end") this.sendCommands() } } @@ -520,6 +520,15 @@ export class Share { return jobSend(this.app, p?.id, p?.text) case "transcript": return jobTranscript(this.app, p?.id) + // Spec 3.4.0 (capability tree): the subagent tree, through the hub as the jobs go. + case "team": + return teamView(this.app, p?.since) + case "team_transcript": + return teamTranscript(this.app, p?.id) + case "team_findings": + return teamFindings(this.app, p?.id) + case "team_stop": + return teamStop(this.app, p?.id) default: throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`) } diff --git a/src/app.ts b/src/app.ts index fcdf403..79c25f0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -546,7 +546,7 @@ export function createApp(o: AppOptions): App { let job: Job // The asker is the session's; a child's ask says which subagent it comes from. const askerFor = (): Asker => ({ - ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }), + ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description, ...(member ? { subagent: { id: member.id, name: member.name, position: member.role } } : {}) } }), get question() { return o.asker.question }, diff --git a/src/bus/index.ts b/src/bus/index.ts index d27755f..9eef27d 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -130,7 +130,7 @@ export interface Asker { purpose?: string /** Set when a subagent running as a background job asks, so the card can say which — it may * ask while the session itself is idle. */ - source?: { jobId: string; agent: string; description: string } + source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } } }): Promise /** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's * id, which keys the card on another screen. */ diff --git a/src/session/engine.ts b/src/session/engine.ts index 9e5608a..3acbd14 100644 --- a/src/session/engine.ts +++ b/src/session/engine.ts @@ -647,9 +647,13 @@ export class Engine { * included, as the store keeps them; never a note, a marked message or a machine turn. */ personSaid(): string[] { const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages - return personTexts(all, this.o.steerMark ?? STEER_MARK) + return [...this.saidBefore, ...personTexts(all, this.o.steerMark ?? STEER_MARK)] } + /** Spec 3.4.0 (acp.md, *Subagents in an agent chat*): the person's earlier words in a chat this + * device has not seen, as the client sent them (`said` on session/new). */ + saidBefore: string[] = [] + /** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed. * `meta`: the turn's id and what was attached, said on the `prompt` event. */ async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> { diff --git a/src/tui/state.ts b/src/tui/state.ts index a884ffd..7d85104 100644 --- a/src/tui/state.ts +++ b/src/tui/state.ts @@ -82,7 +82,7 @@ export interface Pending { /** What the model says the call is for. */ purpose?: string /** The subagent (running as a job) that asks, when one does. */ - source?: { jobId: string; agent: string; description: string } + source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } } resolve: (r: AskReply) => void } diff --git a/tests/acp.test.ts b/tests/acp.test.ts index bad9848..05b68ca 100644 --- a/tests/acp.test.ts +++ b/tests/acp.test.ts @@ -268,7 +268,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", "jobs", "show"]) + 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", "show", "progress", "tree", "agents"]) 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 }) diff --git a/tests/subagents-v2.test.ts b/tests/subagents-v2.test.ts new file mode 100644 index 0000000..d5fe65f --- /dev/null +++ b/tests/subagents-v2.test.ts @@ -0,0 +1,207 @@ +// Subagents v2 (harness spec 3.4.0, subagents.md §5–§14; progress.md) through the whole app: a +// subagent's team_finish ends its turn, a narrowed one is refused what only the main agent may +// allow and asks for it, a call nobody in the tree can allow reaches the person naming the +// subagent, a subagent whose context fills gets one final turn and ends, every engine says its +// progress as phase events, and an agent chat's tree goes over the link. The rules themselves are +// conformance/team.json's and delegation.json's. +import { afterEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createApp, type App } from "../src/app.ts" +import type { AskReply, Event } from "../src/bus/index.ts" +import { paths } from "../src/config/paths.ts" +import { LOOP } from "../src/harness.ts" +import { AcpAgent } from "../src/acp/agent.ts" +import { Peer, type Transport } from "../src/acp/rpc.ts" +import { setTrust } from "../src/project/root.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 say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] }) +const call = (id: string, name: string, args: unknown, usage?: { prompt_tokens: number; completion_tokens: number }): Scripted => ({ + chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls"), ...(usage ? [{ choices: [], usage }] : [])], +}) +const system = (body: any): string => body.messages[0].content +const memberName = (body: any): string | undefined => /You are "([a-z0-9-]+)" \(s\d+\), a subagent/.exec(system(body))?.[1] +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("") +} +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) => Scripted | undefined, spec = "{ context: 32768, parallel: 4 }") { + 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") + const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-sub2-"))) + Bun.spawnSync(["git", "init", "-q", dir]) + setTrust(dir, "trusted") + 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) + } +} + +/** The lead's script: the calls in order, then a closing line; each member's by its name. */ +function scripted(lead: Scripted[], members: Record) { + const at: Record = {} + return (body: any): Scripted => { + const who = memberName(body) ?? "lead" + const list = who === "lead" ? lead : (members[who] ?? []) + const n = (at[who] = (at[who] ?? -1) + 1) + return list[n] ?? say(`${who} is done.`) + } +} + +describe("subagents v2 (spec 3.4.0)", () => { + test("team_finish reports to the parent and ends the subagent: nothing runs after that call", async () => { + const dir = setup( + scripted([call("a1", "team_add", { name: "api", role: "the API", task: "Write the API.", goal: "a service" }), say("api is on it.")], { + api: [call("f1", "team_finish", { report: "The API is done: src/api.ts." }), call("x1", "write", { path: "late.txt", content: "x" })], + }), + ) + app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise => ({ kind: "once" }) }, store: false }) + await app.engine.prompt("build it") + await until(() => app!.team.team.ended.length === 1) + await until(() => !app!.engine.busy) + const t = app.team.team + expect(t.ended[0]).toMatchObject({ id: "s1", name: "api", state: "done" }) + expect(t.room.find((m) => m.report)?.text).toBe("The API is done: src/api.ts.") + // Its one request: nothing after the team_finish call. + expect(fake!.requests.filter((r) => memberName(r) === "api")).toHaveLength(1) + expect(await Bun.file(join(dir, "late.txt")).exists()).toBe(false) + }, 20000) + + test("permissions: a narrowed subagent is told to ask the main agent; a call nobody in the tree can allow reaches the person, naming the subagent", async () => { + const asked: any[] = [] + const dir = setup( + scripted( + [ + call("a1", "team_add", { name: "docs", role: "the docs", task: "Write the docs.", goal: "docs", mode: "manual", request: ["write"] }), + call("a2", "team_add", { name: "ops", role: "the release", task: "Push it." }), + say("Both are on it."), + ], + { + docs: [call("w1", "write", { path: "README.md", content: "# Docs\n" }), say("asked")], + ops: [call("b1", "bash", { command: "git push origin main" }), say("pushed or not")], + }, + ), + ) + app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (req): Promise => (asked.push(req), { kind: "deny" }) }, store: false }) + await app.engine.prompt("go") + await until(() => fake!.requests.filter((r) => memberName(r) === "docs").length >= 2 && fake!.requests.filter((r) => memberName(r) === "ops").length >= 2) + const docs = fake!.requests.filter((r) => memberName(r) === "docs") + expect(toolResults(docs[1])[0]).toBe(T.texts.requestable) + // The docs subagent has team_request (it has request options); the ops subagent does not. + expect(docs[0].tools.map((x: any) => x.function.name)).toContain("team_request") + const ops = fake!.requests.filter((r) => memberName(r) === "ops") + expect(ops[0].tools.map((x: any) => x.function.name)).not.toContain("team_request") + expect(ops[0].tools.map((x: any) => x.function.name)).not.toContain("team_add") + // An all-edit tree: the subagent's push reaches the person, not the main agent. + expect(asked.map((r) => [r.tool, r.source?.subagent?.name])).toEqual([["bash", "ops"]]) + expect(asked[0].source.subagent).toEqual({ id: "s2", name: "ops", position: "the release" }) + expect(await Bun.file(join(dir, "README.md")).exists()).toBe(false) + }, 20000) + + test("a subagent whose context fills is paused, gets one final turn, and ends; its parent gets the notice and its last reply", async () => { + const dir = setup( + scripted([call("a1", "team_add", { name: "scan", role: "scans the code", task: "Scan everything.", goal: "a map" }), say("scan is on it.")], { + scan: [ + call("n1", "team_findings", { action: "write", text: "src/ has 12 modules." }, { prompt_tokens: 3700, completion_tokens: 20 }), + say("Saved my findings; the tests are left."), + ], + }), + "{ context: 4096, parallel: 4 }", + ) + app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise => ({ kind: "once" }) }, store: false }) + await app.engine.prompt("map it") + await until(() => app!.team.team.ended.length === 1) + const t = app.team.team + expect(t.ended[0]).toMatchObject({ name: "scan", state: "out_of_context" }) + expect(t.ended[0]!.findings.map((f) => f.text)).toEqual(["src/ has 12 modules."]) + const scans = fake!.requests.filter((r) => memberName(r) === "scan") + // The final turn: its user message is the notice, under its mark. + expect(lastUser(scans[1])).toContain(T.texts.context_full.replace("{{steps}}", String(T.context.final_steps))) + const report = t.room.find((m) => m.report)! + expect(report.text).toBe(`${T.texts.gone.replace("{{name}}", "scan").replace("{{id}}", "s1").replace("{{name}}", "scan")}\n\nSaved my findings; the tests are left.`) + }, 20000) + + test("every engine says its progress: reading the prompt, a tool, writing, and nothing once the turn is done", async () => { + const dir = setup(() => undefined) + let n = 0 + fake!.stop() + fake = fakeProvider(() => (n++ === 0 ? call("l1", "list", {}) : say("Listed."))) + writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: { context: 32768 }\n`, { mode: 0o600 }) + app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise => ({ kind: "once" }) }, store: false }) + const phases: (string | null)[] = [] + app.bus.on((e: Event) => e.type === "phase" && phases.push(e.progress?.phase ?? null)) + await app.engine.prompt("list it") + expect(phases).toContain("reading_prompt") + expect(phases).toContain("running_tool") + expect(phases).toContain("writing") + expect(phases.at(-1)).toBeNull() + expect(app.engine.progress.current).toBeNull() + }, 20000) +}) + +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)] +} + +test("an agent chat's tree over the link: the setting comes down with the turn; the tree, progress, findings and transcript come up", async () => { + const dir = setup( + scripted([call("a1", "team_add", { name: "api", role: "the API", task: "Write the API.", goal: "a service" }), say("api is on it.")], { + api: [call("n1", "team_findings", { action: "write", text: "Login is POST /session." }), say("API done.")], + }), + ) + 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: 3 } } } }) + expect(init._meta.lembas.capabilities).toEqual(expect.arrayContaining(["progress", "tree", "agents"])) + const s: any = await c.request("session/new", { cwd: dir, mcpServers: [], _meta: { lembas: { agents: { min: 1, max: 3 }, said: ["Use the main model only."] } } }) + await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "build it" }], _meta: { lembas: { agents: { min: 1, max: 3 } } } }) + await until(() => events.some((e) => e.type === "team" && e.tree?.agents.some((a: any) => a.name === "api" && a.state === "finished"))) + const view: any = await c.request("_lembas/team", { sessionId: s.sessionId }) + expect(view.tree).toMatchObject({ min: 1, max: 3, size: 2 }) + expect(view.tree.agents.map((a: any) => [a.id, a.name, a.parent])).toEqual([ + ["lead", "lead", null], + ["s1", "api", "lead"], + ]) + expect(view.room.map((m: any) => [m.from, m.kind])).toEqual([["api", "report"]]) + expect(await c.request("_lembas/team/findings", { sessionId: s.sessionId, id: "s1" })).toMatchObject({ id: "s1", findings: [{ n: 1, text: "Login is POST /session." }] }) + const tr: any = await c.request("_lembas/team/transcript", { sessionId: s.sessionId, id: "s1" }) + expect(tr.turns[0]).toMatchObject({ role: "user" }) + expect(JSON.stringify(tr.turns)).toContain("Write the API.") + expect(events.some((e) => e.type === "phase")).toBe(true) + const st: any = await c.request("_lembas/session/status", { sessionId: s.sessionId }) + expect(st.tree.agents).toHaveLength(2) + expect("progress" in st).toBe(true) +}, 20000)