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:
10 files changed
+200
-24
No files matched your search
+22
-2
@@ -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
@@ -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
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user