Skip to content

Commit e76e7dd

Browse files
authored
Merge pull request #9 from codebase/codex/acp-harness
Add ACP harness support for Buzz
2 parents e7cae13 + 9b26911 commit e76e7dd

8 files changed

Lines changed: 689 additions & 4 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,35 @@ codebase project build --wait "build a launch waitlist page"
8686

8787
OAuth uses Codebase Auto by default (`codebase/d4f`, in-house DeepSeek V4 Flash). Swap models live with `/model`. Set reasoning depth with `/effort`. `project build` hands a prompt to the web builder and prints the session, status, event stream, and preview URL when you pass `--wait`.
8888

89+
## Use Codebase from an ACP client
90+
91+
Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) over stdio:
92+
93+
```sh
94+
codebase acp
95+
```
96+
97+
For a Buzz custom harness, use:
98+
99+
```text
100+
Name: Codebase
101+
Command: codebase
102+
Arguments: acp
103+
```
104+
105+
Buzz can then discover the models available to your Codebase account. Each ACP
106+
session gets its own working directory and conversation, accepts client-supplied
107+
MCP servers, streams assistant and tool progress, supports cancellation, and
108+
routes write/shell approvals through the client's permission UI.
109+
89110
## What makes it good
90111

91112
- **🏁 Tournaments.** `/tournament <task>` races several agents on the same change in isolated worktrees, a judge ranks them, you merge the winner. `--models opus,sonnet,haiku` pits models head-to-head on *your* code.
92113
- **Receipts.** `codebase auto --reliable` turns a one-shot task into an audited run: task lifecycle, per-task evidence, verification, tool calls, usage, and checkpoints are saved locally and inspectable with `codebase receipt`.
93114
- **↺ Rewind anything.** `/rewind` rolls the conversation *and* the files back to before any earlier prompt — a bad turn fully un-happens. Every edit is checkpointed.
94115
- **🧠 Remembers across sessions.** Pulls durable facts (your prefs, project decisions, the rules you set) out of a session, then recalls matching notes with file/source/session/last-used/staleness labels. `#note` to add one by hand; `/memory list|show|forget` to inspect or clean them up.
95116
- **🔌 MCP.** Connect external tool servers (filesystem, Postgres, git, fetch, …) over stdio or remote HTTP, OAuth and all. Their tools splice straight into the agent.
117+
- **ACP.** Run `codebase acp` to use Codebase from Buzz and other ACP clients, with model discovery, streamed tool activity, cancellation, permissions, and client-supplied MCP servers.
96118
- **🤖 Subagents.** Fan out read-only researchers or write-capable workers that keep their tool-noise out of your main context — each can run in its own git worktree, on its own model and reasoning level.
97119
- **🪝 Hooks.** Shell commands on lifecycle events (pre/post tool, edit, prompt, session start/end) — run a formatter on save, block secrets, commit on exit.
98120
- **🌐 SSH.** Run commands on enrolled remote hosts by name, behind the same safety validator as the local shell.

package-lock.json

