Files
LLeMbas-CLI/tests/plan.test.ts
T
HomerandClaude Opus 5.5 8e06590d57
ci / check (push) Canceled after 0s
Harness spec 3.1.0: the prefix stays still, in the CLI
The system prompt is made once per session (start, compaction, model
switch), kept in session_meta and resent byte for byte; it holds no
date. What changes mid-session -- the date, a mode the person chose, a
plan or memory or instruction file they edited, a personality, MCP
instructions that arrived later -- is a <session-note> on the next user
message, stored with it. The tool list is fixed for the session:
plan_submit is always offered and answers unavailable outside plan
mode, the wrap-up keeps the tools and runs no call, a withdrawn tool
stays listed and answers its refusal; the first request waits up to
mcp_wait_ms for MCP servers. Compaction asks for its summary as the
next turn of the same conversation. Cached input tokens are parsed
(incl. llama.cpp timings), stored per request and shown in /context.

Spec: context.md §7/§10, loop.json context.note (+ plan_ended,
plan_own, personal, empty, mcp_wait_ms), modes.json plan.refused,
compact.md without the transcript heading. Conformance area prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:22:02 +00:00

93 lines
5.7 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
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, PlanRequest } from "../src/tool/plan_submit.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
import { TOOL_SPECS } from "../src/harness.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
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 type: webui\n url: ${fake.url}\n models: { f/m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
const cwd = mkdtempSync(join(tmpdir(), "ph-plan-"))
Bun.spawnSync(["git", "init", "-q", cwd])
const asker: Asker = { ask: async (): Promise<AskReply> => ({ kind: "once" }), ...(plan ? { plan } : {}) }
return createApp({ cwd, mode: "plan", store: false, asker })
}
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("plan_write, plan_submit, approve → edit mode in the same turn; the prompt and tools stay as they were", async () => {
const seen: PlanRequest[] = []
const a = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "Implementing." })] }], async (r) => {
seen.push(r)
return { kind: "approve", mode: "edit" }
})
await a.engine.prompt("fix it")
// 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(`[#0001]\n${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")
// Spec 3.1.0 (context.md §10): the system prompt and the tool list are the session's, byte for
// byte; the approval's own result tells the model the mode, so no note says it again.
expect(fake!.requests[2].messages[0]).toEqual(fake!.requests[0].messages[0])
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
expect(names(fake!.requests[2])).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")
})
test("revise and ask back come back as instructions; nobody present means saved, stop", async () => {
const a = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "ok" })] }], async () => ({ kind: "revise", feedback: "use a helper" }))
await a.engine.prompt("fix it")
expect(fake!.requests[2].messages.at(-1).content).toContain("revised: use a helper")
expect(a.engine.mode).toBe("plan")
fake!.stop()
const b = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "Plan saved." })] }])
await b.engine.prompt("fix it")
expect(fake!.requests[2].messages.at(-1).content).toContain("Nobody is present to approve the plan")
expect(b.engine.mode).toBe("plan")
})
test("plan_submit with no plan written is an error naming plan_write; outside plan mode it is offered and answers unavailable", async () => {
const a = app([{ chunks: [exitPlan()] }, { chunks: [delta({ content: "ok" })] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "carrying on" })] }], async () => ({ kind: "approve", mode: "edit" }))
await a.engine.prompt("go")
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")
const [again, after] = fake!.requests.slice(-2)
expect(names(again)).toContain("plan_submit")
expect(again.tools).toEqual(fake!.requests[0].tools)
expect(again.messages[0]).toEqual(fake!.requests[0].messages[0])
// The person's mode change comes as a note on their next message, the mode's own text with it.
expect(again.messages.at(-1).content).toStartWith("again\n<session-note>\nThe permission mode is now edit:\nPermission mode: edit.")
expect(after.messages.at(-1).content).toBe(`[#0002]\n${TOOL_SPECS.plan_submit!.unavailable}`)
expect(a.engine.mode).toBe("edit")
})
})