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>
405 lines
17 KiB
TypeScript
405 lines
17 KiB
TypeScript
// Sessions' loose ends: a delete made with no link up reaches the instance later (the
|
|
// pending list), a session reopens in the directory it was started in, and a resume leaves no
|
|
// empty session behind. A fake LLeMbas is the ACP client of the agent, as in hub.test.ts.
|
|
import { afterEach, beforeEach, expect, test } from "bun:test"
|
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import { AcpAgent, type Limits } from "../src/acp/agent.ts"
|
|
import { Hub } from "../src/acp/hub.ts"
|
|
import { addPendingDeleted, clearPendingDeleted, DELETED_MAX, deletedFile, pendingDeleted } from "../src/acp/pending.ts"
|
|
import { Peer, type Transport } from "../src/acp/rpc.ts"
|
|
import { deleteEverywhere, discardOnExit, Share } from "../src/acp/share.ts"
|
|
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 { setTrust } from "../src/project/root.ts"
|
|
import { CWD_META, REMOTE_META, Store } from "../src/session/store.ts"
|
|
import { delta, fakeProvider, type Fake } from "./fake-provider.ts"
|
|
|
|
let fake: Fake | undefined
|
|
const stops: (() => void)[] = []
|
|
beforeEach(() => rmSync(deletedFile(), { force: true }))
|
|
afterEach(() => {
|
|
fake?.stop()
|
|
for (const s of stops.splice(0).reverse()) s()
|
|
rmSync(deletedFile(), { force: true })
|
|
})
|
|
|
|
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) {
|
|
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: {} }\n`, { mode: 0o600 })
|
|
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
|
}
|
|
|
|
function project(): string {
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "ph-del-")))
|
|
setTrust(dir, "trusted")
|
|
return dir
|
|
}
|
|
|
|
const until = async (ok: () => boolean, what: string) => {
|
|
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10)
|
|
if (!ok()) throw new Error(`timed out waiting for ${what}`)
|
|
}
|
|
|
|
const limitsFor = (root: string): Limits => ({ roots: [root], maxMode: "auto", approvalTimeoutMs: 2000, requireTrust: true })
|
|
const asker = { ask: () => Promise.resolve({ kind: "once" } as AskReply) }
|
|
|
|
/** A fake instance on an agent: what it hears of deletes. */
|
|
async function instance(limits: Limits, hub?: Hub, said: unknown = true) {
|
|
const [ta, tb] = pair()
|
|
new AcpAgent(new Peer(ta), limits, undefined, hub)
|
|
const web = new Peer(tb)
|
|
const deleted: string[] = []
|
|
web.on("_lembas/session/deleted", (p) => void deleted.push(p.sessionId))
|
|
await web.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: said } } })
|
|
return { web, deleted }
|
|
}
|
|
|
|
async function hubUp(limits: Limits) {
|
|
const hub = new Hub({ service: true, limits: { ...limits, enabled: true } })
|
|
expect(await hub.listen()).toBe(true)
|
|
stops.push(() => hub.close())
|
|
return hub
|
|
}
|
|
|
|
function terminal(root: string): { app: App; share: Share } {
|
|
const app = createApp({ cwd: root, modelTitles: false, asker })
|
|
const share = new Share(app, {
|
|
prompt: (r, started) => (started(), app.turns.prompt(r.prompt, r.extra, r.shown, { turnId: r.turnId })),
|
|
compact: () => app.engine.compact(),
|
|
deleted: () => app.newSession(),
|
|
notice: () => {},
|
|
changed: () => {},
|
|
})
|
|
stops.push(() => share.close())
|
|
return { app, share }
|
|
}
|
|
|
|
test("the pending list: deduplicated, newest kept, and clearing keeps what came meanwhile", () => {
|
|
expect(pendingDeleted()).toEqual([])
|
|
addPendingDeleted(["a", "b"])
|
|
addPendingDeleted(["a", "c"])
|
|
// `a` again is the newest word about it.
|
|
expect(pendingDeleted()).toEqual(["b", "a", "c"])
|
|
const sent = pendingDeleted()
|
|
addPendingDeleted(["d"])
|
|
clearPendingDeleted(sent)
|
|
expect(pendingDeleted()).toEqual(["d"])
|
|
addPendingDeleted(Array.from({ length: DELETED_MAX + 20 }, (_, i) => `s${i}`))
|
|
const all = pendingDeleted()
|
|
expect(all.length).toBe(DELETED_MAX)
|
|
expect(all.at(-1)).toBe(`s${DELETED_MAX + 19}`)
|
|
expect(all).not.toContain("d")
|
|
})
|
|
|
|
test("a delete made with no hub waits, and the next link's initialize sends it and forgets it", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "hi" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const app = createApp({ cwd: root, modelTitles: false, asker })
|
|
await app.turns.prompt("hello")
|
|
const id = app.engine.sessionId!
|
|
app.newSession()
|
|
// No hub runs: `lembas sessions delete` and a terminal without a service go this way.
|
|
expect(await deleteEverywhere(app.store!, id)).toBeUndefined()
|
|
expect(app.store!.session(id)).toBeUndefined()
|
|
expect(pendingDeleted()).toEqual([id])
|
|
|
|
// `serve --stdio` (no limits) is an editor: it neither hears it nor empties the list.
|
|
const [ta, tb] = pair()
|
|
new AcpAgent(new Peer(ta), undefined)
|
|
const editor = new Peer(tb)
|
|
const heard: string[] = []
|
|
editor.on("_lembas/session/deleted", (p) => void heard.push(p.sessionId))
|
|
await editor.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: true } } })
|
|
await Bun.sleep(20)
|
|
expect(heard).toEqual([])
|
|
expect(pendingDeleted()).toEqual([id])
|
|
|
|
// A client that is not LLeMbas is not told either.
|
|
const other = await instance(limitsFor(root), undefined, null)
|
|
await Bun.sleep(20)
|
|
expect(other.deleted).toEqual([])
|
|
|
|
const { deleted } = await instance(limitsFor(root))
|
|
await until(() => deleted.length > 0, "the pending delete")
|
|
expect(deleted).toEqual([id])
|
|
expect(pendingDeleted()).toEqual([])
|
|
})
|
|
|
|
test("a hub with no link keeps a terminal's delete, and the link that comes up sends it", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "hi" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const hub = await hubUp(limitsFor(root))
|
|
const { app, share } = terminal(root)
|
|
await share.start()
|
|
await app.turns.prompt("hello")
|
|
const id = app.engine.sessionId!
|
|
app.newSession()
|
|
expect(await share.deleted(id)).toBeUndefined()
|
|
await until(() => pendingDeleted().includes(id), "the hub to keep it")
|
|
const { deleted } = await instance(limitsFor(root), hub)
|
|
await until(() => deleted.includes(id), "the delete sent")
|
|
expect(pendingDeleted()).toEqual([])
|
|
|
|
// With the link up, the next delete goes at once, and nothing waits.
|
|
await app.turns.prompt("again")
|
|
const next = app.engine.sessionId!
|
|
app.newSession()
|
|
expect(await share.deleted(next)).toBeUndefined()
|
|
await until(() => deleted.includes(next), "the second delete")
|
|
expect(pendingDeleted()).toEqual([])
|
|
})
|
|
|
|
test("`lembas sessions delete` through a running hub: let go of, deleted, and told", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "hi" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const hub = await hubUp(limitsFor(root))
|
|
const { deleted } = await instance(limitsFor(root), hub)
|
|
const app = createApp({ cwd: root, modelTitles: false, asker })
|
|
await app.turns.prompt("hello")
|
|
const id = app.engine.sessionId!
|
|
app.newSession()
|
|
// A connection of its own, made and closed for the delete.
|
|
const store = new Store()
|
|
expect(await deleteEverywhere(store, id)).toBeUndefined()
|
|
expect(store.session(id)).toBeUndefined()
|
|
await until(() => deleted.includes(id), "the instance told")
|
|
expect(pendingDeleted()).toEqual([])
|
|
})
|
|
|
|
test("a session reopens in the directory it was started in, while that is still there", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }])
|
|
const root = project()
|
|
const sub = join(root, "pkg")
|
|
mkdirSync(sub)
|
|
// A repository, so a subdirectory is inside the project and not a project of its own.
|
|
Bun.spawnSync(["git", "init", "-q", root])
|
|
config(fake.url)
|
|
// Started in a subdirectory: the store keeps the project's root, and the directory beside it.
|
|
const term = createApp({ cwd: sub, modelTitles: false, asker })
|
|
await term.turns.prompt("hello")
|
|
const id = term.engine.sessionId!
|
|
expect(term.store!.session(id)!.root).toBe(root)
|
|
expect(term.store!.meta<string>(id, CWD_META)).toBe(sub)
|
|
term.newSession()
|
|
expect(term.store!.meta<string>(term.engine.sessionId!, CWD_META)).toBe(sub)
|
|
|
|
const { web } = await instance(limitsFor(root))
|
|
// Not held yet: from the store.
|
|
expect(((await web.request("_lembas/files/search", { sessionId: id, query: "" })) as any).root).toBe(sub)
|
|
// Opened by a prompt: it works there.
|
|
await web.request("session/prompt", { sessionId: id, prompt: [{ type: "text", text: "again" }] })
|
|
expect(((await web.request("_lembas/files/search", { sessionId: id, query: "" })) as any).root).toBe(sub)
|
|
|
|
// Gone, or outside the project: its project's root, as before.
|
|
const store = term.store!
|
|
store.setMeta(id, CWD_META, join(root, "gone"))
|
|
const other = await instance(limitsFor(root))
|
|
expect(((await other.web.request("_lembas/files/search", { sessionId: id, query: "" })) as any).root).toBe(root)
|
|
store.setMeta(id, CWD_META, tmpdir())
|
|
expect(((await other.web.request("_lembas/files/search", { sessionId: id, query: "" })) as any).root).toBe(root)
|
|
})
|
|
|
|
test("a session started in the web UI keeps its directory too", async () => {
|
|
fake = fakeProvider([])
|
|
const root = project()
|
|
const sub = join(root, "web")
|
|
mkdirSync(sub)
|
|
Bun.spawnSync(["git", "init", "-q", root])
|
|
config(fake.url)
|
|
const { web } = await instance(limitsFor(root))
|
|
const s: any = await web.request("session/new", { cwd: sub })
|
|
expect(new Store().meta<string>(s.sessionId, CWD_META)).toBe(sub)
|
|
})
|
|
|
|
test("resuming leaves no empty session behind; one with anything in it stays", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "kept" })] }, { chunks: [delta({ content: "other" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const first = createApp({ cwd: root, modelTitles: false, asker })
|
|
await first.turns.prompt("hello")
|
|
const kept = first.engine.sessionId!
|
|
first.newSession()
|
|
await first.turns.prompt("there")
|
|
const other = first.engine.sessionId!
|
|
first.resume(kept)
|
|
|
|
// `-c` and /sessions: the app made a session at start, and the resume discards it.
|
|
const app = createApp({ cwd: root, modelTitles: false, asker })
|
|
const seen: Event[] = []
|
|
app.bus.on((e) => void seen.push(e))
|
|
const empty = app.engine.sessionId!
|
|
app.resume(kept)
|
|
expect(app.store!.session(empty)).toBeUndefined()
|
|
expect(seen.find((e) => e.type === "discarded")).toEqual({ type: "discarded", id: empty, shown: false })
|
|
// Never shown to the web UI: nothing for the instance to hear.
|
|
expect(pendingDeleted()).toEqual([])
|
|
expect(app.store!.sessions(50, root).map((s) => s.id).sort()).toEqual([kept, other].sort())
|
|
|
|
// Away from a session with words in it: that one is kept.
|
|
app.resume(other)
|
|
expect(app.store!.session(kept)).toBeDefined()
|
|
|
|
// `-s <id>`: no session made first at all.
|
|
const direct = createApp({ cwd: root, modelTitles: false, asker, resuming: kept })
|
|
direct.resume(kept)
|
|
expect(direct.store!.sessions(50, root, false).map((s) => s.id).sort()).toEqual([kept, other].sort())
|
|
})
|
|
|
|
test("an empty session the web UI was shown is deleted there too when a resume discards it", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "kept" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const first = createApp({ cwd: root, modelTitles: false, asker })
|
|
await first.turns.prompt("hello")
|
|
const kept = first.engine.sessionId!
|
|
// No hub: the Share keeps the word for the next link.
|
|
const { app } = terminal(root)
|
|
const empty = app.engine.sessionId!
|
|
app.store!.setMeta(empty, REMOTE_META, true)
|
|
app.resume(kept)
|
|
expect(app.store!.session(empty)).toBeUndefined()
|
|
expect(pendingDeleted()).toEqual([empty])
|
|
})
|
|
|
|
test("/sessions lists only sessions somebody said something in", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "hi" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const app = createApp({ cwd: root, modelTitles: false, asker })
|
|
await app.turns.prompt("hello")
|
|
const said = app.engine.sessionId!
|
|
app.newSession()
|
|
app.newSession()
|
|
expect(app.store!.sessions(40, root, true).map((s) => s.id)).toEqual([said])
|
|
expect(app.store!.isEmpty(app.engine.sessionId!)).toBe(true)
|
|
expect(app.store!.isEmpty(said)).toBe(false)
|
|
})
|
|
|
|
async function cli(...args: string[]) {
|
|
const p = Bun.spawn(["bun", join(import.meta.dir, "../src/cli.ts"), ...args], { env: { ...process.env }, stdout: "pipe", stderr: "pipe" })
|
|
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
return { out, err, code }
|
|
}
|
|
|
|
test("lembas sessions prune and delete: empty ones go, the instance hears of the ones it was shown", async () => {
|
|
const store = new Store()
|
|
const root = project()
|
|
const said = store.createSession(root, "f/m")
|
|
store.append(said.id, { role: "user", parts: [{ type: "text", text: "hello" }] })
|
|
const old = store.createSession(root, "f/m")
|
|
const shown = store.createSession(root, "f/m")
|
|
store.setMeta(shown.id, REMOTE_META, true)
|
|
// Both an hour and more ago; a fresh empty one may be a terminal's, open now.
|
|
store.db.query("UPDATE sessions SET updated = ? WHERE id IN (?, ?)").run(Date.now() - 2 * 3_600_000, old.id, shown.id)
|
|
const fresh = store.createSession(root, "f/m")
|
|
|
|
const dry = await cli("sessions", "prune", "--dry-run")
|
|
expect(dry.code).toBe(0)
|
|
expect(dry.out).toContain(old.id)
|
|
expect(dry.out).toContain(shown.id)
|
|
expect(dry.out).not.toContain(fresh.id)
|
|
expect(store.session(old.id)).toBeDefined()
|
|
|
|
const pruned = await cli("sessions", "prune")
|
|
expect(pruned.out).toContain("deleted 2 empty sessions")
|
|
expect(store.session(old.id)).toBeUndefined()
|
|
expect(store.session(shown.id)).toBeUndefined()
|
|
expect(store.session(fresh.id)).toBeDefined()
|
|
expect(store.session(said.id)).toBeDefined()
|
|
expect(pendingDeleted()).toEqual([shown.id])
|
|
|
|
const del = await cli("sessions", "delete", said.id, "ses_nothing")
|
|
expect(del.code).toBe(1)
|
|
expect(del.out).toContain(`${said.id}: deleted`)
|
|
expect(del.err).toContain("ses_nothing: no such session")
|
|
expect(store.session(said.id)).toBeUndefined()
|
|
expect(pendingDeleted()).toEqual([shown.id, said.id])
|
|
})
|
|
|
|
test("a terminal that leaves takes its empty session with it, and tells the instance when it was shown", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "hi" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
// No hub: an empty session never shown just goes; one shown waits in the pending list.
|
|
const quiet = createApp({ cwd: root, modelTitles: false, asker })
|
|
const quietId = quiet.engine.sessionId!
|
|
expect(await discardOnExit(quiet.store!, quietId)).toBe(true)
|
|
expect(quiet.store!.session(quietId)).toBeUndefined()
|
|
expect(pendingDeleted()).toEqual([])
|
|
const shownApp = createApp({ cwd: root, modelTitles: false, asker })
|
|
const shownId = shownApp.engine.sessionId!
|
|
shownApp.store!.setMeta(shownId, REMOTE_META, true)
|
|
expect(await discardOnExit(shownApp.store!, shownId)).toBe(true)
|
|
expect(pendingDeleted()).toEqual([shownId])
|
|
rmSync(deletedFile(), { force: true })
|
|
|
|
// Anything said in it: kept.
|
|
const said = createApp({ cwd: root, modelTitles: false, asker })
|
|
await said.turns.prompt("hello")
|
|
expect(await discardOnExit(said.store!, said.engine.sessionId)).toBe(false)
|
|
expect(said.store!.session(said.engine.sessionId!)).toBeDefined()
|
|
|
|
// With the service: the terminal's session was shared (shown); once its terminal has closed its
|
|
// link, the instance is told at once.
|
|
const hub = await hubUp(limitsFor(root))
|
|
const { deleted } = await instance(limitsFor(root), hub)
|
|
const { app, share } = terminal(root)
|
|
await share.start()
|
|
const id = app.engine.sessionId!
|
|
await until(() => share.sharedId === id, "the session shared")
|
|
share.close()
|
|
await Bun.sleep(20)
|
|
expect(await discardOnExit(app.store!, id)).toBe(true)
|
|
await until(() => deleted.includes(id), "the instance told")
|
|
expect(pendingDeleted()).toEqual([])
|
|
})
|
|
|
|
test("an empty session another terminal still has open is not taken from it", async () => {
|
|
fake = fakeProvider([])
|
|
const root = project()
|
|
config(fake.url)
|
|
await hubUp(limitsFor(root))
|
|
const { app, share } = terminal(root)
|
|
await share.start()
|
|
const id = app.engine.sessionId!
|
|
await until(() => share.sharedId === id, "the session shared")
|
|
// Somebody else leaving with that id (a reload's twin, a stale -s): the hub refuses, it stays.
|
|
expect(await discardOnExit(app.store!, id)).toBe(false)
|
|
expect(app.store!.session(id)).toBeDefined()
|
|
})
|
|
|
|
test("lembas run on its way out: an empty session goes, one with a prompt stays", async () => {
|
|
fake = fakeProvider([{ chunks: [delta({ content: "done" })] }])
|
|
const root = project()
|
|
config(fake.url)
|
|
const empty = createApp({ cwd: root, modelTitles: false, asker, snapshots: false })
|
|
const id = empty.engine.sessionId!
|
|
expect(empty.discardIfEmpty()).toBe(true)
|
|
expect(empty.store!.session(id)).toBeUndefined()
|
|
const ran = createApp({ cwd: root, modelTitles: false, asker, snapshots: false })
|
|
await ran.engine.prompt("hello")
|
|
expect(ran.discardIfEmpty()).toBe(false)
|
|
expect(ran.store!.session(ran.engine.sessionId!)).toBeDefined()
|
|
// No store (--no-store): nothing to do.
|
|
expect(createApp({ cwd: root, modelTitles: false, asker, store: false }).discardIfEmpty()).toBe(false)
|
|
})
|