diff --git a/README.md b/README.md index a67414d..e45e8fb 100644 --- a/README.md +++ b/README.md @@ -94,15 +94,41 @@ Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) ov codebase acp ``` -For a Buzz custom harness, use: +### Buzz + +Buzz accepts Codebase as a custom ACP harness. Current Buzz builds only attach +their channel-publishing MCP to recognized runtime names, so use this small +compatibility launcher until Buzz supports MCP injection for every custom +harness: + +```sh +mkdir -p ~/.local/share/codebase-buzz +cat > ~/.local/share/codebase-buzz/codex-acp <<'SH' +#!/bin/sh +if [ "$#" -eq 1 ] && [ "$1" = "--version" ]; then + echo "codebase-acp 1.1.7" + exit 0 +fi +[ "$#" -gt 0 ] || set -- acp +exec codebase "$@" +SH +chmod +x ~/.local/share/codebase-buzz/codex-acp +``` + +Then add the Buzz custom harness: ```text Name: Codebase -Command: codebase -Arguments: acp +Command: /Users/YOU/.local/share/codebase-buzz/codex-acp +Arguments: (leave empty) ``` -Buzz can then discover the models available to your Codebase account. Each ACP +The launcher still runs Codebase; the `codex-acp` filename lets current Buzz +versions supply the MCP tools an agent needs to publish its answer into the +channel. Without that bridge, Buzz may show Codebase's generated text in agent +activity but never post it as a message. + +Buzz can discover the models available to your Codebase account. Each ACP session gets its own working directory and conversation, accepts client-supplied MCP servers, streams assistant and tool progress, supports cancellation, and routes write/shell approvals through the client's permission UI. diff --git a/package-lock.json b/package-lock.json index 012b24c..2442261 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codebase-cli", - "version": "2.0.0-pre.81", + "version": "2.0.0-pre.82", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codebase-cli", - "version": "2.0.0-pre.81", + "version": "2.0.0-pre.82", "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", diff --git a/package.json b/package.json index 125d185..1aaed78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codebase-cli", - "version": "2.0.0-pre.81", + "version": "2.0.0-pre.82", "description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.", "keywords": [ "ai", diff --git a/src/acp/server.test.ts b/src/acp/server.test.ts index 9295ce0..926d599 100644 --- a/src/acp/server.test.ts +++ b/src/acp/server.test.ts @@ -135,4 +135,60 @@ describe("runAcpServer", () => { toAgent.end(); await serverDone; }); + + it("streams provider failures as visible ACP messages", async () => { + faux.setResponses([ + fauxAssistantMessage([], { + stopReason: "error", + errorMessage: '402 "insufficient_credits"', + }), + ]); + + const toAgent = new PassThrough(); + const fromAgent = new PassThrough(); + const serverDone = runAcpServer({ + stdin: toAgent, + stdout: fromAgent, + configOverride: { model, apiKey: "faux-key", source: "byok" }, + }); + const updates: acp.SessionNotification[] = []; + const stream = acp.ndJsonStream(Writable.toWeb(toAgent), Readable.toWeb(fromAgent) as ReadableStream); + + const result = await acp + .client({ name: "buzz-error-test" }) + .onNotification(acp.methods.client.session.update, (ctx) => { + updates.push(ctx.params); + }) + .connectWith(stream, async (client) => { + await client.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientInfo: { name: "buzz-acp", version: "test" }, + }); + const session = await client.request(acp.methods.agent.session.new, { + cwd, + mcpServers: [], + }); + return client.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Reply even if the provider fails." }], + }); + }); + + expect(result.stopReason).toBe("end_turn"); + const text = updates + .filter( + ( + event, + ): event is acp.SessionNotification & { + update: { sessionUpdate: "agent_message_chunk"; content: { type: "text"; text: string } }; + } => event.update.sessionUpdate === "agent_message_chunk" && event.update.content.type === "text", + ) + .map((event) => event.update.content.text) + .join(""); + expect(text).toContain("Codebase couldn't complete this turn"); + expect(text).toContain("Codebase credits are exhausted"); + + toAgent.end(); + await serverDone; + }); }); diff --git a/src/acp/server.ts b/src/acp/server.ts index 6f45772..889a678 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -7,6 +7,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core"; import type { ImageContent } from "@earendil-works/pi-ai"; import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js"; import type { ModelOption } from "../agent/model-list.js"; +import { latestAssistantError, userFacingErrorMessage } from "../errors/user-facing.js"; import type { NamedServer } from "../mcp/config.js"; import type { PermissionRequest, ResponseChoice } from "../permissions/store.js"; import { VERSION } from "../version.js"; @@ -165,7 +166,14 @@ export async function runAcpServer(options: AcpServerOptions = {}): Promise message.role === "assistant"); + if (!lastAssistant) return undefined; + const candidate = lastAssistant as { stopReason?: unknown; errorMessage?: unknown }; + if (typeof candidate.errorMessage === "string" && candidate.errorMessage.trim()) return candidate.errorMessage; + if (candidate.stopReason === "error") return "Agent turn ended with an error."; + return undefined; } diff --git a/src/headless/run.ts b/src/headless/run.ts index 9c31a87..7390bf0 100644 --- a/src/headless/run.ts +++ b/src/headless/run.ts @@ -3,7 +3,7 @@ import type { Usage } from "@earendil-works/pi-ai"; import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js"; import { ConfigError } from "../agent/config.js"; import { visibleMessages } from "../agent/visible-messages.js"; -import { userFacingErrorMessage } from "../errors/user-facing.js"; +import { latestAssistantError, userFacingErrorMessage } from "../errors/user-facing.js"; import { ReceiptStore } from "./receipt-store.js"; import { formatReliabilityFailure, @@ -535,15 +535,6 @@ function extractText(message: { content?: unknown }): string { return parts.join(""); } -function latestAssistantError(messages: AgentMessage[]): string | undefined { - const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); - if (!lastAssistant) return undefined; - const candidate = lastAssistant as { stopReason?: unknown; errorMessage?: unknown }; - if (typeof candidate.errorMessage === "string" && candidate.errorMessage.trim()) return candidate.errorMessage; - if (candidate.stopReason === "error") return "Agent turn ended with an error."; - return undefined; -} - function latestAssistantText(messages: AgentMessage[]): string { const lastAssistant = [...messages] .reverse()