Harness spec 1.1.0: one plan, prototypes and the library shared; the CLI implements it

The spec: plan_write and plan_update (new, shared, offered in every mode) and
plan_submit (now shared) keep one structured plan — findings, objectives,
phases of tasks — in place of the todo list, which lives on as a former name
of plan_write. The shared system text is one text for both projects, with
variables for what differs; the mode texts, a prototypes block and a library
block are shared. acp.md: the client hands the device what only it knows
(context, library, prototypes), the plan travels as a structure (plan_state),
library calls carry the chat's scope, and a client may rewind a session.
loop.json says its numbers as numbers. Four conformance areas: plan,
prototype, library_scope, toolcall_index.

The CLI: the plan is kept per session in the store and drawn above the prompt
and on the approval card; the prompt is assembled from the spec's texts with
their variables filled; the link reads the instance's instructions,
personalization, knowledge scope and prototype offer, sends the plan as a
structure and answers rewind; library calls to the instance carry the session
as scope; a prototype written in the terminal is saved as a file and named in
the transcript; a tool-call fragment without an index continues the last call;
the default theme is Moria; dead exports and Hermes leftovers removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-18 04:02:12 +02:00
1 parent 6e70ff46d0
commit 837f38c744
96 files changed
+3259 -527

No files matched your search

