CLI loads harness spec 3.0.0-draft; the show tool over the link

The spec's version may be a pre-release (3.0.0-draft). Every tool file is
loaded, with the new fields `sessions`, `requires`, `unavailable` and
`confirm`; the CLI's tools are those not the server's alone and offered in an
agent session (forCli) — a chat's own tools over the server's tree (mkdir, mv,
rm, search, python, the forge verbs) are not built in. blocks/context.md is
known; blocks/workspace.md is the server's alone.

show is built in with `requires: display`: offered only in a session a client
opened over the link when that client said `show: true` (capability `show`);
the device reads the file (at most 1 MB) and sends it with `_lembas/show`.
The harness tests now say 3.0.0's split of tools instead of "the execution
tools are the CLI's alone".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-27 20:00:02 +00:00
1 parent 9f536d8345
commit bc78aec424
10 files changed
+200 -24

No files matched your search

+21
View File
@@ -274,6 +274,9 @@ export const CAPABILITIES = [
// `_lembas/jobs` and `_lembas/job/*` methods, `jobs` in `status`, `job` on an approval, and a
// note's turn marked `machine`.
"jobs",
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
// said it can show files; the device sends the file with `_lembas/show`.
"show",
] as const
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
@@ -427,6 +430,9 @@ export class AcpAgent {
private get cards() {
return this.lembas && this.clientProtocol >= 2
}
/** The client shows files (`show: true` in `clientCapabilities._meta.lembas`, spec 3.0.0): the
* show tool is offered in the sessions it opens, and nowhere else. */
private shows = false
/** initialize has been answered: notifications about sessions may go out from now on. */
private ready = false
@@ -486,6 +492,8 @@ export class AcpAgent {
const said = p?.clientCapabilities?._meta?.lembas
this.lembas = Boolean(said)
this.clientProtocol = typeof said?.protocol === "number" ? said.protocol : 1
this.shows = said?.show === true
for (const s of this.sessions.values()) if (s.agent === this) this.equipShow(s)
// The sessions this link took over (the hub's) get their command set once the client
// knows the agent — after this answer, not before it.
setTimeout(() => {
@@ -686,6 +694,7 @@ export class AcpAgent {
private adopt(s: Session) {
s.off?.()
s.agent = this
this.equipShow(s)
s.off = s.app.bus.on((e) => {
if (s.agent !== this) return
this.forward(s, e)
@@ -694,6 +703,18 @@ export class AcpAgent {
})
}
/** The show tool's display, for a session this link holds: the client's viewer, reached with
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
private equipShow(s: Session) {
const ctx = s.app.engine.o.toolCtx
if (!this.shows) return void delete ctx.show
ctx.show = async (req, callId) => {
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined
return r?.shown === true ? { shown: true } : { shown: false, reason: typeof r?.reason === "string" && r.reason ? r.reason : "the client did not show it" }
}
}
/** `available_commands_update`: the session's command set, when it changed (or always). */
private sendCommands(s: Session, always = false) {
if (!this.ready || this.peer.isClosed || s.agent !== this) return
+34 -3
View File
@@ -7,6 +7,8 @@ import LOOP_JSON from "../harness/loop.json"
import t_apply_patch from "../harness/tools/apply_patch.json"
import t_ask_user from "../harness/tools/ask_user.json"
import t_bash from "../harness/tools/bash.json"
import t_branch from "../harness/tools/branch.json"
import t_commit from "../harness/tools/commit.json"
import t_decisions from "../harness/tools/decisions.json"
import t_edit from "../harness/tools/edit.json"
import t_glob from "../harness/tools/glob.json"
@@ -19,20 +21,31 @@ import t_knowledge_get from "../harness/tools/knowledge_get.json"
import t_knowledge_search from "../harness/tools/knowledge_search.json"
import t_list from "../harness/tools/list.json"
import t_memory from "../harness/tools/memory.json"
import t_merge from "../harness/tools/merge.json"
import t_mkdir from "../harness/tools/mkdir.json"
import t_multiedit from "../harness/tools/multiedit.json"
import t_mv from "../harness/tools/mv.json"
import t_note_manage from "../harness/tools/note_manage.json"
import t_note_view from "../harness/tools/note_view.json"
import t_notes_search from "../harness/tools/notes_search.json"
import t_plan_submit from "../harness/tools/plan_submit.json"
import t_plan_update from "../harness/tools/plan_update.json"
import t_plan_write from "../harness/tools/plan_write.json"
import t_pr from "../harness/tools/pr.json"
import t_python from "../harness/tools/python.json"
import t_read from "../harness/tools/read.json"
import t_recall from "../harness/tools/recall.json"
import t_release from "../harness/tools/release.json"
import t_report from "../harness/tools/report.json"
import t_rm from "../harness/tools/rm.json"
import t_search from "../harness/tools/search.json"
import t_session_search from "../harness/tools/session_search.json"
import t_settings from "../harness/tools/settings.json"
import t_show from "../harness/tools/show.json"
import t_skill_manage from "../harness/tools/skill_manage.json"
import t_skill_view from "../harness/tools/skill_view.json"
import t_skills_list from "../harness/tools/skills_list.json"
import t_tag from "../harness/tools/tag.json"
import t_task from "../harness/tools/task.json"
import t_tasks from "../harness/tools/tasks.json"
import t_view_image from "../harness/tools/view_image.json"
@@ -43,9 +56,11 @@ import { fillPrompt } from "./prompt/fill.ts"
export interface ToolSpecDef {
name: string
/** shared: both projects implement it; cli / llembas: only that one does. The execution tools
* (bash, edit, read…) are the CLI's: the web UI runs no agent of its own. */
/** shared: both projects implement it; cli / llembas: only that one does. */
scope: "shared" | "cli" | "llembas"
/** Spec 3.0.0: where it is offered — a chat on the server (its file system), an agent session
* on a device (this CLI, on its own disk), or both. */
sessions: "chat" | "agent" | "both"
risk: "read" | "write" | "execute" | "interact"
exclusive: boolean
/** How a call is shown in a transcript: a shell command, an edit with its diff, a
@@ -60,6 +75,13 @@ export interface ToolSpecDef {
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
offered?: "subagent"
argument_aliases?: Record<string, string>
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's). */
requires?: ("display" | "page" | "forge")[]
/** What a call answers, word for word, when a requirement went away after the offer. */
unavailable?: string
/** `always`: put to the person in every mode, auto included; never remembered. */
confirm?: "always"
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
variables?: Record<string, string>
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
@@ -68,6 +90,8 @@ export interface ToolSpecDef {
parameters: Record<string, unknown>
}
/** `X.Y.Z`, or a SemVer pre-release while the spec is written ahead of both implementations
* (`3.0.0-draft`). */
export const HARNESS_VERSION = VERSION.trim()
/** How one prompt runs (harness/loop.json): the step backstop, the budgets' defaults, the wrap-up
@@ -163,9 +187,16 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_report, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
)
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
* offered in an agent session (`sessions` agent or both) — a chat's tools over the server's tree
* (mkdir, mv, rm, search, python, the forge verbs) are not the CLI's. */
export function forCli(t: ToolSpecDef): boolean {
return t.scope !== "llembas" && t.sessions !== "chat"
}
/** A tool's name, description and `exclusive` flag as the spec has them, for its definition:
* `...described("read")`. With `vars`, the description filled and the values kept for its
* parameters (toSpec). */
+5 -2
View File
@@ -16,6 +16,7 @@ import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with {
import blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" }
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" }
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
@@ -39,8 +40,9 @@ import personalityFunny from "../../harness/prompts/personality/funny.md" with {
import personalityFormal from "../../harness/prompts/personality/formal.md" with { type: "text" }
import personalitySocratic from "../../harness/prompts/personality/socratic.md" with { type: "text" }
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas).
// Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas):
// every text whose scope is shared or cli. The server's own (blocks/workspace.md, a chat's view of
// its file system) is not here. Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
const BUILTIN: Record<string, string> = {
"system/default.md": defaultSystem,
"system/identity.md": identity,
@@ -53,6 +55,7 @@ const BUILTIN: Record<string, string> = {
"blocks/library.md": blockLibrary,
"blocks/prototypes.md": blockPrototypes,
"blocks/subagent.md": blockSubagent,
"blocks/context.md": blockContext,
"agents/explore.md": agentExplore,
"agents/general.md": agentGeneral,
"modes/manual.md": modeManual,
+1
View File
@@ -353,6 +353,7 @@ export class Engine {
if (!t?.requires) return true
if (t.requires === "vision") return this.model.spec.vision === true
if (t.requires === "project") return this.o.toolCtx.projectDir !== undefined
if (t.requires === "display") return this.o.toolCtx.show !== undefined
if (t.requires === "knowledge") {
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
+2
View File
@@ -17,6 +17,7 @@ import { memoryTool } from "./memory.ts"
import { sessionSearchTool } from "./session_search.ts"
import { skillManageTool, skillsListTool, skillViewTool } from "./skills.ts"
import { settingsTool } from "./settings.ts"
import { showTool } from "./show.ts"
import { knowledgeGetTool, knowledgeSearchTool, noteManageTool, notesSearchTool, noteViewTool } from "./library.ts"
import type { Tool } from "./tool.ts"
import { writeTool } from "./write.ts"
@@ -56,6 +57,7 @@ export const BUILTIN_TOOLS: Tool[] = [
skillManageTool,
settingsTool,
viewImageTool,
showTool,
// Offered to subagents only (the spec's `offered: subagent`; app.ts SUBAGENT_ONLY).
reportTool,
] as Tool[]
+76
View File
@@ -0,0 +1,76 @@
// show (harness spec 3.0.0): a file opened in the user's viewer beside the conversation. A terminal
// has no such viewer, so the tool is offered only in a session a client opened over the link and
// only when that client said it can show files (`show: true`, harness/acp.md `_lembas/show`): the
// device reads the file and sends it, since the client cannot reach this disk.
import { statSync } from "node:fs"
import { basename, extname, relative } from "node:path"
import { z } from "zod"
import { described, TOOL_SPECS } from "../harness.ts"
import { absPath, defineTool, readText, showPath, ToolError } from "./tool.ts"
/** The most a file may be to be sent (acp.md: at most 1 MB; a larger one is refused before sending). */
export const SHOW_MAX_BYTES = 1024 * 1024
/** The answer when nothing can be shown (spec `unavailable`), word for word. */
export const SHOW_UNAVAILABLE = TOOL_SPECS.show!.unavailable!
/** What `_lembas/show` carries for one file. */
export interface ShowRequest {
path: string
kind: "markdown" | "code" | "text" | "html"
language?: string
line?: number
text: string
}
export type ShowReply = { shown: true } | { shown: false; reason: string }
// A known source extension is code, with its language; the list is the viewer's highlighters.
const LANGUAGES: Record<string, string> = {
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
}
/** The viewer's kind for a file, by its name (acp.md): `.md` markdown, `.html`/`.htm` html, a known
* source extension code, anything else text. */
export function showKind(path: string): { kind: ShowRequest["kind"]; language?: string } {
const ext = extname(path).slice(1).toLowerCase()
if (ext === "md" || ext === "markdown") return { kind: "markdown" }
if (ext === "html" || ext === "htm") return { kind: "html" }
const language = LANGUAGES[ext] ?? (basename(path) === "Dockerfile" ? "dockerfile" : basename(path) === "Makefile" ? "makefile" : undefined)
return language ? { kind: "code", language } : { kind: "text" }
}
export const showTool = defineTool({
...described("show"),
access: "show",
requires: "display",
schema: z.object({ path: z.string(), line: z.number().int().min(1).optional() }),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "show", class: "interact", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async run(a, ctx) {
// Offered with a display; one that went away since answers the spec's sentence.
if (!ctx.show) return { output: SHOW_UNAVAILABLE, isError: true }
const abs = absPath(a.path, ctx)
let size: number
try {
const st = statSync(abs)
if (!st.isFile()) throw new ToolError(`${showPath(abs, ctx)} is not a file.`)
size = st.size
} catch (e) {
if (e instanceof ToolError) throw e
throw new ToolError(`${showPath(abs, ctx)} does not exist. Write it first.`)
}
if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
const text = await readText(abs)
if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`)
const rel = relative(ctx.cwd, abs) || basename(abs)
const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId)
if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true }
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
},
})
+7 -2
View File
@@ -67,6 +67,9 @@ export interface ToolContext {
set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult
models(): string[]
}
/** Open a file in the user's viewer (the show tool): set only where a client over the link said
* it can show files (harness/acp.md, capability `show`). Absent: no display, and show is not offered. */
show?: (req: import("./show.ts").ShowRequest, callId?: string) => Promise<import("./show.ts").ShowReply>
/** The scope sent on every call to the instance's library (`_meta.lembas.scope`, harness/acp.md):
* the session's id, where the client has said what the session may search. */
libraryScope?: string
@@ -112,8 +115,10 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
access: string
/** Runs alone, never alongside other calls of the same step (it waits on the user). */
exclusive?: boolean
/** Offered only when this holds: a model with vision, plan mode, or a project directory. */
requires?: "vision" | "plan" | "project" | "knowledge"
/** Offered only when this holds: a model with vision, plan mode, a project directory, knowledge
* bases with documents, or a display (a client over the link that shows files: the spec's
* `requires: display`). */
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
description: string
schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
+22 -2
View File
@@ -64,7 +64,7 @@ const limitsFor = (root: string, extra: Partial<Limits> = {}): Limits => ({ root
/** A fake LLeMbas on one side of the agent. `lembas: false`: an editor that is not LLeMbas. */
/** `protocol`: what the client says it speaks (2 by default; 1 is an older client). */
async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; instance?: string; hub?: Hub; handlers?: Record<string, (p: any) => unknown> } = {}) {
async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number; instance?: string; hub?: Hub; show?: boolean; handlers?: Record<string, (p: any) => unknown> } = {}) {
const [ta, tb] = pair()
new AcpAgent(new Peer(ta), limits, o.instance, o.hub)
const c = new Peer(tb)
@@ -76,7 +76,7 @@ async function client(limits?: Limits, o: { lembas?: boolean; protocol?: number;
c.on("_lembas/permission/settled", (p) => void notes.push({ method: "settled", params: p }))
c.handle("session/request_permission", () => ({ outcome: { outcome: "selected", optionId: "once" } }))
for (const [m, fn] of Object.entries(o.handlers ?? {})) c.handle(m, fn)
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: o.lembas === false ? {} : { _meta: { lembas: o.protocol === 1 ? true : { protocol: o.protocol ?? 2 } } } })
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: o.lembas === false ? {} : { _meta: { lembas: o.protocol === 1 ? true : { protocol: o.protocol ?? 2, ...(o.show ? { show: true } : {}) } } } })
return { c, updates, events, notes, init }
}
@@ -312,6 +312,26 @@ test("initialize lists the 1.1.0 capabilities", async () => {
for (const cap of ["context", "library", "prototypes", "plan_state", "rewind"]) expect(init._meta.lembas.capabilities).toContain(cap)
})
test("show (spec 3.0.0): offered only to a client that shows files; the file goes over _lembas/show", async () => {
const page = "<!doctype html><h1>Hi</h1>"
fake = fakeProvider([{ chunks: [toolCall(0, "s1", "show", JSON.stringify({ path: "out/page.html", line: 1 }))] }, { chunks: [delta({ content: "there" })] }, { chunks: [delta({ content: "plain" })] }])
config(fake.url)
const root = project({ "out/page.html": page })
let shown: any
const { c, init } = await client(limitsFor(root), { show: true, handlers: { "_lembas/show": (p) => ((shown = p), { shown: true }) } })
expect(init._meta.lembas.capabilities).toContain("show")
const s: any = await c.request("session/new", { cwd: root })
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "make a page" }] })
expect(fake.requests[0].tools.map((t: any) => t.function.name)).toContain("show")
expect(shown).toEqual({ sessionId: s.sessionId, toolCallId: "s1", path: "out/page.html", kind: "html", line: 1, text: page })
expect(fake.requests[1].messages.find((m: any) => m.role === "tool").content).toContain("Shown to the user: out/page.html")
// A client that did not say it shows files: no show tool.
const other = await client(limitsFor(root))
const s2: any = await other.c.request("session/new", { cwd: root })
await other.c.request("session/prompt", { sessionId: s2.sessionId, prompt: [{ type: "text", text: "hi" }] })
expect(fake.requests[2].tools.map((t: any) => t.function.name)).not.toContain("show")
})
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")
+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", "context", "library", "prototypes", "plan_state", "rewind", "jobs"])
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", "jobs", "show"])
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 })
+31 -14
View File
@@ -5,7 +5,7 @@ import { expect, test } from "bun:test"
import { readdirSync, readFileSync } from "node:fs"
import { join, relative } from "node:path"
import { z } from "zod"
import { fillDescription, HARNESS_VERSION, narrowParameters, PURPOSE_DEF, TOOL_SPECS } from "../src/harness.ts"
import { fillDescription, forCli, HARNESS_VERSION, narrowParameters, PURPOSE_DEF, TOOL_SPECS } from "../src/harness.ts"
import { createApp, stricterMode } from "../src/app.ts"
import { paths } from "../src/config/paths.ts"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
@@ -19,7 +19,7 @@ import { purposeKey, toSpec, type Tool, type ToolContext } from "../src/tool/too
const HARNESS = join(import.meta.dir, "..", "harness")
const ours: Tool[] = [...BUILTIN_TOOLS]
const forUs = Object.values(TOOL_SPECS).filter((t) => t.scope !== "llembas")
const forUs = Object.values(TOOL_SPECS).filter(forCli)
/** A JSON Schema with its prose and zod's integer bounds taken out: the shape a call must have. */
function shape(o: unknown): unknown {
@@ -52,7 +52,8 @@ function sample(schema: Record<string, unknown>): Record<string, unknown> {
const ctx = { root: "/tmp/ph-spec", cwd: "/tmp/ph-spec", signal: new AbortController().signal, readFiles: new Set(), fileStamps: new Map(), bashTimeoutMs: 1000 } as unknown as ToolContext
test("the spec has a version, and one file per tool, named as the tool", () => {
expect(HARNESS_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
// X.Y.Z, or a pre-release while the spec runs ahead of both implementations (3.0.0-draft).
expect(HARNESS_VERSION).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/)
const files = readdirSync(join(HARNESS, "tools")).filter((f) => f.endsWith(".json"))
expect(files.length).toBe(Object.keys(TOOL_SPECS).length)
for (const f of files) expect(JSON.parse(readFileSync(join(HARNESS, "tools", f), "utf8")).name).toBe(f.replace(/\.json$/, ""))
@@ -68,7 +69,8 @@ test("every tool says how it is shown (block), from the spec's list", () => {
})
test("every tool the spec gives the CLI is built in, and every built-in tool is in the spec", () => {
expect(ours.map((t) => t.name).sort()).toEqual(forUs.map((t) => t.name).sort())
// recall comes with context hygiene (harness/context.md), in the commit that implements it.
expect(ours.map((t) => t.name).sort()).toEqual(forUs.map((t) => t.name).filter((n) => n !== "recall").sort())
})
for (const t of ours) {
@@ -112,10 +114,14 @@ test("read and write never share a tool: every tool carries one risk, and a read
}
})
test("every prompt text is in harness/prompts, and every file there is one the CLI knows", () => {
test("every prompt text is in harness/prompts, and every file there that is not the server's alone is one the CLI knows", () => {
const walk = (d: string): string[] => readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(join(d, e.name)) : e.name.endsWith(".md") ? [join(d, e.name)] : []))
const files = walk(join(HARNESS, "prompts")).map((f) => relative(join(HARNESS, "prompts"), f))
for (const f of files) expect(() => promptText(f)).not.toThrow()
const index = JSON.parse(readFileSync(join(HARNESS, "prompts", "index.json"), "utf8")) as { prompts: Record<string, { scope: string }> }
for (const f of files) {
if (index.prompts[f]?.scope === "llembas") expect(() => promptText(f)).toThrow()
else expect(() => promptText(f)).not.toThrow()
}
})
test("prompts/index.json lists every prompt text, each with a scope", () => {
@@ -126,18 +132,29 @@ test("prompts/index.json lists every prompt text, each with a scope", () => {
for (const p of Object.values(index.prompts)) expect(["shared", "cli", "llembas"]).toContain(p.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", "edit", "glob", "grep", "list", "multiedit", "read", "write"]
for (const n of exec) expect(TOOL_SPECS[n]!.scope).toBe("cli")
test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's, never a chat's", () => {
for (const t of Object.values(TOOL_SPECS)) expect(["chat", "agent", "both"]).toContain(t.sessions)
// The file tools are shared and offered in both worlds: here on the device's disk, in a chat over
// the server's tree. multiedit, apply_patch and bash stay the agent's.
for (const n of ["read", "write", "edit", "glob", "grep", "list"]) expect([TOOL_SPECS[n]!.scope, TOOL_SPECS[n]!.sessions]).toEqual(["shared", "both"])
for (const n of ["apply_patch", "bash", "multiedit"]) expect([TOOL_SPECS[n]!.scope, TOOL_SPECS[n]!.sessions]).toEqual(["cli", "agent"])
// A chat's own tools over the server's tree are not the CLI's, and none is built in here.
const chats = ["branch", "commit", "merge", "mkdir", "mv", "pr", "python", "release", "rm", "search", "tag"]
for (const n of chats) {
expect(TOOL_SPECS[n]!.sessions).toBe("chat")
expect(forCli(TOOL_SPECS[n]!)).toBe(false)
expect(ours.some((t) => t.name === n)).toBe(false)
}
// The local library stays the CLI's alone; a chat's notes and knowledge are files now.
for (const n of ["knowledge_get", "knowledge_search", "note_manage", "note_view", "notes_search"]) expect([TOOL_SPECS[n]!.scope, TOOL_SPECS[n]!.sessions]).toEqual(["cli", "agent"])
// show needs a display: offered only where a client over the link shows files.
expect(TOOL_SPECS.show!.requires).toEqual(["display"])
expect(ours.find((t) => t.name === "show")!.requires).toBe("display")
// 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"])
// Spec 2.0.0: the job tools cover both kinds of background work and are shared (the CLI's former
// bash_* names are their aliases); report is a subagent's.
const shared = ["ask_user", "job_list", "job_output", "job_send", "job_stop", "knowledge_get", "knowledge_search", "memory", "note_manage", "note_view", "notes_search", "plan_submit", "plan_update", "plan_write", "report", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search"]
for (const n of shared) expect(TOOL_SPECS[n]!.scope).toBe("shared")
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "web_fetch", "web_search", "write"]
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
})