Skip to content

Commit 930ca89

Browse files
authored
Merge pull request #11 from codebase/codex/acp-visible-errors
Fix Buzz ACP replies and document harness setup
2 parents 585add4 + 34cc30c commit 930ca89

7 files changed

Lines changed: 120 additions & 19 deletions

File tree

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,41 @@ Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) ov
9494
codebase acp
9595
```
9696

97-
For a Buzz custom harness, use:
97+
### Buzz
98+
99+
Buzz accepts Codebase as a custom ACP harness. Current Buzz builds only attach
100+
their channel-publishing MCP to recognized runtime names, so use this small
101+
compatibility launcher until Buzz supports MCP injection for every custom
102+
harness:
103+
104+
```sh
105+
mkdir -p ~/.local/share/codebase-buzz
106+
cat > ~/.local/share/codebase-buzz/codex-acp <<'SH'
107+
#!/bin/sh
108+
if [ "$#" -eq 1 ] && [ "$1" = "--version" ]; then
109+
echo "codebase-acp 1.1.7"
110+
exit 0
111+
fi
112+
[ "$#" -gt 0 ] || set -- acp
113+
exec codebase "$@"
114+
SH
115+
chmod +x ~/.local/share/codebase-buzz/codex-acp
116+
```
117+
118+
Then add the Buzz custom harness:
98119

99120
```text
100121
Name: Codebase
101-
Command: codebase
102-
Arguments: acp
122+
Command: /Users/YOU/.local/share/codebase-buzz/codex-acp
123+
Arguments: (leave empty)
103124
```
104125

105-
Buzz can then discover the models available to your Codebase account. Each ACP
126+
The launcher still runs Codebase; the `codex-acp` filename lets current Buzz
127+
versions supply the MCP tools an agent needs to publish its answer into the
128+
channel. Without that bridge, Buzz may show Codebase's generated text in agent
129+
activity but never post it as a message.
130+
131+
Buzz can discover the models available to your Codebase account. Each ACP
106132
session gets its own working directory and conversation, accepts client-supplied
107133
MCP servers, streams assistant and tool progress, supports cancellation, and
108134
routes write/shell approvals through the client's permission UI.

package-lock.json

Lines changed: 2 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: 1 addition & 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.81",
3+
"version": "2.0.0-pre.82",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/acp/server.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,60 @@ describe("runAcpServer", () => {
135135
toAgent.end();
136136
await serverDone;
137137
});
138+
139+
it("streams provider failures as visible ACP messages", async () => {
140+
faux.setResponses([
141+
fauxAssistantMessage([], {
142+
stopReason: "error",
143+
errorMessage: '402 "insufficient_credits"',
144+
}),
145+
]);
146+
147+
const toAgent = new PassThrough();
148+
const fromAgent = new PassThrough();
149+
const serverDone = runAcpServer({
150+
stdin: toAgent,
151+
stdout: fromAgent,
152+
configOverride: { model, apiKey: "faux-key", source: "byok" },
153+
});
154+
const updates: acp.SessionNotification[] = [];
155+
const stream = acp.ndJsonStream(Writable.toWeb(toAgent), Readable.toWeb(fromAgent) as ReadableStream<Uint8Array>);
156+
157+
const result = await acp
158+
.client({ name: "buzz-error-test" })
159+
.onNotification(acp.methods.client.session.update, (ctx) => {
160+
updates.push(ctx.params);
161+
})
162+
.connectWith(stream, async (client) => {
163+
await client.request(acp.methods.agent.initialize, {
164+
protocolVersion: acp.PROTOCOL_VERSION,
165+
clientInfo: { name: "buzz-acp", version: "test" },
166+
});
167+
const session = await client.request(acp.methods.agent.session.new, {
168+
cwd,
169+
mcpServers: [],
170+
});
171+
return client.request(acp.methods.agent.session.prompt, {
172+
sessionId: session.sessionId,
173+
prompt: [{ type: "text", text: "Reply even if the provider fails." }],
174+
});
175+
});
176+
177+
expect(result.stopReason).toBe("end_turn");
178+
const text = updates
179+
.filter(
180+
(
181+
event,
182+
): event is acp.SessionNotification & {
183+
update: { sessionUpdate: "agent_message_chunk"; content: { type: "text"; text: string } };
184+
} => event.update.sessionUpdate === "agent_message_chunk" && event.update.content.type === "text",
185+
)
186+
.map((event) => event.update.content.text)
187+
.join("");
188+
expect(text).toContain("Codebase couldn't complete this turn");
189+
expect(text).toContain("Codebase credits are exhausted");
190+
191+
toAgent.end();
192+
await serverDone;
193+
});
138194
});

src/acp/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
77
import type { ImageContent } from "@earendil-works/pi-ai";
88
import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js";
99
import type { ModelOption } from "../agent/model-list.js";
10+
import { latestAssistantError, userFacingErrorMessage } from "../errors/user-facing.js";
1011
import type { NamedServer } from "../mcp/config.js";
1112
import type { PermissionRequest, ResponseChoice } from "../permissions/store.js";
1213
import { VERSION } from "../version.js";
@@ -165,7 +166,14 @@ export async function runAcpServer(options: AcpServerOptions = {}): Promise<numb
165166
await session.notificationQueue;
166167
return { stopReason: "refusal" };
167168
}
168-
if (result.error) throw new Error(result.error);
169+
const turnError = result.error ?? latestAssistantError(session.bundle.agent.state.messages);
170+
if (turnError) {
171+
const prefix = session.assistantText
172+
? "\n\nCodebase couldn't complete this turn: "
173+
: "Codebase couldn't complete this turn: ";
174+
await notifyText(session, client, `${prefix}${userFacingErrorMessage(turnError)}`);
175+
await session.notificationQueue;
176+
}
169177
return { stopReason: "end_turn" };
170178
} finally {
171179
unsubscribeEvents();

src/errors/user-facing.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
2+
13
const PROVIDER_AUTH_PATTERNS = [
24
/\bauthentication_error\b/i,
35
/\binvalid[_ -]?(?:x-)?api[_ -]?key\b/i,
@@ -16,6 +18,24 @@ export function providerAuthRecoveryMessage(raw: string): string | undefined {
1618
].join(" ");
1719
}
1820

21+
export function providerCreditsRecoveryMessage(raw: string): string | undefined {
22+
const trimmed = raw.trim();
23+
if (!trimmed || !/(\b402\b|insufficient[_ -]?credits?)/i.test(trimmed)) return undefined;
24+
return [
25+
"Codebase credits are exhausted.",
26+
"Run `codebase usage` to check the balance, add credits in Codebase Settings, wait for the plan reset, or switch to a BYOK model.",
27+
].join(" ");
28+
}
29+
1930
export function userFacingErrorMessage(raw: string): string {
20-
return providerAuthRecoveryMessage(raw) ?? raw.trim();
31+
return providerAuthRecoveryMessage(raw) ?? providerCreditsRecoveryMessage(raw) ?? raw.trim();
32+
}
33+
34+
export function latestAssistantError(messages: AgentMessage[]): string | undefined {
35+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
36+
if (!lastAssistant) return undefined;
37+
const candidate = lastAssistant as { stopReason?: unknown; errorMessage?: unknown };
38+
if (typeof candidate.errorMessage === "string" && candidate.errorMessage.trim()) return candidate.errorMessage;
39+
if (candidate.stopReason === "error") return "Agent turn ended with an error.";
40+
return undefined;
2141
}

src/headless/run.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Usage } from "@earendil-works/pi-ai";
33
import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js";
44
import { ConfigError } from "../agent/config.js";
55
import { visibleMessages } from "../agent/visible-messages.js";
6-
import { userFacingErrorMessage } from "../errors/user-facing.js";
6+
import { latestAssistantError, userFacingErrorMessage } from "../errors/user-facing.js";
77
import { ReceiptStore } from "./receipt-store.js";
88
import {
99
formatReliabilityFailure,
@@ -535,15 +535,6 @@ function extractText(message: { content?: unknown }): string {
535535
return parts.join("");
536536
}
537537

538-
function latestAssistantError(messages: AgentMessage[]): string | undefined {
539-
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
540-
if (!lastAssistant) return undefined;
541-
const candidate = lastAssistant as { stopReason?: unknown; errorMessage?: unknown };
542-
if (typeof candidate.errorMessage === "string" && candidate.errorMessage.trim()) return candidate.errorMessage;
543-
if (candidate.stopReason === "error") return "Agent turn ended with an error.";
544-
return undefined;
545-
}
546-
547538
function latestAssistantText(messages: AgentMessage[]): string {
548539
const lastAssistant = [...messages]
549540
.reverse()

0 commit comments

Comments
 (0)