Lines changed: 12 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.80",
3+
"version": "2.0.0-pre.81",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",
@@ -63,6 +63,7 @@
6363
"prepack": "npm run build"
6464
},
6565
"dependencies": {
66+
"@agentclientprotocol/sdk": "^1.3.0",
6667
"@earendil-works/pi-agent-core": "0.74.0",
6768
"@earendil-works/pi-ai": "0.74.0",
6869
"@earendil-works/pi-tui": "^0.74.0",

src/acp/server.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { PassThrough, Readable, Writable } from "node:stream";
5+
import { fileURLToPath } from "node:url";
6+
import * as acp from "@agentclientprotocol/sdk";
7+
import type { Model } from "@earendil-works/pi-ai";
8+
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider } from "@earendil-works/pi-ai";
9+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
10+
import { runAcpServer } from "./server.js";
11+
12+
const MOCK_MCP = fileURLToPath(new URL("../mcp/__test__/mock-server.mjs", import.meta.url));
13+
14+
describe("runAcpServer", () => {
15+
let faux: ReturnType<typeof registerFauxProvider>;
16+
let model: Model<string>;
17+
let home: string;
18+
let cwd: string;
19+
let previousHome: string | undefined;
20+
21+
beforeEach(() => {
22+
previousHome = process.env.HOME;
23+
home = mkdtempSync(join(tmpdir(), "acp-home-"));
24+
cwd = mkdtempSync(join(tmpdir(), "acp-cwd-"));
25+
process.env.HOME = home;
26+
process.env.CODEBASE_NO_AUTO_MEMORY = "1";
27+
faux = registerFauxProvider({
28+
models: [
29+
{
30+
id: "test-model",
31+
name: "Test Model",
32+
reasoning: false,
33+
input: ["text"],
34+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
35+
contextWindow: 100_000,
36+
maxTokens: 4096,
37+
},
38+
],
39+
tokenSize: { min: 1, max: 2 },
40+
});
41+
model = faux.models[0] as Model<string>;
42+
});
43+
44+
afterEach(() => {
45+
faux.unregister();
46+
rmSync(home, { recursive: true, force: true });
47+
rmSync(cwd, { recursive: true, force: true });
48+
if (previousHome === undefined) delete process.env.HOME;
49+
else process.env.HOME = previousHome;
50+
delete process.env.CODEBASE_NO_AUTO_MEMORY;
51+
});
52+
53+
it("runs a Buzz-compatible ACP turn with injected MCP tools and streamed updates", async () => {
54+
faux.setResponses([
55+
fauxAssistantMessage([fauxToolCall("mcp__buzz__echo", { text: "from Buzz" })], {
56+
stopReason: "toolUse",
57+
}),
58+
fauxAssistantMessage("posted through Buzz"),
59+
]);
60+
61+
const toAgent = new PassThrough();
62+
const fromAgent = new PassThrough();
63+
const serverDone = runAcpServer({
64+
stdin: toAgent,
65+
stdout: fromAgent,
66+
configOverride: { model, apiKey: "faux-key", source: "byok" },
67+
});
68+
const updates: acp.SessionNotification[] = [];
69+
const permissionRequests: acp.RequestPermissionRequest[] = [];
70+
const stream = acp.ndJsonStream(Writable.toWeb(toAgent), Readable.toWeb(fromAgent) as ReadableStream<Uint8Array>);
71+
72+
const result = await acp
73+
.client({ name: "buzz-test" })
74+
.onNotification(acp.methods.client.session.update, (ctx) => {
75+
updates.push(ctx.params);
76+
})
77+
.onRequest(acp.methods.client.session.requestPermission, (ctx) => {
78+
permissionRequests.push(ctx.params);
79+
const allow = ctx.params.options.find((option) => option.kind === "allow_once");
80+
if (!allow) return { outcome: { outcome: "cancelled" as const } };
81+
return { outcome: { outcome: "selected" as const, optionId: allow.optionId } };
82+
})
83+
.connectWith(stream, async (client) => {
84+
const initialized = await client.request(acp.methods.agent.initialize, {
85+
protocolVersion: acp.PROTOCOL_VERSION,
86+
clientInfo: { name: "buzz-acp", version: "test" },
87+
});
88+
expect(initialized.protocolVersion).toBe(acp.PROTOCOL_VERSION);
89+
expect(initialized.agentInfo?.name).toBe("Codebase");
90+
91+
const session = await client.request(acp.methods.agent.session.new, {
92+
cwd,
93+
mcpServers: [
94+
{
95+
name: "buzz",
96+
command: process.execPath,
97+
args: [MOCK_MCP],
98+
env: [],
99+
},
100+
],
101+
});
102+
expect(session.configOptions?.[0]).toMatchObject({
103+
id: "model",
104+
category: "model",
105+
currentValue: "test-model",
106+
});
107+
await client.request(acp.methods.agent.session.setConfigOption, {
108+
sessionId: session.sessionId,
109+
configId: "model",
110+
value: "test-model",
111+
});
112+
return client.request(acp.methods.agent.session.prompt, {
113+
sessionId: session.sessionId,
114+
prompt: [{ type: "text", text: "Reply through the Buzz tool." }],
115+
});
116+
});
117+
118+
expect(result.stopReason).toBe("end_turn");
119+
expect(permissionRequests).toHaveLength(1);
120+
expect(permissionRequests[0]?.toolCall.name).toBe("mcp__buzz__echo");
121+
expect(updates.some((event) => event.update.sessionUpdate === "tool_call")).toBe(true);
122+
expect(updates.some((event) => event.update.sessionUpdate === "tool_call_update")).toBe(true);
123+
const text = updates
124+
.filter(
125+
(
126+
event,
127+
): event is acp.SessionNotification & {
128+
update: { sessionUpdate: "agent_message_chunk"; content: { type: "text"; text: string } };
129+
} => event.update.sessionUpdate === "agent_message_chunk" && event.update.content.type === "text",
130+
)
131+
.map((event) => event.update.content.text)
132+
.join("");
133+
expect(text).toContain("posted through Buzz");
134+
135+
toAgent.end();
136+
await serverDone;
137+
});
138+
});

0 commit comments

Comments
 (0)