LLeMbas CLI 1.0.0
ci / check (push) Waiting to run

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+713
View File
@@ -0,0 +1,713 @@
// `_lembas` protocol 2 (harness/acp.md): @ file search, the command set and /commands over
// ACP, images and files in a prompt, ask_user and plan_submit answered from the web, the model
// event, and turn ids. A fake LLeMbas is the client; the model is a scripted endpoint.
import { afterEach, expect, test } from "bun:test"
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { AcpAgent, planReplyFrom, questionParams, questionReplyFrom, type Limits } from "../src/acp/agent.ts"
import { formatAnswers } from "../src/tool/question.ts"
import { Hub } from "../src/acp/hub.ts"
import { BLOCK_LIMIT, readBlocks } from "../src/acp/prompt.ts"
import { Peer, type Transport } from "../src/acp/rpc.ts"
import { Share, type LocalAsk } from "../src/acp/share.ts"
import { pruneTurnFiles, turnFile, TurnLog } from "../src/acp/turns.ts"
import { createApp, type App } from "../src/app.ts"
import { paths } from "../src/config/paths.ts"
import { setTrust } from "../src/project/root.ts"
import type { QuestionReply } from "../src/tool/question.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
const stops: (() => void)[] = []
afterEach(() => {
fake?.stop()
for (const s of stops.splice(0).reverse()) s()
})
function pair(): [Transport, Transport] {
const make = () => ({ msg: (_: string) => {}, end: () => {} })
const a = make()
const b = make()
const side = (me: typeof a, other: typeof a): Transport => ({
send: (t) => queueMicrotask(() => other.msg(t)),
onMessage: (fn) => (me.msg = fn),
onClose: (fn) => (me.end = fn),
close: () => (me.end(), other.end()),
})
return [side(a, b), side(b, a)]
}
function config(url: string, extra = "", model = "{}") {
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${url}\n models: { m: ${model} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: f/m\n${extra}`)
}
function project(files: Record<string, string> = {}): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "ph-acp2-")))
Bun.spawnSync(["git", "init", "-q", dir])
for (const [f, text] of Object.entries(files)) {
mkdirSync(join(dir, f, ".."), { recursive: true })
writeFileSync(join(dir, f), text)
}
setTrust(dir, "trusted")
return dir
}
const until = async (ok: () => boolean, what: string) => {
for (let i = 0; i < 300 && !ok(); i++) await Bun.sleep(10)
if (!ok()) throw new Error(`timed out waiting for ${what}`)
}
const limitsFor = (root: string, extra: Partial<Limits> = {}): Limits => ({ roots: [root], maxMode: "auto", approvalTimeoutMs: 3000, requireTrust: true, ...extra })
/** A fake LLeMbas on one side of the agent. `lembas: false`: an editor that is not LLeMbas. */
/** `protocol`: what the client says it speaks (2 by default; 1 is an older client). */
async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; instance?: string; hub?: Hub; handlers?: Record<string, (p: any) => unknown> } = {}) {
const [ta, tb] = pair()
new AcpAgent(new Peer(ta), limits, o.instance, o.hub)
const c = new Peer(tb)
const updates: any[] = []
const events: any[] = []
const notes: { method: string; params: any }[] = []
c.on("session/update", (p) => void updates.push(p))
c.on("_lembas/event", (p) => void events.push(p))
c.on("_lembas/permission/settled", (p) => void notes.push({ method: "settled", params: p }))
c.handle("session/request_permission", () => ({ outcome: { outcome: "selected", optionId: "once" } }))
for (const [m, fn] of Object.entries(o.handlers ?? {})) c.handle(m, fn)
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: o.lembas === false ? {} : { _meta: { lembas: o.protocol === 1 ? true : { protocol: o.protocol ?? 2 } } } })
return { c, updates, events, notes, init }
}
// §0
test("initialize: protocol 2, the new capabilities, images taken, the login's connection named", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project()
const { init } = await client(limitsFor(root, { terminal: true }), { instance: "example" })
expect(init._meta.lembas.protocol).toBe(2)
for (const cap of ["files", "commands", "attachments", "ask", "plan", "model", "turns", "terminal", "shell"]) expect(init._meta.lembas.capabilities).toContain(cap)
expect(init.agentCapabilities.promptCapabilities).toEqual({ image: true, audio: false, embeddedContext: true })
expect(init._meta.lembas.connection).toBe("example")
})
// §1
test("_lembas/files/search: the TUI's index, by session or by directory, gitignored files left out", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project({ "src/router.ts": "x", "src/view.ts": "y", "README.md": "z", "build/out.js": "w", ".gitignore": "build/\n" })
const { c } = await client(limitsFor(root))
const byDir: any = await c.request("_lembas/files/search", { cwd: root, query: "rout" })
expect(byDir.root).toBe(root)
expect(byDir.files[0]).toEqual({ path: "src/router.ts", kind: "file" })
const dirs: any = await c.request("_lembas/files/search", { cwd: root, query: "src" })
expect(dirs.files).toContainEqual({ path: "src", kind: "dir" })
const ignored: any = await c.request("_lembas/files/search", { cwd: root, query: "out.js" })
expect(ignored.files.map((f: any) => f.path)).not.toContain("build/out.js")
const s: any = await c.request("session/new", { cwd: root })
const bySession: any = await c.request("_lembas/files/search", { sessionId: s.sessionId, query: "view", limit: 1 })
expect(bySession.files).toEqual([{ path: "src/view.ts", kind: "file" }])
// Outside the device's roots: refused, as session/new is.
await expect(c.request("_lembas/files/search", { cwd: tmpdir(), query: "x" })).rejects.toThrow("outside")
await expect(c.request("_lembas/files/search", { query: "x" })).rejects.toThrow("cwd")
})
// §2
test("the command set: sent after session/new, by request for a directory; TUI-only commands never", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project({ ".agent/config.yaml": "", ".agent/commands/deploy.md": "---\ndescription: ship it\n---\nDeploy $ARGUMENTS now.", ".agent/commands/theme.md": "a custom one named like a built-in" })
// A global skill, removed afterwards: the suite shares one home, and other files have skills of their own.
const skillDir = join(paths.config, "skills", "acp-sort-imports")
mkdirSync(skillDir, { recursive: true })
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: acp-sort-imports\ndescription: sort the imports\n---\nSort them.")
stops.push(() => rmSync(skillDir, { recursive: true, force: true }))
const { c, updates } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
await until(() => updates.some((u) => u.update.sessionUpdate === "available_commands_update"), "the command set")
const sent = updates.find((u) => u.update.sessionUpdate === "available_commands_update")
expect(sent.sessionId).toBe(s.sessionId)
const names = sent.update.availableCommands.map((x: any) => x.name)
for (const n of ["compact", "plan", "undo", "review", "changelog", "init", "continue", "deploy", "acp-sort-imports"]) expect(names).toContain(n)
for (const n of ["theme", "settings", "sessions", "quit", "login"]) expect(names).not.toContain(n)
expect(sent.update.availableCommands.find((x: any) => x.name === "deploy")).toEqual({ name: "deploy", description: "ship it", input: { hint: "arguments" }, _meta: { lembas: { kind: "custom" } } })
expect(sent.update.availableCommands.find((x: any) => x.name === "acp-sort-imports")._meta.lembas.kind).toBe("skill")
const asked: any = await c.request("_lembas/commands", { cwd: root })
expect(asked.commands.map((x: any) => x.name)).toEqual(names)
})
test("/commands over ACP: a custom command expanded as the TUI would, an unknown one sent as text, /compact done", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "deploying" })] }, { chunks: [delta({ content: "plain" })] }, { chunks: [delta({ content: "the summary" })] }])
config(fake.url)
const root = project({ ".agent/config.yaml": "", ".agent/commands/deploy.md": "Deploy $ARGUMENTS now." })
const { c, events } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
const r: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "/deploy staging" }] })
expect(r._meta.lembas.command).toBe("deploy")
expect(JSON.stringify(fake.requests[0].messages)).toContain("Deploy staging now.")
// What the transcript shows is what was typed, not the body.
expect(events.find((e) => e.event.type === "prompt").event.text).toBe("/deploy staging")
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "/etc/hosts is odd" }] })
expect(JSON.stringify(fake.requests[1].messages)).toContain("/etc/hosts is odd")
const compacted: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "/compact" }] })
expect(compacted._meta.lembas).toMatchObject({ command: "compact", summary: "the summary" })
// A review with nothing to review is said, not sent.
const review: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "/review" }] })
expect(review._meta.lembas.message).toContain("nothing to review")
expect(fake.requests).toHaveLength(3)
})
test("@path in an ACP prompt is attached by the TUI's expander", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "seen" })] }])
config(fake.url)
const root = project({ "notes.txt": "the secret word is heron\n" })
const { c, events } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "read @notes.txt please" }] })
expect(JSON.stringify(fake.requests[0].messages)).toContain("1: the secret word is heron")
expect(events.find((e) => e.event.type === "prompt").event.attachments).toEqual([{ name: "notes.txt", mimeType: "text/plain", size: 25 }])
})
// §3
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64")
test("an image goes to a vision model as an image, and is a chip in the event and the history", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "a pixel" })] }])
config(fake.url, "", "{ vision: true }")
const root = project()
const { c, events } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "what is this?" }, { type: "image", mimeType: "image/png", data: PNG.toString("base64"), uri: "file:///x/shot.png" }] })
const sent = JSON.stringify(fake.requests[0].messages)
expect(sent).toContain("image_url")
expect(sent).toContain("data:image/png;base64,")
const chips = events.find((e) => e.event.type === "prompt").event.attachments
expect(chips).toEqual([{ name: "shot.png", mimeType: "image/png", size: PNG.length }])
const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId })
expect(h.turns[0]).toEqual({ role: "user", text: "what is this?", turnId: chipsEvent(events).turnId, attachments: [{ name: "shot.png", mimeType: "image/png", size: PNG.length }] })
expect(existsSync(join(paths.state, "attachments", s.sessionId))).toBe(true)
// Deleting the session takes its attachments with it.
await c.request("_lembas/session/delete", { sessionId: s.sessionId })
expect(existsSync(join(paths.state, "attachments", s.sessionId))).toBe(false)
})
test("without vision the image is saved and named, a text file attached, a binary one referenced", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
config(fake.url)
const root = project()
const { c } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", {
sessionId: s.sessionId,
prompt: [
{ type: "image", mimeType: "image/png", data: PNG.toString("base64") },
{ type: "resource", resource: { uri: "file:///home/u/todo.md", mimeType: "text/markdown", text: "- buy bread" } },
{ type: "resource", resource: { uri: "file:///home/u/blob.bin", mimeType: "application/octet-stream", blob: Buffer.from([0, 1, 2, 0]).toString("base64") } },
],
})
const sent = JSON.stringify(fake.requests[0].messages)
expect(sent).toContain("not attached: this model has no vision")
expect(sent).toContain("image-1.png")
expect(sent).toContain("1: - buy bread")
expect(sent).toContain("binary file, 4 bytes")
expect(sent).not.toContain("image_url")
})
test("limits: 10 MB a block, 25 MB a prompt, said in a sentence", () => {
const big = Buffer.alloc(BLOCK_LIMIT + 10).toString("base64")
expect(() => readBlocks([{ type: "image", mimeType: "image/png", data: big }])).toThrow("larger than 10 MB")
const nine = Buffer.alloc(9 * 1024 * 1024).toString("base64")
expect(() => readBlocks([1, 2, 3].map(() => ({ type: "resource", resource: { uri: "a.bin", blob: nine } })))).toThrow("more than 25 MB")
expect(() => readBlocks([{ type: "image", mimeType: "image/bmp", data: "AA==" }])).toThrow("PNG, JPEG, WebP or GIF")
})
const chipsEvent = (events: any[]) => events.find((e) => e.event.type === "prompt").event
test("history: each user turn carries its turn id — the web UI's, or one made for a terminal's turn", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "web" })] }, { chunks: [delta({ content: "typed" })] }])
const root = project()
config(fake.url)
const { c, heard } = await linkedHub(limitsFor(root), {})
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "from the web" }], _meta: { lembas: { turnId: "W7" } } })
// The same session carried on in a terminal: its typed turn has an id of its own.
const { releaseForResume } = await import("../src/acp/share.ts")
const { app, share } = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }))
expect(await releaseForResume(s.sessionId)).toBeUndefined()
app.resume(s.sessionId)
await until(() => share.sharedId === s.sessionId, "shared")
await app.turns.prompt("typed here")
const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId })
const users = h.turns.filter((t: any) => t.role === "user")
expect(users[0]).toMatchObject({ text: "from the web", turnId: "W7" })
expect(users[1].turnId).toMatch(/^[0-9a-f-]{36}$/)
expect(heard.length).toBeGreaterThan(0)
})
// §4
const ASK = JSON.stringify({ questions: [{ header: "DB", question: "Which database?", options: [{ label: "SQLite", recommended: true }, { label: "Postgres" }] }] })
test("ask_user in a session held here is asked of LLeMbas, and its answer reaches the model", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "q1", "ask_user", ASK)] }, { chunks: [delta({ content: "Postgres it is" })] }])
config(fake.url)
const root = project()
let asked: any
const { c } = await client(limitsFor(root), {
handlers: {
"_lembas/question": (p) => {
asked = p
return { outcome: "answered", answers: [{ kind: "options", labels: ["Postgres"] }] }
},
},
})
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "set it up" }] })
expect(asked).toMatchObject({ sessionId: s.sessionId, toolCallId: "q1", questions: [{ header: "DB", question: "Which database?" }] })
const result = fake.requests[1].messages.find((m: any) => m.role === "tool")
expect(result.content).toContain("→ Postgres")
})
test("plan_submit is asked of LLeMbas; approving moves the session to the mode chosen", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "p1", "plan_submit", JSON.stringify({ path: ".agent/plans/p.md" }))] }, { chunks: [delta({ content: "implementing" })] }])
config(fake.url, "mode: plan\n")
const root = project({ ".agent/plans/p.md": "# The plan\n1. do it\n" })
let asked: any
const { c, events } = await client(limitsFor(root, { maxMode: "edit" }), {
handlers: {
"_lembas/plan": (p) => {
asked = p
return { outcome: "approve", mode: "edit" }
},
},
})
const s: any = await c.request("session/new", { cwd: root, _meta: { mode: "plan" } })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "plan it" }] })
expect(asked).toMatchObject({ toolCallId: "p1", path: ".agent/plans/p.md", text: "# The plan\n1. do it\n" })
expect(events.some((e) => e.event.type === "mode" && e.event.mode === "edit")).toBe(true)
expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("approved the plan")
})
test("a client that is not LLeMbas gets no question card: ask_user says nobody can be asked", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "q1", "ask_user", ASK)] }, { chunks: [delta({ content: "assumed" })] }])
config(fake.url)
const root = project()
let asked = 0
const { c } = await client(undefined, { lembas: false, handlers: { "_lembas/question": () => (asked++, {}) } })
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }] })
expect(asked).toBe(0)
expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("Nobody can be asked")
})
test("a question nobody answers in time is dismissed, and the card taken down", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "q1", "ask_user", ASK)] }, { chunks: [delta({ content: "assumed" })] }])
config(fake.url)
const root = project()
const { c, notes } = await client(limitsFor(root, { approvalTimeoutMs: 100 }), { handlers: { "_lembas/question": () => new Promise(() => {}) } })
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }] })
expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("dismissed")
expect(notes).toContainEqual({ method: "settled", params: { sessionId: s.sessionId, toolCallId: "q1" } })
})
test("the answers read leniently and safely", () => {
const req = { questions: [{ header: "a", question: "a?", options: [] }] }
expect(questionReplyFrom({ outcome: "answered", answers: [{ kind: "custom", text: "mine" }] }, req)).toEqual({ answers: [{ kind: "custom", text: "mine" }] })
expect(questionReplyFrom({ outcome: "answered", answers: [{ kind: "weird" }] }, req)).toEqual({ dismissed: true })
expect(questionReplyFrom({ outcome: "dismissed" }, req)).toEqual({ dismissed: true })
// A question left blank skips that one only; the answers beside it are kept.
const three = { questions: ["a", "b", "c"].map((h) => ({ header: h, question: `${h}?`, options: [] })) }
for (const blank of [{ kind: "skipped" }, null]) {
const r: any = questionReplyFrom({ outcome: "answered", answers: [{ kind: "custom", text: "x" }, blank, { kind: "options", labels: ["y"] }] }, three)
expect(r.dismissed).toBeUndefined()
expect(r.answers[0]).toEqual({ kind: "custom", text: "x" })
expect(r.answers[1]).toBeUndefined()
expect(r.answers[2]).toEqual({ kind: "options", labels: ["y"] })
expect(formatAnswers(three, r)).toContain("→ (no answer)")
}
expect(planReplyFrom({ outcome: "approve", mode: "edit" }, "manual")).toEqual({ kind: "approve", mode: "manual" })
expect(planReplyFrom({ outcome: "approve", mode: "edit" }, "auto")).toEqual({ kind: "approve", mode: "edit" })
expect(planReplyFrom({ outcome: "approve", mode: "manual" }, "plan")).toEqual({ kind: "dismissed" })
expect(planReplyFrom({ outcome: "revise", feedback: " smaller " })).toEqual({ kind: "revise", feedback: "smaller" })
})
// §4 through the hub: a terminal's session, first answer wins.
async function linkedHub(limits: Limits, handlers: Record<string, (p: any) => unknown>, instance?: string) {
const hub = new Hub({ service: true, limits: { ...limits, enabled: true } })
expect(await hub.listen()).toBe(true)
stops.push(() => hub.close())
const r = await client(limits, { hub, handlers, instance })
const heard: { method: string; params: any }[] = []
r.c.on("_lembas/session/announce", (p) => void heard.push({ method: "announce", params: p }))
return { ...r, hub, heard }
}
async function terminal(root: string, question: (req: any) => LocalAsk<QuestionReply>, seen: { mode?: string }[] = [], cwd = root) {
let share: Share | undefined
const app: App = createApp({
cwd,
modelTitles: false,
asker: {
ask: async () => ({ kind: "once" }),
question: (req, callId) => (share ? share.question(req, callId, question(req)) : question(req).reply),
},
})
share = new Share(app, {
prompt: (r, started) => (started(), seen.push({ mode: r.mode }), app.turns.prompt(r.prompt, r.extra, r.shown, { turnId: r.turnId, attachments: r.attachments })),
compact: () => app.engine.compact(),
deleted: () => app.newSession(),
notice: () => {},
changed: () => {},
})
const s = share
stops.push(() => s.close())
await s.start()
return { app, share: s }
}
test("a terminal's ask_user: asked in the web UI too; its answer takes the terminal's card down", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "q9", "ask_user", ASK)] }, { chunks: [delta({ content: "done" })] }])
const root = project()
config(fake.url)
let asked: any
const { heard } = await linkedHub(limitsFor(root), {
"_lembas/question": async (p) => {
asked = p
return { outcome: "answered", answers: [{ kind: "options", labels: ["SQLite"] }] }
},
})
let dismissed: QuestionReply | undefined
const { app } = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: (r) => void (dismissed = r) }))
await until(() => heard.length > 0, "the announcement")
await app.turns.prompt("ask me")
expect(asked).toMatchObject({ toolCallId: "q9", _meta: { lembas: { patient: true } } })
expect(dismissed).toEqual({ answers: [{ kind: "options", labels: ["SQLite"] }] })
expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("SQLite")
})
test("answered at the terminal first: the web UI's card is settled", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "q8", "ask_user", ASK)] }, { chunks: [delta({ content: "done" })] }])
const root = project()
config(fake.url)
const { heard, notes } = await linkedHub(limitsFor(root), { "_lembas/question": () => new Promise(() => {}) })
const { app } = await terminal(root, () => ({ reply: Bun.sleep(30).then(() => ({ answers: [{ kind: "custom" as const, text: "typed here" }] })), dismiss: () => {} }))
await until(() => heard.length > 0, "the announcement")
await app.turns.prompt("ask me")
await until(() => notes.length > 0, "the settled notification")
expect(notes[0]!.params.toolCallId).toBe("q8")
})
// §6
test("the model event: said whenever the model or effort changes, forwarded to LLeMbas", async () => {
fake = fakeProvider([])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n example:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { a: { efforts: [low, high] }, b: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: example/a\n")
const root = project()
const { c, events } = await client(limitsFor(root), { instance: "example" })
const s: any = await c.request("session/new", { cwd: root })
await c.request("_lembas/configure", { sessionId: s.sessionId, model: "b" })
await c.request("_lembas/configure", { sessionId: s.sessionId, model: "a", effort: "high" })
await until(() => events.filter((e) => e.event.type === "model").length >= 2, "two model events")
const models = events.filter((e) => e.event.type === "model").map((e) => e.event)
// `instance`: the model is spoken to through the connection this link's instance is.
expect(models[0]).toEqual({ type: "model", ref: "example/b", effort: "off", connection: "example", instance: true })
expect(models.at(-1)).toEqual({ type: "model", ref: "example/a", effort: "high", connection: "example", instance: true })
})
// §7
test("turn ids: a prompt sent twice runs once, and both answers are the original's", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "slow " }), delta({ content: "reply" })], gapMs: 100 }, { chunks: [delta({ content: "second" })] }])
config(fake.url)
const root = project()
const { c, events } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
const prompt = (text: string, turnId: string) => c.request<any>("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text }], _meta: { lembas: { turnId } } })
const one = prompt("hello", "T1")
await Bun.sleep(20)
const again = prompt("hello", "T1")
const two = prompt("next", "T2")
await Bun.sleep(20)
const status: any = await c.request("_lembas/session/status", { sessionId: s.sessionId })
expect(status).toMatchObject({ busy: true, turnId: "T1", queued: ["T2"] })
const [a, b] = await Promise.all([one, again])
expect(a).toEqual(b)
expect(a._meta.lembas.turnId).toBe("T1")
await two
// Finished: answered at once, nothing sent to the model.
expect(await prompt("hello", "T1")).toEqual(a)
expect(fake.requests).toHaveLength(2)
const after: any = await c.request("_lembas/session/status", { sessionId: s.sessionId })
expect(after).toMatchObject({ busy: false, queued: [], lastTurnId: "T2" })
expect(after.turnId).toBeUndefined()
// Every turn's events carry its id.
expect(events.filter((e) => e.event.type === "prompt").map((e) => e.event.turnId)).toEqual(["T1", "T2"])
expect(events.filter((e) => e.event.type === "task").map((e) => `${e.event.state}:${e.event.turnId}`)).toEqual(["start:T1", "end:T1", "start:T2", "end:T2"])
})
test("TurnLog keeps 200 ids, never forgetting one that has not finished", async () => {
const log = new TurnLog<number>()
let release!: () => void
const blocked = log.run("first", (started) => (started(), new Promise<number>((r) => (release = () => r(1)))))
for (let i = 0; i < 250; i++) await log.run(`t${i}`, async (started) => (started(), i))
expect(log.has("first")).toBe(true)
expect(log.has("t0")).toBe(false)
expect(log.has("t249")).toBe(true)
release()
expect(await blocked).toBe(1)
expect(log.state("first")).toBe("done")
})
test("a turn typed in a terminal has an id of its own, on its events and in the status", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "typed" })] }])
const root = project()
config(fake.url)
const { heard, events, c, updates } = await linkedHub(limitsFor(root), {})
const { app } = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }))
await until(() => heard.length > 0, "the announcement")
// Its command set reaches the web UI too, from the terminal through the hub.
await until(() => updates.some((u) => u.sessionId === app.engine.sessionId && u.update.sessionUpdate === "available_commands_update"), "the terminal's command set")
await app.turns.prompt("from the keyboard")
const id = app.engine.sessionId!
await until(() => events.some((e) => e.event.type === "task" && e.event.state === "end"), "the end of the turn")
const start = events.find((e) => e.event.type === "task" && e.event.state === "start").event
expect(start.turnId).toMatch(/^[0-9a-f-]{36}$/)
const status: any = await c.request("_lembas/session/status", { sessionId: id })
expect(status).toMatchObject({ held: "terminal", busy: false, lastTurnId: start.turnId, queued: [] })
})
test("the shared command set agrees with the TUI: every name it reserves, every description it shows", async () => {
const { COMMANDS } = await import("../src/tui/commands.ts")
const { SHARED_BUILTINS, TUI_COMMAND_NAMES } = await import("../src/session/commands.ts")
const names = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])])
expect([...TUI_COMMAND_NAMES].sort()).toEqual(names.sort() as typeof TUI_COMMAND_NAMES[number][])
for (const b of SHARED_BUILTINS) expect(COMMANDS.find((c) => c.name === b.name)?.description).toBe(b.description)
})
test("turn ids outlive a restart of the service: a finished turn is not run again by a new link", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "once" })] }, { chunks: [delta({ content: "never" })] }])
config(fake.url)
const root = project()
const first = await client(limitsFor(root))
const s: any = await first.c.request("session/new", { cwd: root })
const a: any = await first.c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }], _meta: { lembas: { turnId: "R1" } } })
// A new agent: nothing in memory, the session opened from the store.
const again = await client(limitsFor(root))
const status: any = await again.c.request("_lembas/session/status", { sessionId: s.sessionId })
expect(status).toMatchObject({ exists: true, held: "none", lastTurnId: "R1" })
const b: any = await again.c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }], _meta: { lembas: { turnId: "R1" } } })
expect(b).toEqual(a)
expect(fake.requests).toHaveLength(1)
const after: any = await again.c.request("_lembas/session/status", { sessionId: s.sessionId })
expect(after.lastTurnId).toBe("R1")
})
// Audit fixes.
test("a protocol-1 LLeMbas (2.1.0) gets no question card; a client that lacks the method falls back", async () => {
const ASK1 = JSON.stringify({ questions: [{ header: "DB", question: "Which?", options: [{ label: "A" }, { label: "B" }] }] })
fake = fakeProvider([{ chunks: [toolCall(0, "q1", "ask_user", ASK1)] }, { chunks: [delta({ content: "x" })] }, { chunks: [toolCall(0, "q2", "ask_user", ASK1)] }, { chunks: [delta({ content: "y" })] }])
config(fake.url)
const root = project()
let asked = 0
const old = await client(limitsFor(root), { protocol: 1, handlers: { "_lembas/question": () => (asked++, {}) } })
const s: any = await old.c.request("session/new", { cwd: root })
await old.c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }] })
expect(asked).toBe(0)
expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("Nobody can be asked")
// Says 2, has no such method: method-not-found is nobody to ask, not a dismissal.
const liar = await client(limitsFor(root))
const t: any = await liar.c.request("session/new", { cwd: root })
await liar.c.request("session/prompt", { sessionId: t.sessionId, prompt: [{ type: "text", text: "go" }] })
expect(fake.requests[3].messages.find((m: any) => m.role === "tool").content).toContain("Nobody can be asked")
})
test("an @path from the web is attached only inside the session's project and the device's roots", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
config(fake.url)
const root = project({ "inside.txt": "INSIDE-OK\n" })
const outside = realpathSync(mkdtempSync(join(tmpdir(), "ph-outside-")))
writeFileSync(join(outside, "secret.txt"), "TOP-SECRET-OUTSIDE\n")
// A link inside the project to the file outside is outside.
Bun.spawnSync(["ln", "-s", join(outside, "secret.txt"), join(root, "link.txt")])
const { c, events } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
const rel = join("..", outside.split("/").pop()!, "secret.txt")
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: `look @${outside}/secret.txt @${rel} @link.txt @inside.txt` }] })
const sent = JSON.stringify(fake.requests[0].messages)
expect(sent).not.toContain("TOP-SECRET-OUTSIDE")
expect(sent).toContain("1: INSIDE-OK")
// Left as text, and not counted as read.
expect(sent).toContain(`@${outside}/secret.txt`)
expect(events.find((e) => e.event.type === "prompt").event.attachments.map((a: any) => a.name)).toEqual(["inside.txt"])
})
test("two uploads of one name in one prompt are two files", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
config(fake.url)
const root = project()
const { c } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", {
sessionId: s.sessionId,
prompt: [
{ type: "text", text: "compare" },
{ type: "resource", resource: { uri: "file:///a/notes.txt", text: "FIRST-FILE" } },
{ type: "resource", resource: { uri: "file:///b/notes.txt", text: "SECOND-FILE" } },
],
})
const sent = JSON.stringify(fake.requests[0].messages)
expect(sent).toContain("FIRST-FILE")
expect(sent).toContain("SECOND-FILE")
const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId })
expect(h.turns[0].attachments.map((a: any) => a.name)).toEqual(["notes.txt", "notes.txt"])
})
test("one turn log for the terminal and the service: a turn is not run twice across a handover, either way", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "on the service" })] }, { chunks: [delta({ content: "in the terminal" })] }, { chunks: [delta({ content: "never" })] }])
const root = project()
config(fake.url)
const { c, hub, heard } = await linkedHub(limitsFor(root), {})
// Service first: the web UI's turn S1 runs in the service.
const s: any = await c.request("session/new", { cwd: root })
const a: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "one" }], _meta: { lembas: { turnId: "S1" } } })
// A terminal takes it over; S1 again is the service's answer, not a second run.
const { releaseForResume } = await import("../src/acp/share.ts")
const { app, share } = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }))
expect(await releaseForResume(s.sessionId)).toBeUndefined()
app.resume(s.sessionId)
await until(() => share.sharedId === s.sessionId, "shared")
expect(await c.request<any>("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "one" }], _meta: { lembas: { turnId: "S1" } } })).toEqual(a)
expect((await c.request<any>("_lembas/session/status", { sessionId: s.sessionId })).lastTurnId).toBe("S1")
// T1 runs in the terminal; the terminal goes; T1 again is not run by the service.
const b: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "two" }], _meta: { lembas: { turnId: "T1" } } })
share.close()
await until(() => !hub.shared.has(s.sessionId), "the terminal leaving")
expect((await c.request<any>("_lembas/session/status", { sessionId: s.sessionId })).lastTurnId).toBe("T1")
expect(await c.request<any>("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "two" }], _meta: { lembas: { turnId: "T1" } } })).toEqual(b)
expect(fake.requests).toHaveLength(2)
expect(heard.length).toBeGreaterThan(0)
})
test("turn files: a damaged one is empty, writes are whole, old ones go", () => {
const dir = join(paths.state, "turns-test")
mkdirSync(dir, { recursive: true })
const f = join(dir, "s.json")
for (const bad of ["[null]", "{", "[[1,2],[\"\",{}]]", "\"x\""]) {
writeFileSync(f, bad)
expect(() => new TurnLog(turnFile(f))).not.toThrow()
expect(new TurnLog(turnFile(f)).last).toBeUndefined()
}
writeFileSync(f, JSON.stringify([["ok", { stopReason: "end_turn" }], null]))
expect(new TurnLog(turnFile(f)).has("ok")).toBe(true)
// Merged with what the other process wrote, newest last, at most 200.
const store = turnFile<number>(f)
store.save(Array.from({ length: 250 }, (_, i) => [`t${i}`, i] as [string, number]))
const kept = store.load()
expect(kept).toHaveLength(200)
expect(kept.at(-1)).toEqual(["t249", 249])
expect(readdirSync(dir).filter((n) => n.endsWith(".tmp"))).toEqual([])
// A file not written for longer than the limit goes.
const old = join(dir, "old.json")
writeFileSync(old, "[]")
utimesSync(old, new Date(0), new Date(0))
pruneTurnFiles(dir, 30)
expect(existsSync(old)).toBe(false)
expect(existsSync(f)).toBe(true)
rmSync(dir, { recursive: true, force: true })
})
test("a command's mode from the web is clamped to remote.max_mode in a terminal shared under the limits", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
const root = project()
config(fake.url, "remote:\n max_mode: manual\n")
const cmdDir = join(paths.config, "commands")
mkdirSync(cmdDir, { recursive: true })
writeFileSync(join(cmdDir, "loose.md"), "---\nmode: auto\n---\nDo it all.")
stops.push(() => rmSync(join(cmdDir, "loose.md"), { force: true }))
const { c, heard } = await linkedHub(limitsFor(root), {})
const seen: { mode?: string }[] = []
const { app } = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }), seen)
await until(() => heard.length > 0, "the announcement")
await c.request("session/prompt", { sessionId: app.engine.sessionId, prompt: [{ type: "text", text: "/loose" }] })
expect(seen).toEqual([{ mode: "manual" }])
})
test("a command naming a model gives the session its model and its effort back afterwards", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: { efforts: [low, high], effort: low }, other: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const root = project({ ".agent/config.yaml": "", ".agent/commands/quick.md": "---\nmodel: f/other\n---\nQuick." })
const { c } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root, _meta: { effort: "high" } })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "/quick" }] })
expect(fake.requests[0].model).toBe("other")
expect(await c.request<any>("_lembas/configure", { sessionId: s.sessionId })).toEqual({ model: "f/m", effort: "high" })
})
test("a terminal started in a subdirectory: files/search answers from where @ mentions are read", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "ok" })] }])
const root = project({ "pkg/src/deep.ts": "export const deep = 1\n", "top.ts": "x" })
// The device's roots: a web prompt's @path is read only inside them.
config(fake.url, `remote:\n enabled: true\n roots: [${root}]\n`)
const { c, heard } = await linkedHub(limitsFor(root), {})
const { app } = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }), [], join(root, "pkg"))
await until(() => heard.length > 0, "the announcement")
// The project is the repository's root; the terminal works in pkg/.
expect(heard[0]!.params.cwd).toBe(root)
const r: any = await c.request("_lembas/files/search", { sessionId: app.engine.sessionId, query: "deep" })
expect(r.root).toBe(join(root, "pkg"))
expect(r.files[0]).toEqual({ path: "src/deep.ts", kind: "file" })
// And that path, sent back as @path, is the file.
await c.request("session/prompt", { sessionId: app.engine.sessionId, prompt: [{ type: "text", text: `see @${r.files[0].path}` }] })
expect(JSON.stringify(fake.requests[0].messages)).toContain("1: export const deep = 1")
})
test("@ mentions: a space and a # in a name are escaped with a backslash; a bare # is a line range", async () => {
const { attachmentsFor, escapeMention } = await import("../src/project/attach.ts")
const root = project({ "my notes.md": "spaced\n", "a#b.md": "hashed\n", "c.md": "one\ntwo\nthree\n" })
const ctx = { root, cwd: root, readFiles: new Set<string>(), fileStamps: new Map<string, number>() }
expect(escapeMention("my notes.md")).toBe("my\\ notes.md")
expect(escapeMention("a#b.md")).toBe("a\\#b.md")
const got = attachmentsFor(`@${escapeMention("my notes.md")} @${escapeMention("a#b.md")} @c.md#2-3`, ctx)
expect(got.map((a) => a.path)).toEqual(["my notes.md", "a#b.md", "c.md"])
expect(got[0]!.text).toContain("1: spaced")
expect(got[1]!.text).toContain("1: hashed")
expect(got[2]!.text).toContain("2: two\n3: three")
})
test("option labels go to the card at most 240 characters long; the model gets the whole one chosen", () => {
const long = "x".repeat(300)
const req = { questions: [{ header: "h", question: "q?", options: [{ label: long }, { label: "short" }] }] }
const p = questionParams("s", req, "c")
expect(p.questions[0]!.options[0]!.label).toHaveLength(240)
expect(req.questions[0]!.options[0]!.label).toHaveLength(300)
expect(questionReplyFrom({ outcome: "answered", answers: [{ kind: "options", labels: [long.slice(0, 240)] }] }, req)).toEqual({ answers: [{ kind: "options", labels: [long] }] })
})
test("a terminal's session is announced with whether its model is this link's instance's", async () => {
fake = fakeProvider([])
const root = project()
config(fake.url)
// The link's instance is the connection the session's model is on (`f`), then one it is not.
const one = await linkedHub(limitsFor(root), {}, "f")
const t = await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }))
await until(() => one.heard.length > 0, "the announcement")
expect(one.heard[0]!.params).toMatchObject({ model: "f/m", instance: true })
t.share.close()
for (const s of stops.splice(0).reverse()) s()
const two = await linkedHub(limitsFor(root), {}, "example")
await terminal(root, () => ({ reply: new Promise(() => {}), dismiss: () => {} }))
await until(() => two.heard.length > 0, "the announcement")
expect(two.heard[0]!.params).toMatchObject({ model: "f/m", instance: false })
})