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>
208 lines
11 KiB
TypeScript
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")
|
|
})
|
|
})
|