Files
LLeMbas-CLI/tests/tools.test.ts
T
HomerandClaude Opus 5.5 f9bad01ed7
ci / check (push) Waiting to run
LLeMbas CLI 1.0.0
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>
2026-10-09 21:59:03 +00:00

208 lines
11 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { applyPatchTool } from "../src/tool/apply_patch.ts"
import { bashKillTool, bashOutputTool, bashTool } from "../src/tool/bash.ts"
import { editTool } from "../src/tool/edit.ts"
import { multieditTool } from "../src/tool/multiedit.ts"
import { formatAnswers, questionSchema, unattendedReply, type QuestionRequest } from "../src/tool/question.ts"
import { readTool } from "../src/tool/read.ts"
import { todoTool } from "../src/tool/todo.ts"
import type { ToolContext } from "../src/tool/tool.ts"
function ctx(): ToolContext {
const root = mkdtempSync(join(tmpdir(), "ph-tools-"))
return { root, cwd: root, signal: new AbortController().signal, readFiles: new Set(), fileStamps: new Map(), bashTimeoutMs: 5000 }
}
const REQ: QuestionRequest = {
why: "Two things decide the design.",
questions: [
{ header: "Database", question: "Which database?", options: [{ label: "PostgreSQL", recommended: true }, { label: "SQLite" }] },
{ header: "Layers", question: "Which layers?", multiple: true, options: [{ label: "API" }, { label: "CLI" }, { label: "Web" }] },
{ header: "Auth", question: "Sessions how?", options: [{ label: "JWT" }, { label: "Cookies" }] },
{ header: "Deploy", question: "Where to?", options: [{ label: "Docker" }, { label: "Bare metal" }] },
],
}
describe("question", () => {
test("the answer text: options, recommended, custom, a question back — and what to do next", () => {
const text = formatAnswers(REQ, {
answers: [
{ kind: "options", labels: ["PostgreSQL"] },
{ kind: "options", labels: ["API", "CLI"] },
{ kind: "custom", text: "signed cookies, no server store" },
{ kind: "back", text: "What does Docker add to build time?" },
],
})
expect(text).toBe(`Q1 [Database] Which database?
→ PostgreSQL (the one you recommended)
Q2 [Layers] Which layers?
→ API, CLI
Q3 [Auth] Sessions how?
→ custom: "signed cookies, no server store"
Q4 [Deploy] Where to?
↩ counter-question: "What does Docker add to build time?"
Q1, Q2, Q3 are answered. Instead of answering Q4, the user asked you a question. Answer it in your reply, then call question again with only Q4 — adjusted if your answer changes the options.`)
expect(formatAnswers(REQ, { dismissed: true })).toContain("Do not ask them again")
})
test("nobody present: the recommended option, said to be automatic; or the first; or dismissed", () => {
const rec = unattendedReply(REQ, "recommended")
expect(formatAnswers(REQ, rec)).toContain("→ PostgreSQL (the one you recommended) (chosen automatically: nobody is present")
expect(!rec.dismissed && rec.answers[1]).toEqual({ kind: "options", labels: ["API"] })
expect(unattendedReply(REQ, "fail")).toEqual({ dismissed: true })
})
test("lenient input: a single question, options as strings, `choices`, a missing header", () => {
const a = questionSchema.parse({ question: "Tabs or spaces?", choices: ["tabs", "spaces"] }) as QuestionRequest
expect(a.questions[0]).toMatchObject({ header: "Tabs or spaces", options: [{ label: "tabs" }, { label: "spaces" }] })
})
})
describe("file tools", () => {
test("multiedit applies in order, and on any failure changes nothing", async () => {
const c = ctx()
writeFileSync(join(c.root, "a.ts"), "let x = 1\nlet y = 2\n")
await readTool.run({ path: "a.ts" }, c)
await multieditTool.run({ path: "a.ts", edits: [{ old: "x = 1", new: "x = 10" }, { old: "x = 10", new: "x = 11" }] }, c)
expect(readFileSync(join(c.root, "a.ts"), "utf8")).toBe("let x = 11\nlet y = 2\n")
await expect(multieditTool.run({ path: "a.ts", edits: [{ old: "y = 2", new: "y = 3" }, { old: "nope", new: "x" }] }, c)).rejects.toThrow("step 2 of 2")
expect(readFileSync(join(c.root, "a.ts"), "utf8")).toBe("let x = 11\nlet y = 2\n")
})
test("apply_patch: add, update with context, move, delete — or nothing", async () => {
const c = ctx()
writeFileSync(join(c.root, "app.py"), "def greet():\n print('Hi')\n\ngreet()\n")
writeFileSync(join(c.root, "old.txt"), "bye\n")
const r = await applyPatchTool.run(
{
patch: `*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: app.py
*** Move to: main.py
@@ def greet():
- print('Hi')
+ print('Hello, world!')
*** Delete File: old.txt
*** End Patch`,
},
c,
)
expect(readFileSync(join(c.root, "hello.txt"), "utf8")).toBe("Hello world\n")
expect(readFileSync(join(c.root, "main.py"), "utf8")).toContain("Hello, world!")
expect(existsSync(join(c.root, "app.py"))).toBe(false)
expect(existsSync(join(c.root, "old.txt"))).toBe(false)
expect(r.title).toContain("hello.txt")
await expect(
applyPatchTool.run({ patch: "*** Begin Patch\n*** Add File: x.txt\n+x\n*** Update File: main.py\n@@\n-not there\n+y\n*** End Patch" }, c),
).rejects.toThrow("Nothing was applied")
expect(existsSync(join(c.root, "x.txt"))).toBe(false)
})
test("apply_patch takes a unified diff: by its headers or by path, created and deleted files, all or nothing", async () => {
const c = ctx()
writeFileSync(join(c.root, "a.txt"), "one\ntwo\nthree\n")
writeFileSync(join(c.root, "crlf.txt"), "\uFEFFx\r\ny\r\n")
writeFileSync(join(c.root, "gone.txt"), "bye\n")
// No headers: the file is the path. Wrong line numbers are only a hint.
await applyPatchTool.run({ patch: "@@ -40,2 +40,2 @@\n one\n-two\n+TWO\n", path: "a.txt" }, c)
expect(readFileSync(join(c.root, "a.txt"), "utf8")).toBe("one\nTWO\nthree\n")
// A BOM and CRLF are kept.
await applyPatchTool.run({ patch: "--- a/crlf.txt\n+++ b/crlf.txt\n@@ -1,2 +1,2 @@\n x\n-y\n+Y\n" }, c)
expect(readFileSync(join(c.root, "crlf.txt"), "utf8")).toBe("\uFEFFx\r\nY\r\n")
// Several files in one git diff: a new one, a deleted one, a changed one.
const r = await applyPatchTool.run(
{
patch: [
"diff --git a/new.txt b/new.txt", "new file mode 100644", "--- /dev/null", "+++ b/new.txt", "@@ -0,0 +1,2 @@", "+hello", "+world",
"diff --git a/gone.txt b/gone.txt", "--- a/gone.txt", "+++ /dev/null", "@@ -1 +0,0 @@", "-bye",
"--- a/a.txt", "+++ b/a.txt", "@@ -3 +3 @@", "-three", "+3",
].join("\n"),
},
c,
)
expect(readFileSync(join(c.root, "new.txt"), "utf8")).toBe("hello\nworld\n")
expect(existsSync(join(c.root, "gone.txt"))).toBe(false)
expect(readFileSync(join(c.root, "a.txt"), "utf8")).toBe("one\nTWO\n3\n")
expect(r.title).toContain("new.txt")
// One hunk that does not fit: no file is touched, and the model is shown what is there.
await expect(applyPatchTool.run({ patch: "--- /dev/null\n+++ b/x.txt\n@@ -0,0 +1 @@\n+x\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-nothing like this\n+y\n" }, c)).rejects.toThrow("-> 1 one")
expect(existsSync(join(c.root, "x.txt"))).toBe(false)
await expect(applyPatchTool.run({ patch: "@@ -1 +1 @@\n-one\n+1\n" }, c)).rejects.toThrow("names no file")
// The permission request names the files before anything is read.
expect(applyPatchTool.permission({ patch: "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+1\n" }, c).patterns).toEqual(["a.txt"])
expect(applyPatchTool.permission({ patch: "@@ -1 +1 @@\n-one\n+1\n", path: "b.txt" }, c).patterns).toEqual(["b.txt"])
})
test("a byte-order mark survives edit, multiedit and both apply_patch formats", async () => {
const c = ctx()
const bom = (name: string) => (writeFileSync(join(c.root, name), "\uFEFFx\ny\n"), c.readFiles.add(join(c.root, name)))
bom("e.txt")
await editTool.run({ path: "e.txt", old: "y", new: "Y" }, c)
bom("m.txt")
await multieditTool.run({ path: "m.txt", edits: [{ old: "y", new: "Y" }] }, c)
bom("p.txt")
await applyPatchTool.run({ patch: "*** Begin Patch\n*** Update File: p.txt\n@@\n x\n-y\n+Y\n*** End Patch" }, c)
bom("u.txt")
await applyPatchTool.run({ patch: "@@ -1,2 +1,2 @@\n x\n-y\n+Y\n", path: "u.txt" }, c)
for (const f of ["e.txt", "m.txt", "p.txt", "u.txt"]) expect(readFileSync(join(c.root, f), "utf8")).toBe("\uFEFFx\nY\n")
})
test("apply_patch: a file named twice gets both changes; a move never overwrites", async () => {
const c = ctx()
writeFileSync(join(c.root, "f.txt"), "one\ntwo\nthree\nfour\n")
writeFileSync(join(c.root, "g.txt"), "keep me\n")
await applyPatchTool.run({ patch: "*** Begin Patch\n*** Update File: f.txt\n@@\n-one\n+ONE\n two\n*** Update File: f.txt\n@@\n three\n-four\n+FOUR\n*** End Patch" }, c)
expect(readFileSync(join(c.root, "f.txt"), "utf8")).toBe("ONE\ntwo\nthree\nFOUR\n")
await expect(applyPatchTool.run({ patch: "*** Begin Patch\n*** Update File: f.txt\n*** Move to: g.txt\n@@\n-ONE\n+1\n*** End Patch" }, c)).rejects.toThrow("does not overwrite")
expect(readFileSync(join(c.root, "g.txt"), "utf8")).toBe("keep me\n")
expect(readFileSync(join(c.root, "f.txt"), "utf8")).toBe("ONE\ntwo\nthree\nFOUR\n")
})
test("todo replaces the list and reports progress", async () => {
const c = ctx()
const todos = { items: [] as { content: string; status: "pending" | "in_progress" | "completed" | "cancelled" }[] }
// Through the schema, as the engine does: it turns the bare string into a pending item.
const args = todoTool.schema.parse({ todos: [{ content: "read", status: "completed" }, { content: "fix", status: "in_progress" }, "test"] })
const r = await todoTool.run(args, { ...c, todos })
expect(r.output).toBe("[x] read\n[>] fix\n[ ] test")
expect(r.title).toBe("1/3 done")
expect(todos.items).toHaveLength(3)
})
})
describe("background bash", () => {
test("start, read new output, see it finish; kill a long one", async () => {
const c = ctx()
const started = await bashTool.run({ command: "echo one; sleep 0.5; echo two", background: true }, c)
const id = /as (job\d+)/.exec(started.output)![1]!
expect(started.output).toContain("one")
const later = await bashOutputTool.run({ id, wait: 3 }, c)
expect(later.output).toContain("two")
await Bun.sleep(200)
expect((await bashOutputTool.run({ id }, c)).output).toContain("exited with 0")
const long = await bashTool.run({ command: "sleep 30", background: true }, c)
const id2 = /as (job\d+)/.exec(long.output)![1]!
expect((await bashKillTool.run({ id: id2 }, c)).output).toContain("Stopped")
expect((await bashOutputTool.run({ id: id2, wait: 3 }, c)).output).toMatch(/exited with (null|143|-?\d+)/)
})
})
describe("paths the way a model writes them", () => {
test("~/x is the home directory, not a directory named ~; a missing directory says so", async () => {
const { absPath } = await import("../src/tool/tool.ts")
const { homedir } = await import("node:os")
const { listTool, globTool } = await import("../src/tool/search.ts")
const c = ctx()
expect(absPath("~/notes.md", c)).toBe(join(homedir(), "notes.md"))
expect(absPath("~", c)).toBe(homedir())
expect(absPath("a/~b", c)).toBe(join(c.root, "a/~b"))
await expect(listTool.run({ path: "nope" }, c)).rejects.toThrow("nope does not exist")
await expect(globTool.run({ pattern: "*", path: "nope" }, c)).rejects.toThrow("nope does not exist")
})
})