+112 -9
View File
@@ -2,11 +2,11 @@
// 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 { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, 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 { formatAnswers } from "../src/tool/ask_user.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"
@@ -15,7 +15,7 @@ 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 type { QuestionReply } from "../src/tool/ask_user.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
@@ -273,12 +273,13 @@ test("ask_user in a session held here is asked of LLeMbas, and its answer reache
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" })] }])
test("plan_submit is asked of LLeMbas with the plan written, its text and its file; approving moves the session to the mode chosen", async () => {
const PLAN = { title: "The plan", summary: "One change.", phases: [{ title: "Work", tasks: ["do it"] }] }
fake = fakeProvider([{ chunks: [toolCall(0, "w1", "plan_write", JSON.stringify(PLAN))] }, { chunks: [toolCall(0, "p1", "plan_submit", "{}")] }, { chunks: [delta({ content: "implementing" })] }])
config(fake.url, "mode: plan\n")
const root = project({ ".agent/plans/p.md": "# The plan\n1. do it\n" })
const root = project({ ".agent/config.yaml": "" })
let asked: any
const { c, events } = await client(limitsFor(root, { maxMode: "edit" }), {
const { c, events, updates } = await client(limitsFor(root, { maxMode: "edit" }), {
handlers: {
"_lembas/plan": (p) => {
asked = p
@@ -288,9 +289,111 @@ test("plan_submit is asked of LLeMbas; approving moves the session to the mode c
})
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" })
const text = "# The plan\nOne change.\n## p1 Work (pending)\n[t1] [ ] do it"
expect(asked).toMatchObject({ toolCallId: "p1", text, plan: { version: 2, title: "The plan", steps: ["do it"] } })
// The copy for the record, under the project's plans directory, named by the day and the title.
expect(asked.path).toMatch(/^\.agent\/plans\/\d{4}-\d{2}-\d{2}-the-plan\.md$/)
expect(readFileSync(join(root, asked.path), "utf8")).toBe(text + "\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")
expect(fake.requests[2].messages.filter((m: any) => m.role === "tool").at(-1).content).toContain("approved the plan")
// The plan travels as a structure too (capability plan_state): on the update, beside ACP's entries.
const update = updates.find((u) => u.update.sessionUpdate === "plan")
expect(update.update.entries).toEqual([{ content: "do it", status: "pending", priority: "medium" }])
expect(update.update._meta.lembas.plan).toMatchObject({ version: 2, title: "The plan", phases: [{ id: "p1", tasks: [{ id: "t1", text: "do it", status: "todo" }] }] })
expect((await c.request<any>("_lembas/session/status", { sessionId: s.sessionId })).plan).toMatchObject({ title: "The plan" })
})
// §5 — harness spec 1.1.0: what only the client knows, the plan as a structure, rewinding.
test("initialize lists the 1.1.0 capabilities", async () => {
fake = fakeProvider([])
config(fake.url)
const { init } = await client(limitsFor(project()))
for (const cap of ["context", "library", "prototypes", "plan_state", "rewind"]) expect(init._meta.lembas.capabilities).toContain(cap)
})
test("the context reaches the prompt: the instance's instructions, its personalization, the library's scope, prototypes", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }, { chunks: [delta({ content: "three" })] }])
config(fake.url, "personality: concise\ninstructions: local words\n")
const root = project()
const { c } = await client(limitsFor(root), { instance: "example" })
const context = {
system_prompt: "Answer in Slovak.",
personalization: { personality: "formal", personality_custom: "", instructions: "Call me Jaro." },
library: { bases: [{ id: "kb1", name: "Manuals" }, { id: "kb2", name: "Contracts" }] },
prototypes: { offered: true, sources: "The page may load nothing from the network." },
}
const s: any = await c.request("session/new", { cwd: root, _meta: { lembas: context } })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "hi" }] })
const system = () => fake!.requests.at(-1)!.messages[0].content as string
expect(system()).toContain("## Instructions from example\n\nAnswer in Slovak.")
// The account's personalization, in place of the device's own.
expect(system()).toContain("## How the person you are talking to wants to be helped\n\nCall me Jaro.")
expect(system()).not.toContain("local words")
expect(system()).toContain("## Personality\n\n")
expect(system()).toContain("formal")
// Prototypes offered, with what a page may load and where it is seen from a terminal.
expect(system()).toContain("prototype-chat")
expect(system()).toContain("The page may load nothing from the network.")
expect(system()).toContain("It is saved as a file you can open, and the LLeMbas web UI renders it live.")
// The library's scope is named only where its tools are offered (none here: no local base, no
// instance library) — nothing unfilled reaches the model either way.
expect(system()).not.toContain("{{")
// A later prompt replaces the context: personalization back to the device's, nothing from the instance.
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }], _meta: { lembas: { turnId: "T2", system_prompt: "", personalization: null, prototypes: { offered: false, sources: "" } } } })
expect(system()).not.toContain("Instructions from example")
expect(system()).toContain("local words")
expect(system()).not.toContain("prototype-chat")
// A prompt that says only its turn id changes nothing.
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "more" }], _meta: { lembas: { turnId: "T3" } } })
expect(system()).toContain("local words")
})
test("the plan is sent after a session is opened from the store, and cleared with plan: null", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "w1", "plan_write", JSON.stringify({ title: "Kept", phases: [{ title: "P", tasks: ["a"] }] }))] }, { chunks: [delta({ content: "ok" })] }])
config(fake.url)
const root = project()
const first = await client(limitsFor(root))
const s: any = await first.c.request("session/new", { cwd: root })
await first.c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "plan" }] })
// A new link: the session opened from the store says its plan before anything changes it.
const again = await client(limitsFor(root))
expect((await again.c.request<any>("_lembas/session/status", { sessionId: s.sessionId })).plan).toMatchObject({ title: "Kept" })
await again.c.request("_lembas/session/status", { sessionId: s.sessionId })
await again.c.request("_lembas/configure", { sessionId: s.sessionId })
await until(() => again.updates.some((u) => u.update.sessionUpdate === "plan"), "the plan update")
expect(again.updates.find((u) => u.update.sessionUpdate === "plan").update._meta.lembas.plan).toMatchObject({ title: "Kept" })
// /plan clear from the web UI? Not a command; the session's own clearing says plan: null.
await again.c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "/undo" }] })
})
test("rewind: the turn and every later one leave the conversation; the next prompt does not see them; files stay", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "first answer" })] }, { chunks: [delta({ content: "second answer" })] }, { chunks: [delta({ content: "third answer" })] }, { chunks: [delta({ content: "after the rewind" })] }])
config(fake.url)
const root = project()
const { c } = 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 } } })
await prompt("one", "T1")
await prompt("two", "T2")
await prompt("three", "T3")
writeFileSync(join(root, "made.txt"), "by the second turn\n")
await expect(c.request("_lembas/session/rewind", { sessionId: s.sessionId, turnId: "nope" })).rejects.toThrow("no turn")
expect(await c.request<any>("_lembas/session/rewind", { sessionId: s.sessionId, turnId: "T2" })).toEqual({ reverted: 2 })
// Gone from the history and from what the model is sent; the file is as it was.
const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId })
expect(h.turns.filter((t: any) => t.role === "user").map((t: any) => t.text)).toEqual(["one"])
await prompt("four", "T4")
const sent = JSON.stringify(fake.requests[3].messages)
expect(sent).toContain("first answer")
expect(sent).not.toContain("second answer")
expect(sent).not.toContain("third answer")
expect(readFileSync(join(root, "made.txt"), "utf8")).toBe("by the second turn\n")
// A session opened from the store (nothing in memory): rewound by its rows.
const again = await client(limitsFor(root))
expect(await again.c.request<any>("_lembas/session/rewind", { sessionId: s.sessionId, turnId: "T1" })).toEqual({ reverted: 2 })
const after: any = await again.c.request("_lembas/session/history", { sessionId: s.sessionId })
expect(after.turns).toEqual([])
})
test("a client that is not LLeMbas gets no question card: ask_user says nobody can be asked", async () => {
+1 -1
View File
@@ -233,7 +233,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"])
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"])
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 })
+1 -1
View File
@@ -35,7 +35,7 @@ test("past the output budget the tools are withdrawn, the model is told, and it
expect(await a.engine.prompt("what is in big.txt?")).toBe("budget")
expect(tools(fake!.requests[0])).toContain("read")
expect(tools(fake!.requests[1])).toEqual([])
expect(JSON.stringify(fake!.requests[1].messages.at(-1))).toContain("reached the budget for this reply")
expect(JSON.stringify(fake!.requests[1].messages.at(-1))).toContain("The tool output budget for this prompt has been reached")
expect(notices.join("\n")).toContain("with too much tool output to read")
})
+6 -1
View File
@@ -10,8 +10,13 @@ const dir = join(import.meta.dir, "..", "harness", "conformance")
for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
const area = f.replace(/\.json$/, "")
const doc = JSON.parse(readFileSync(join(dir, f), "utf8")) as { cases: Record<string, unknown>[] }
// An area nobody here answers yet is skipped and named, not failed: the harness spec can be a
// step ahead of this implementation inside one round of work.
if (!AREAS[area]) {
test.skip(`conformance: ${area} — no runner in tests/conformance.ts yet`, () => {})
continue
}
test(`conformance: ${area} (${doc.cases.length} cases)`, () => {
expect(AREAS[area]).toBeDefined()
for (const c of doc.cases) {
const { expect: want, ...input } = c
expect(want, `${area}: ${JSON.stringify(input)} has no expect — bun run harness record`).toBeDefined()
+21
View File
@@ -12,6 +12,10 @@ import { replace } from "../src/tool/replace.ts"
import { applyUnified, parseUnified } from "../src/tool/unidiff.ts"
import { derive, joinBom, parse } from "../src/tool/patch.ts"
import type { Mode, PermissionConfig } from "../src/config/schema.ts"
import { apply, fromTodos, normalise, type Plan } from "../src/plan/plan.ts"
import { ToolCallAccumulator } from "../src/provider/openai-chat.ts"
import { parseInfo, titleFor } from "../src/prototype/fence.ts"
import { resolveBase } from "../src/library/store.ts"
type Case = Record<string, unknown>
@@ -57,6 +61,23 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
return { error: (e as Error).message }
}
},
plan: (c) => {
if (c.op === "normalise") return normalise(c.plan)
if (c.op === "update") return apply(c.plan as Plan, c.changes)
if (c.op === "todos") return fromTodos(c.todos)
throw new Error(`plan: unknown op ${String(c.op)}`)
},
prototype: (c) => {
const info = parseInfo(c.info as string)
return info ? { kind: info.kind, title: titleFor(c.code as string, info.title) } : null
},
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
const acc = new ToolCallAccumulator()
acc.feed((c.deltas as Record<string, unknown>[]).map(({ index, id, name, arguments: args }) => ({ index, id, function: { name, arguments: args } })))
return acc.result().map((p) => ({ id: p.id, name: p.name, arguments: p.args }))
},
envelope: (c) => {
// planPatch's logic without the filesystem: every file followed through the patch in memory.
const files = new Map<string, string | null>(Object.entries(c.files as Record<string, string>))
+1 -1
View File
@@ -50,7 +50,7 @@ describe("end to end", () => {
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "bash_output", "bash_list", "bash_kill", "web_search", "web_fetch", "task", "todo", "ask_user", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings"])
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "bash_output", "bash_list", "bash_kill", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings"])
const ends = events.filter((e) => e.type === "tool_end")
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
const sid = store.sessions(1)[0]!.id
+16
View File
@@ -0,0 +1,16 @@
// A prompt text's variables (harness spec, prompts/index.json): filled in, a bullet-only line
// taken out with its empty variable, and a variable with no value refused rather than sent.
import { expect, test } from "bun:test"
import { fillPrompt } from "../src/prompt/fill.ts"
test("a variable is filled; a line that is only a bullet and an empty variable disappears", () => {
const text = "{{intro}}\n\n# Using tools\n- {{surface}} Be concise.\n- {{tool_list}}\n - {{file_tools}}\n- Keep working.\n* {{background}}\n\n{{git}}"
const out = fillPrompt(text, { intro: "You help.", surface: "A terminal.", tool_list: "", file_tools: "Use read.", background: " ", git: "" })
expect(out).toBe("You help.\n\n# Using tools\n- A terminal. Be concise.\n - Use read.\n- Keep working.\n\n")
})
test("a variable with no value is an error, not a literal {{name}} sent to the model", () => {
expect(() => fillPrompt("- {{plan_context}}\n{{intro}}", { intro: "x" })).toThrow("no value for {{plan_context}}")
// A filled variable may itself contain a bullet-and-variable line: it is text, not a template.
expect(fillPrompt("- {{a}}", { a: "- {{b}}" })).toBe("- - {{b}}")
})
+33 -3
View File
@@ -12,6 +12,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { MODE_ALIASES, MODES, type Mode } from "../src/config/schema.ts"
import { promptText } from "../src/prompt/assemble.ts"
import { normalise, type Plan } from "../src/plan/plan.ts"
import { canonicalToolNames, resolveCall } from "../src/tool/names.ts"
import { BUILTIN_TOOLS } from "../src/tool/registry.ts"
import { purposeKey, type Tool, type ToolContext } from "../src/tool/tool.ts"
@@ -58,7 +59,9 @@ test("the spec has a version, and one file per tool, named as the tool", () => {
})
test("every tool says how it is shown (block), from the spec's list", () => {
const kinds = ["shell", "edit", "read", "search", "web", "task", "hidden", "tool"]
const kinds = ["shell", "edit", "read", "search", "web", "task", "plan", "hidden", "tool"]
// A plan change is not drawn as a call: the plan card refreshes instead.
for (const name of ["plan_write", "plan_update"]) expect(TOOL_SPECS[name]!.block).toBe("plan")
for (const s of Object.values(TOOL_SPECS)) expect(kinds).toContain(s.block)
// The ones that carry their change carry its diff: the web UI draws it from result.meta.diff.
for (const name of ["edit", "multiedit", "apply_patch", "write"]) expect(TOOL_SPECS[name]!.block).toBe("edit")
@@ -125,9 +128,13 @@ test("prompts/index.json lists every prompt text, each with a scope", () => {
test("the execution tools are the CLI's alone; the web UI runs no agent", () => {
// LLeMbas removed its SSH workplace, so nothing that runs a command or touches a file is shared.
const exec = ["apply_patch", "bash", "bash_kill", "bash_list", "bash_output", "edit", "glob", "grep", "list", "multiedit", "read", "write", "todo", "plan_submit"]
const exec = ["apply_patch", "bash", "bash_kill", "bash_list", "bash_output", "edit", "glob", "grep", "list", "multiedit", "read", "write"]
for (const n of exec) expect(TOOL_SPECS[n]!.scope).toBe("cli")
const shared = ["ask_user", "knowledge_get", "knowledge_search", "memory", "note_manage", "note_view", "notes_search", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search"]
// The plan is one structure both keep (spec 1.1.0): its three tools are shared, and todo is
// plan_write's former name rather than a tool.
expect(TOOL_SPECS.todo).toBeUndefined()
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
const shared = ["ask_user", "knowledge_get", "knowledge_search", "memory", "note_manage", "note_view", "notes_search", "plan_submit", "plan_update", "plan_write", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search"]
for (const n of shared) expect(TOOL_SPECS[n]!.scope).toBe("shared")
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
})
@@ -160,6 +167,29 @@ test("every {{variable}} in a description is declared, and none reaches a model"
const bash = app.engine.o.tools.find((t) => t.name === "bash")!
expect(bash.description).toContain("default 90 seconds, `timeout` up to 10 minutes). For a server")
expect(PURPOSE_DEF.schema).toEqual({ type: "string", description: expect.stringContaining("what this call is for") })
// Nor through the assembled system prompt, in any mode, with or without a plan, with the
// instance's pieces or without them; the CLI's facts are in it and the bullets LLeMbas alone
// fills are gone.
const plan = normalise({ title: "T", phases: [{ title: "P", tasks: ["a"] }] }) as Plan
for (const mode of MODES) {
for (const p of [undefined, plan]) {
const system = app.engine.o.system(mode, app.engine.model, p)
expect(system).not.toContain("{{")
expect(system).toContain("Use the instructions below and the tools you have to help with software engineering and with running the project.")
expect(system).toContain("- Your output is shown in a terminal and rendered as GitHub-flavoured markdown in a monospace font. Be concise")
expect(system).toContain("- Use the dedicated tools for files: read (not cat)")
expect(system).toContain("- A long-running command (a server, a watcher) runs with bash `background: true`")
expect(system).toContain("# Git\n- How git is used in this project")
expect(system).not.toContain("- \n")
if (p) expect(system).toContain("- ### The current plan\n\n# T\n## p1 P (pending)\n[t1] [ ] a\n\nThis is the plan as it stands now. Work to it, and quote the ids above rather than restating the plan in your answer.")
else expect(system).not.toContain("The current plan")
}
}
app.setContext({ instructions: "Be brief.", prototypes: { offered: true, sources: "" }, library: { bases: [{ id: "k", name: "K" }] } }, "example")
const withContext = app.engine.o.system("manual", app.engine.model, plan)
expect(withContext).not.toContain("{{")
expect(withContext).toContain("## Instructions from example\n\nBe brief.")
expect(withContext).toContain("prototype-page")
})
test("acp.md names every _lembas method the code handles or sends, and every capability", async () => {
+36 -2
View File
@@ -14,7 +14,7 @@ afterEach(() => {
})
function fakeLembasMcp() {
const calls: { auth: string; name?: string; args?: unknown }[] = []
const calls: { auth: string; name?: string; args?: unknown; meta?: unknown }[] = []
const server = Bun.serve({
port: 0,
async fetch(req) {
@@ -35,7 +35,8 @@ function fakeLembasMcp() {
],
})
if (msg.method === "tools/call") {
calls.push({ auth, name: msg.params.name, args: msg.params.arguments })
// `_meta.lembas` alone: the SDK adds a progress token of its own beside it.
calls.push({ auth, name: msg.params.name, args: msg.params.arguments, ...(msg.params._meta?.lembas ? { meta: msg.params._meta.lembas } : {}) })
return reply({ content: [{ type: "text", text: `ok: ${msg.params.name}` }], isError: false })
}
return Response.json({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "no" } })
@@ -68,6 +69,39 @@ test("the instance's library replaces this machine's, under the tools' own names
expect(lembas.calls).toEqual([{ auth: "Bearer lmb_lib", name: "memory_add", args: { content: "Prefers tabs." } }])
})
test("a session the client gave a library scope sends its id on every call to the instance's library; base passes through", async () => {
const lembas = fakeLembasMcp()
mkdirSync(join(paths.config, "lembas"), { recursive: true })
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { ai: { base_url: lembas.base, connection: "ai", logged_in_at: "" } } }))
writeFileSync(join(paths.config, "lembas", "ai.key"), "lmb_lib\n", { mode: 0o600 })
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n dialect: openai-chat\n base_url: http://127.0.0.1:1/v1\n models: { m: {} }\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlibrary: lembas\n")
const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
stops.push(() => void app.close())
await app.mcpReady
await Bun.sleep(50)
const search = app.engine.o.tools.find((t) => t.name === "notes_search")!
const ctx = () => ({ ...app.engine.o.toolCtx, signal: new AbortController().signal })
// Nothing said about the session's library: no scope, as before.
await search.run({ query: "a" } as never, ctx())
expect(lembas.calls[0]).toEqual({ auth: "Bearer lmb_lib", name: "notes_search", args: { query: "a" } })
// The client named the bases: the session's id rides on the call as _meta.lembas.scope, and
// the instance answers within that chat's scope (beside the SDK's own progress token); `base`
// is the model's word, passed untouched.
app.setContext({ library: { bases: [{ id: "kb1", name: "Manuals" }] } }, "ai")
await search.run({ query: "b", base: "Manuals" } as never, ctx())
expect(lembas.calls[1]).toEqual({ auth: "Bearer lmb_lib", name: "notes_search", args: { query: "b", base: "Manuals" }, meta: { scope: app.engine.sessionId } })
// The prompt names what the session may search.
expect(app.engine.o.system("manual", app.engine.model)).toContain("Knowledge searches here cover only: Manuals. Finding nothing there means nothing is there, not that the library is empty.")
// Said again with no bases: the account's whole library — nothing named to the model, while the
// scope still rides (the instance answers in the chat's data group).
app.setContext({ library: { bases: [] } }, "ai")
await search.run({ query: "c" } as never, ctx())
expect(lembas.calls[2]!.meta).toEqual({ scope: app.engine.sessionId })
expect(app.engine.o.system("manual", app.engine.model)).not.toContain("Knowledge searches here cover only")
expect(app.engine.o.system("manual", app.engine.model)).toContain("search it with knowledge_search")
})
test("without a login it says so and keeps this machine's library", () => {
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: {} }))
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n dialect: openai-chat\n base_url: http://127.0.0.1:1/v1\n models: { m: {} }\n", { mode: 0o600 })
+14 -1
View File
@@ -1,6 +1,6 @@
// The names harness spec v1 changed (shared with LLeMbas): question → ask_user, plan_exit →
// plan_submit, websearch/webfetch → web_search/web_fetch, notes → notes_search/note_view/
// note_manage, bash's workdir → cwd, the mode unrestricted → auto. Whatever was written under an
// note_manage, bash's workdir → cwd, the mode unrestricted → auto; and 1.1.0's todo → plan_write. Whatever was written under an
// old name — a stored session, a config file, an agent file, a prompt override — keeps working.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
@@ -35,6 +35,19 @@ test("a call under a former name runs the tool it now is, with its arguments", (
expect(canonicalToolNames(["read", "webfetch", "notes"])).toEqual(["read", "web_fetch", "notes_search", "note_view", "note_manage"])
})
test("a todo call is a plan_write of one phase called Work, its statuses translated", () => {
const r = resolveCall("todo", { todos: [{ content: "Read the code", status: "completed" }, { content: "Fix it", status: "in_progress" }, "Test it", { content: "Old idea", status: "cancelled" }] })
expect(r.name).toBe("plan_write")
expect(r.raw).toEqual({
title: "Work",
phases: [{ title: "Work", status: "active", tasks: [{ text: "Read the code", status: "done" }, { text: "Fix it", status: "doing" }, { text: "Test it", status: "todo" }, { text: "Old idea", status: "dropped" }] }],
})
// Nothing in progress: the phase is pending. An empty list is an empty plan, which the tool refuses.
expect((resolveCall("todo", { todos: ["a"] }).raw as { phases: { status: string }[] }).phases[0]!.status).toBe("pending")
expect(resolveCall("todo", { todos: [] }).raw).toEqual({ title: "Work", phases: [] })
expect(canonicalToolNames(["todo", "read"])).toEqual(["plan_write", "read"])
})
test("the mode unrestricted is auto, wherever a mode is written", () => {
expect(asMode("unrestricted")).toBe("auto")
expect(asMode(" auto ")).toBe("auto")
+92
View File
@@ -0,0 +1,92 @@
// The plan as a structure (src/plan/plan.ts, harness spec 1.1.0): what the conformance cases do
// not pin — how it is shown, the panel's digest, and that the engine keeps it with the session.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import type { Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { apply, fromTodos, isPlan, normalise, open, progress, render, summary, type Plan } from "../src/plan/plan.ts"
import { Store } from "../src/session/store.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
const plan = normalise({
title: "Fix the login bug",
summary: "Two changes.",
findings: ["Session cookie is not renewed", "Test missing"],
objectives: ["Login works"],
phases: [
{ title: "Code", status: "done", tasks: [{ text: "Renew the cookie", status: "done" }, { text: "Add the test", status: "dropped", note: "not needed" }] },
{ title: "Verify", status: "active", tasks: [{ text: "Run the suite", status: "doing" }, "Tag it"] },
],
}) as Plan
test("render: the plan as text with its ids, one mark per task, a note after a dash", () => {
expect(render(plan)).toBe(
["# Fix the login bug", "Two changes.", "Findings: [f1] Session cookie is not renewed; [f2] Test missing", "Objectives: [o1] Login works (open)", "## p1 Code (done)", "[t1] [x] Renew the cookie", "[t2] [~] Add the test — not needed", "## p2 Verify (active)", "[t3] [>] Run the suite", "[t4] [ ] Tag it"].join("\n"),
)
// An empty summary, no findings and no objectives leave no line behind.
expect(render(normalise({ title: "T", phases: [{ title: "P", tasks: ["a"] }] }) as Plan)).toBe("# T\n## p1 P (pending)\n[t1] [ ] a")
})
test("progress leaves dropped tasks out; the panel's digest is the active phase, the count, what is doing, next, and the last two done", () => {
expect(progress(plan)).toEqual({ done: 1, total: 3 })
expect(open(plan)).toBe(true)
const s = summary(plan)
expect(s.head).toBe("plan 1/3 · Verify")
expect(s.lines).toEqual([
{ status: "doing", text: "Run the suite" },
{ status: "todo", text: "Tag it" },
{ status: "done", text: "Renew the cookie" },
])
// One phase: the plan's title heads the panel. Everything done or dropped: nothing open.
const one = fromTodos([{ content: "a", status: "completed" }, { content: "b", status: "cancelled" }]) as Plan
expect(summary(one).head).toBe("plan 1/1 · Work")
expect(open(one)).toBe(false)
expect(summary(normalise({ title: "Big", phases: [{ title: "P", tasks: ["1", "2", "3", "4", "5", "6", "7"] }] }) as Plan).lines).toHaveLength(5)
})
test("apply never changes the plan it was given, and refuses an `after` that is no task", () => {
const before = JSON.stringify(plan)
const r = apply(plan, { tasks: [{ id: "t4", status: "done" }], add_tasks: [{ phase: "p2", text: "Push", after: "t3" }] })
expect("plan" in r && r.plan.phases[1]!.tasks.map((t) => `${t.id}:${t.status}`)).toEqual(["t3:doing", "t5:todo", "t4:done"])
expect(JSON.stringify(plan)).toBe(before)
expect(apply(plan, { add_tasks: [{ phase: "p2", text: "x", after: "t99" }] })).toEqual({ error: 'Unknown task id "t99". Known: t1, t2, t3, t4.' })
expect(apply(plan, { add_tasks: [{ phase: "p2", text: " " }] })).toEqual({ error: "A new task needs its text." })
expect(apply(plan, { objectives: [{ id: "o1", status: "closed" }] })).toEqual({ error: 'Unknown status "closed" for an objective. One of: open, done, dropped.' })
expect(isPlan(fromTodos([]))).toBe(false)
})
test("the engine keeps the plan with the session: said on the bus, stored, back on resume, gone on /new", async () => {
fake = fakeProvider([
{ chunks: [toolCall(0, "p1", "plan_write", JSON.stringify({ title: "Work", phases: [{ title: "Work", tasks: ["one", "two"] }] }))] },
{ chunks: [toolCall(0, "p2", "plan_update", JSON.stringify({ tasks: [{ id: "t1", status: "done" }] }))] },
{ 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: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\ntitles: prompt\n")
const cwd = mkdtempSync(join(tmpdir(), "ph-planstore-"))
const store = new Store(join(cwd, "sessions.db"))
const a = createApp({ cwd, store, snapshots: false, asker: { ask: async () => ({ kind: "once" }) } })
const seen: (Plan | undefined)[] = []
a.bus.on((e: Event) => e.type === "plan" && seen.push(e.plan))
await a.engine.prompt("do it")
expect(seen.map((p) => p?.phases[0]!.tasks.map((t) => t.status))).toEqual([["todo", "todo"], ["done", "todo"]])
const id = a.engine.sessionId!
expect(store.meta<Plan>(id, "plan")?.steps).toEqual(["one", "two"])
// The plan is the session's: a new session starts without one, a resume brings it back.
a.engine.newSession(store.createSession(cwd, "f/m").id)
expect(a.engine.plan).toBeUndefined()
expect(seen.at(-1)).toBeUndefined()
a.engine.resume(id, [])
expect(a.engine.plan?.title).toBe("Work")
a.engine.setPlan(null)
expect(store.meta(id, "plan")).toBeNull()
a.engine.resume(id, [])
expect(a.engine.plan).toBeUndefined()
})
+27 -14
View File
@@ -1,18 +1,19 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import type { Asker, AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import type { PlanReply } from "../src/tool/plan_exit.ts"
import type { PlanReply, PlanRequest } from "../src/tool/plan_submit.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
const PLAN = "# Fix the average\n\n## Goal\nTests pass.\n\n## Changes\n1. calc.py: divide by len(values)\n"
function app(script: Parameters<typeof fakeProvider>[0], plan?: (r: { path: string; text: string }) => Promise<PlanReply>) {
const PLAN = { title: "Fix the average", objectives: [{ text: "Tests pass" }], phases: [{ title: "Changes", tasks: ["calc.py: divide by len(values)"] }] }
const RENDERED = "# Fix the average\nObjectives: [o1] Tests pass (open)\n## p1 Changes (pending)\n[t1] [ ] calc.py: divide by len(values)"
function app(script: Parameters<typeof fakeProvider>[0], plan?: (r: PlanRequest) => Promise<PlanReply>) {
fake = fakeProvider(script)
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: {} }\n`, { mode: 0o600 })
@@ -22,23 +23,35 @@ function app(script: Parameters<typeof fakeProvider>[0], plan?: (r: { path: stri
const asker: Asker = { ask: async (): Promise<AskReply> => ({ kind: "once" }), ...(plan ? { plan } : {}) }
return createApp({ cwd, mode: "plan", store: false, asker })
}
const writePlan = (name = "2026-09-29-fix.md") => toolCall(0, "w1", "write", JSON.stringify({ path: `.agent/plans/${name}`, content: PLAN }))
const exitPlan = (name = "2026-09-29-fix.md") => toolCall(0, "x1", "plan_submit", JSON.stringify({ path: `.agent/plans/${name}` }))
const writePlan = () => toolCall(0, "w1", "plan_write", JSON.stringify(PLAN))
// `path` is a former argument, ignored: the plan is the one written with plan_write.
const exitPlan = () => toolCall(0, "x1", "plan_submit", JSON.stringify({ path: ".agent/plans/old.md" }))
const names = (r: any) => r.tools.map((t: any) => t.function.name)
describe("plan mode", () => {
test("write the plan, plan_submit, approve → edit mode in the same turn, and plan_submit is no longer offered", async () => {
const seen: string[] = []
test("plan_write, plan_submit, approve → edit mode in the same turn, and plan_submit is no longer offered", async () => {
const seen: PlanRequest[] = []
const a = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "Implementing." })] }], async (r) => {
seen.push(r.path, r.text)
seen.push(r)
return { kind: "approve", mode: "edit" }
})
await a.engine.prompt("fix it")
expect(seen).toEqual([".agent/plans/2026-09-29-fix.md", PLAN])
// The card gets the plan, its text with ids, and the copy kept under plans/ for the record.
expect(seen).toHaveLength(1)
expect(seen[0]!.text).toBe(RENDERED)
expect(seen[0]!.plan.title).toBe("Fix the average")
expect(seen[0]!.path).toMatch(/^\.agent\/plans\/\d{4}-\d{2}-\d{2}-fix-the-average\.md$/)
expect(readFileSync(join(a.project.root, seen[0]!.path), "utf8")).toBe(RENDERED + "\n")
expect(a.engine.plan?.steps).toEqual(["calc.py: divide by len(values)"])
// The model read the plan back with its ids from plan_write.
expect(fake!.requests[1].messages.at(-1).content).toBe(RENDERED)
expect(names(fake!.requests[0])).toContain("plan_submit")
expect(names(fake!.requests[0])).toContain("plan_write")
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: plan")
expect(fake!.requests[2].messages[0].content).toContain("Permission mode: edit")
expect(names(fake!.requests[2])).not.toContain("plan_submit")
// plan_write and plan_update are offered in every mode: the plan is kept current while implementing.
expect(names(fake!.requests[2])).toContain("plan_update")
expect(fake!.requests[2].messages.at(-1).content).toContain("The user approved the plan")
expect(a.engine.mode).toBe("edit")
})
@@ -56,11 +69,11 @@ describe("plan mode", () => {
expect(b.engine.mode).toBe("plan")
})
test("plan_submit refuses a file outside the plans directory; edit mode does not offer it", async () => {
const a = app([{ chunks: [toolCall(0, "x1", "plan_submit", '{"path":"README.md"}')] }, { chunks: [delta({ content: "ok" })] }], async () => ({ kind: "approve", mode: "edit" }))
writeFileSync(join(a.project.root, "README.md"), "x")
test("plan_submit with no plan written is an error naming plan_write; edit mode does not offer it", async () => {
const a = app([{ chunks: [exitPlan()] }, { chunks: [delta({ content: "ok" })] }], async () => ({ kind: "approve", mode: "edit" }))
await a.engine.prompt("go")
expect(fake!.requests[1].messages.at(-1).content).toContain("must be a file under")
expect(fake!.requests[1].messages.at(-1).content).toContain("Write it with plan_write")
expect(a.engine.mode).toBe("plan")
a.engine.mode = "edit"
await a.engine.prompt("again").catch(() => {})
expect(names(fake!.requests.at(-1))).not.toContain("plan_submit")
+81
View File
@@ -0,0 +1,81 @@
// Prototypes in the terminal (harness spec 1.1.0, blocks/prototypes.md): a completed
// `prototype-chat` / `prototype-page` fence is saved as a file under the state directory, the
// model's output as it is, and drawn as one line in its place; /prototype lists them. How the
// fence's info line is read is the conformance area `prototype` (tests/conformance.ts).
import { afterEach, expect, test } from "bun:test"
import { existsSync, readFileSync, statSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../src/config/paths.ts"
import { parseInfo, splitPrototypes, titleFor } from "../src/prototype/fence.ts"
import { listPrototypes, prototypeDir, savePrototype, slugOf } from "../src/prototype/store.ts"
import { delta } from "./fake-provider.ts"
import { tui } from "./tui/harness.tsx"
let t: Awaited<ReturnType<typeof tui>> | undefined
afterEach(() => t?.stop())
const PAGE = "<!doctype html>\n<title>Bakery</title>\n<p>Fresh bread.</p>"
test("the info line: the kind whatever its case, the title the rest of the line or the page's own", () => {
expect(parseInfo("prototype-chat Monthly sales")).toEqual({ kind: "chat", title: "Monthly sales" })
expect(parseInfo("Prototype-Page")).toEqual({ kind: "page", title: "" })
expect(parseInfo("prototype")).toEqual({ kind: "chat", title: "" })
expect(parseInfo("html")).toBeNull()
expect(titleFor("<title> <b>From</b>\n the page </title>", "")).toBe("From the page")
expect(titleFor(PAGE, "Given")).toBe("Given")
expect(titleFor("<p>none</p>", "")).toBe("")
})
test("a reply split: markdown as it is, a prototype fence closed or still streaming, other code blocks left alone", () => {
const text = "Here:\n\n```prototype-page Bakery landing\n" + PAGE + "\n```\n\nAnd code:\n\n```js\nconsole.log(1)\n```\n\n```prototype-chat Chart\n<p>half"
const segs = splitPrototypes(text)
expect(segs.map((s) => s.kind)).toEqual(["text", "prototype", "text", "prototype"])
expect(segs[1]).toMatchObject({ kind: "prototype", info: { kind: "page", title: "Bakery landing" }, code: PAGE, closed: true, start: 7 })
expect(segs[2]).toEqual({ kind: "text", text: "\nAnd code:\n\n```js\nconsole.log(1)\n```\n" })
expect(segs[3]).toMatchObject({ kind: "prototype", closed: false, code: "<p>half" })
// A fence that is a longer run of backticks, or tildes, closes only with its own; `~~~` inside
// a backtick fence is content.
const tilde = splitPrototypes("~~~prototype\n```\nnot a fence\n```\n~~~\n")
expect(tilde[0]).toMatchObject({ kind: "prototype", closed: true, code: "```\nnot a fence\n```" })
expect(splitPrototypes("plain text, no fence")).toEqual([{ kind: "text", text: "plain text, no fence" }])
})
test("saved under the state directory, numbered, slugged from the title, 0600, once per content", () => {
const id = "ses_proto_test"
const path = savePrototype(id, PAGE, { kind: "page", title: "Bakery landing" })
expect(path).toBe(join(prototypeDir(id), "1-bakery-landing.html"))
expect(path.startsWith(join(paths.state, "prototypes"))).toBe(true)
expect(readFileSync(path, "utf8")).toBe(PAGE)
expect(statSync(path).mode & 0o777).toBe(0o600)
// The same page again is the same file; a different one is the next number, named by its
// own <title> when the fence gave none, by its kind when neither says.
expect(savePrototype(id, PAGE, { kind: "page", title: "Bakery landing" })).toBe(path)
expect(savePrototype(id, "<title>Sales: Q3</title><p>x</p>", { kind: "chat", title: "" })).toBe(join(prototypeDir(id), "2-sales-q3.html"))
expect(savePrototype(id, "<p>bare</p>", { kind: "chat", title: "" })).toBe(join(prototypeDir(id), "3-chat.html"))
expect(listPrototypes(id).map((p) => p.name)).toEqual(["1-bakery-landing.html", "2-sales-q3.html", "3-chat.html"])
expect(slugOf("Čaj & káva: ráno", "chat")).toBe("čaj-káva-ráno")
})
test("in the TUI a completed prototype fence is one line saying where it was saved; /prototype lists them", async () => {
const reply = "A page for you:\n\n```prototype-page Bakery landing\n" + PAGE + "\n```\n\nThat is the landing page."
t = await tui([{ chunks: [delta({ content: reply.slice(0, 40) }), delta({ content: reply.slice(40) })] }], { width: 120, height: 30 }, "", { store: true })
await t.input.typeText("make a page")
t.input.pressEnter()
expect(await t.until("That is the landing page.")).toBe(true)
const id = t.app.engine.sessionId!
const saved = listPrototypes(id)
expect(saved).toHaveLength(1)
expect(saved[0]!.name).toBe("1-bakery-landing.html")
expect(readFileSync(saved[0]!.path, "utf8")).toBe(PAGE)
// Drawn as the one line, not as the code block; the markdown around it as before.
expect(await t.until("prototype · Bakery landing · saved to")).toBe(true)
expect(t.frame()).toContain("A page for you:")
expect(t.frame()).not.toContain("Fresh bread.")
await t.input.typeText("/prototype")
t.input.pressEnter()
expect(await t.until(`1 ${saved[0]!.path}`.slice(0, 60))).toBe(true)
await t.input.typeText("/prototype 2")
t.input.pressEnter()
expect(await t.until("no prototype 2 here")).toBe(true)
expect(existsSync(saved[0]!.path)).toBe(true)
})
+1 -1
View File
@@ -32,7 +32,7 @@ test("every tool that can ask takes a purpose; bash says it in description; tool
for (const name of ["edit", "write", "read", "web_fetch", "skill_manage", "task"]) expect(spec(name).properties.purpose?.description).toContain("what this call is for")
expect(spec("bash").properties.purpose).toBeUndefined()
expect(spec("bash").properties.description?.description).toContain("what this command is for")
for (const name of ["ask_user", "todo", "plan_submit", "bash_output", "bash_list", "notes_search", "note_view", "note_manage"]) expect(spec(name).properties.purpose).toBeUndefined()
for (const name of ["ask_user", "plan_write", "plan_update", "plan_submit", "bash_output", "bash_list", "notes_search", "note_view", "note_manage"]) expect(spec(name).properties.purpose).toBeUndefined()
})
test("split: taken out of the arguments; a server's own `purpose` stays its own", () => {
+4 -4
View File
@@ -24,8 +24,8 @@ const contrast = (a: string, b: string) => {
// The themes that paint their own background (hermes and mono keep the terminal's).
const painted = Object.values(BUILTIN_THEMES).filter((t) => t.colors.bg)
test("the default is lembas, and every built-in theme has every colour as #rrggbb", () => {
expect(DEFAULT_THEME.name).toBe("lembas")
test("the default is moria, and every built-in theme has every colour as #rrggbb", () => {
expect(DEFAULT_THEME.name).toBe("moria")
expect(painted.map((t) => t.name).sort()).toEqual(["lembas", "lembas-light", "moria", "shire"])
for (const t of Object.values(BUILTIN_THEMES)) {
for (const k of Object.keys(HERMES.colors) as (keyof Theme["colors"])[]) expect({ theme: t.name, key: k, ok: /^#[0-9a-fA-F]{6}$/.test(t.colors[k] ?? "") }).toEqual({ theme: t.name, key: k, ok: true })
@@ -46,7 +46,7 @@ test("readable: text 7:1 on the background; what is read 4.5:1 on the background
expect(low).toEqual([])
})
test("a user theme in themes/ (or the old skins/) is merged over lembas; an unknown one falls back with a warning", () => {
test("a user theme in themes/ (or the old skins/) is merged over the default; an unknown one falls back with a warning", () => {
mkdirSync(join(paths.config, "themes"), { recursive: true })
writeFileSync(join(paths.config, "themes", "mine.yaml"), "colors:\n accent: '#ff00ff'\n")
mkdirSync(join(paths.config, "skins"), { recursive: true })
@@ -57,7 +57,7 @@ test("a user theme in themes/ (or the old skins/) is merged over lembas; an unkn
expect(mine.colors.bg).toBe(DEFAULT_THEME.colors.bg)
expect(loadTheme("old").theme.colors.title).toBe("#00ff00")
const none = loadTheme("nope")
expect(none.theme.name).toBe("lembas")
expect(none.theme.name).toBe("moria")
expect(none.warning).toContain('theme "nope" not found')
})
+21 -9
View File
@@ -6,9 +6,11 @@ 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 { formatAnswers, questionSchema, unattendedReply, type QuestionRequest } from "../src/tool/ask_user.ts"
import { readTool } from "../src/tool/read.ts"
import { todoTool } from "../src/tool/todo.ts"
import { planUpdateTool } from "../src/tool/plan_update.ts"
import { planWriteTool } from "../src/tool/plan_write.ts"
import type { Plan } from "../src/plan/plan.ts"
import type { ToolContext } from "../src/tool/tool.ts"
function ctx(): ToolContext {
@@ -163,15 +165,25 @@ describe("file tools", () => {
expect(readFileSync(join(c.root, "f.txt"), "utf8")).toBe("ONE\ntwo\nthree\nFOUR\n")
})
test("todo replaces the list and reports progress", async () => {
test("plan_write stores the plan with ids and shows it; plan_update changes it by id, and an unknown id changes nothing", 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")
let stored: Plan | undefined
const plan = { get: () => stored, set: (p: Plan | null) => (stored = p ?? undefined) }
// Through the schema, as the engine does: a bare string is a task, a phase sent as its title is empty.
const args = planWriteTool.schema.parse({ title: "Fix it", findings: ["cookie not renewed"], phases: [{ title: "Code", tasks: [{ text: "read", status: "done" }, { text: "fix", status: "doing" }, "test"] }, "Verify"] })
const r = await planWriteTool.run(args, { ...c, plan })
expect(r.output).toBe("# Fix it\nFindings: [f1] cookie not renewed\n## p1 Code (pending)\n[t1] [x] read\n[t2] [>] fix\n[t3] [ ] test\n## p2 Verify (pending)")
expect(r.title).toBe("1/3 done")
expect(todos.items).toHaveLength(3)
expect(stored?.steps).toEqual(["read", "fix", "test"])
const u = await planUpdateTool.run(planUpdateTool.schema.parse({ tasks: [{ id: "t2", status: "done" }, { id: "t3", status: "dropped", note: "covered" }], add_tasks: [{ phase: "p2", text: "run the suite" }] }), { ...c, plan })
expect(u.output).toBe("# Fix it\nFindings: [f1] cookie not renewed\n## p1 Code (pending)\n[t1] [x] read\n[t2] [x] fix\n[t3] [~] test — covered\n## p2 Verify (pending)\n[t4] [ ] run the suite")
expect(u.title).toBe("2/3 done")
const bad = await planUpdateTool.run(planUpdateTool.schema.parse({ tasks: [{ id: "t9", status: "done" }] }), { ...c, plan })
expect(bad).toEqual({ output: 'Unknown task id "t9". Known: t1, t2, t3, t4.', isError: true })
expect(stored?.phases[1]!.tasks).toHaveLength(1)
// No plan yet: plan_update says so instead of inventing one.
stored = undefined
expect((await planUpdateTool.run({ tasks: [{ id: "t1", status: "done" }] }, { ...c, plan })).isError).toBe(true)
})
})
+31 -11
View File
@@ -217,21 +217,42 @@ test("the question card: pick by number, multiple with space, ask back — and t
expect(result).toContain('↩ counter-question: "what does docker cost?"')
})
test("esc dismisses the questions; the todo panel shows open work", async () => {
test("esc dismisses the questions; the plan panel shows open work, and a plan call is not drawn as a tool line", async () => {
t = await tui([
{ chunks: [toolCall(0, "t1", "todo", JSON.stringify({ todos: [{ content: "read the code", status: "completed" }, { content: "fix the bug", status: "in_progress" }, { content: "run tests", status: "pending" }] })), toolCall(1, "q1", "ask_user", '{"question":"Go on?","options":["yes","no"]}')] },
{ chunks: [toolCall(0, "t1", "plan_write", JSON.stringify({ title: "The bug", phases: [{ title: "Work", status: "active", tasks: [{ text: "read the code", status: "done" }, { text: "fix the bug", status: "doing" }, "run tests"] }] })), toolCall(1, "q1", "ask_user", '{"question":"Go on?","options":["yes","no"]}')] },
{ chunks: [delta({ content: "Carrying on." })] },
])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Go on?")).toBe(true)
expect(t.frame()).toContain("todo 1/3")
expect(t.frame()).toContain("plan 1/3 · The bug")
expect(t.frame()).toContain("▸ fix the bug")
expect(t.frame()).toContain("☐ run tests")
expect(t.frame()).not.toContain("plan_write")
t.input.pressEscape()
expect(await t.until("Carrying on.")).toBe(true)
expect(t.fake.requests[1].messages.at(-1).content).toContain("dismissed")
})
test("the former todo tool still fills the panel; /plan show prints the plan with its ids; /plan clear drops it", async () => {
t = await tui([
{ chunks: [toolCall(0, "t1", "todo", JSON.stringify({ todos: [{ content: "read the code", status: "completed" }, { content: "fix the bug", status: "in_progress" }] }))] },
{ chunks: [delta({ content: "Working." })] },
])
await t.input.typeText("go")
t.input.pressEnter()
expect(await t.until("Working.")).toBe(true)
expect(t.frame()).toContain("plan 1/2 · Work")
await t.input.typeText("/plan show")
t.input.pressEnter()
expect(await t.until("[t2] [>] fix the bug")).toBe(true)
expect(t.frame()).toContain("## p1 Work (active)")
await t.input.typeText("/plan clear")
t.input.pressEnter()
expect(await t.until("plan cleared")).toBe(true)
expect(t.frame()).not.toContain("plan 1/2")
})
test("@ lists files; tab takes one; the model receives the file", async () => {
t = await tui([{ chunks: [delta({ content: "It says hello world." })] }])
await t.input.typeText("what does @hel")
@@ -306,22 +327,21 @@ test("pasting an image's path (quoted, as a file manager does) inserts an @refer
expect(t.frame()).toContain(`@${t.cwd}/my\\ shot.png`)
})
test("the plan card: the plan shown as markdown; a approves and the status bar says edit", async () => {
test("the plan card: the plan shown with its ids; a approves and the status bar says edit", async () => {
t = await tui([
{ chunks: [toolCall(0, "w1", "write", JSON.stringify({ path: ".agent/plans/2026-09-29-x.md", content: "# The plan\n\n## Changes\n- **one** thing\n" }))] },
{ chunks: [toolCall(0, "x1", "plan_submit", '{"path":".agent/plans/2026-09-29-x.md"}')] },
{ chunks: [toolCall(0, "w1", "plan_write", JSON.stringify({ title: "The plan", summary: "Two changes.", phases: [{ title: "Changes", tasks: ["one thing", "another"] }] }))] },
{ chunks: [toolCall(0, "x1", "plan_submit", "{}")] },
{ chunks: [delta({ content: "Implementing now." })] },
])
await t.input.typeText("/plan tidy up")
t.input.pressEnter()
expect(await t.until("Approve — edit mode")).toBe(true)
expect(await t.until("one thing")).toBe(true) // markdown is parsed off-thread; it draws a moment later
const f = t.frame()
expect(f).toContain("plan · .agent/plans/2026-09-29-x.md")
expect(f).toContain("plan · The plan")
const card = f.slice(f.indexOf("╭─ plan ·"))
expect(card).toContain("The plan")
expect(card).toContain("one thing")
expect(card).not.toContain("**one**") // rendered, not raw
expect(card).toContain("Two changes.")
expect(card).toContain("p1 Changes (pending)")
expect(card).toContain("[t1] [ ] one thing")
expect(f).toContain(" plan · fake/coder")
await t.input.typeText("a")
expect(await t.until("Implementing now.")).toBe(true)