Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,9 @@ environment pull-request show <id>`. Diff commands require an explicit target
co-located daemon. Optional per-agent fields: `args`, `env`, `cwd`,
`modelCli`, `reasoningCli`, `nativeReasoning`, `nativeSkillRoots`
(`{"user": [...], "project": [...]}` relative paths), `permissionCli`,
`supportsManualCompaction`, and `dialect` (`cursor` or `grok`). The old
`customAcpAgents` array in `config.json` is deprecated; bb reads it and warns
until 0.41.
`supportsManualCompaction`, and `dialect` (`cursor`, `opencode`, `omp`, or
`grok`). The old `customAcpAgents` array in `config.json` is deprecated; bb
reads it and warns until 0.41.
- Top-level `customModels` in the same `config.json` registers extra picker
models. `providerId` accepts a built-in provider id or any `acp-*` provider
id. The provider must still accept the id: `claude-code` and `codex` accept
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ an agent that nests skills or reads them from every ancestor directory),
`permissionCli` (permission-mode launch flags), `supportsManualCompaction`
(only if the agent accepts an explicit compaction request — bb hides
`/compact` otherwise), and `dialect` (the vendor side channels bb reads for
the agent: `cursor` or `grok`).
the agent: `cursor`, `opencode`, `omp`, or `grok`).

The change applies immediately: the plugin re-registers its providers when the
setting changes, with no restart and no `config refresh`.
Expand Down
69 changes: 69 additions & 0 deletions packages/provider-bridge-acp/src/delta-translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "./delta-translation.js";
import { resolveAcpDialect } from "./dialect.js";
import { ACP_TOOL_PAYLOAD_MAX_CHARS } from "./tool-classification.js";
import type { AcpToolCallUpdateEvent } from "./wire.js";

/**
* ACP translation equivalence for the narrow-grammar path.
Expand Down Expand Up @@ -737,6 +738,23 @@ describe("acp delta translation (moved from the legacy adapter suite)", () => {
expect(item.exitCode).toBe(1);
});

it("does not claim OpenCode metadata for a generic ACP agent", () => {
const item = completeCommand(
startedHarness(),
"call-generic",
"echo ok",
{
status: "completed",
rawOutput: {
output: "ok\n",
metadata: { exit: 0, output: "ok\n", truncated: false },
},
},
);
expect(item.exitCode).toBeUndefined();
expect(item.aggregatedOutput).toContain('"metadata"');
});

it("omits the exit code when a completed call carries no result at all", () => {
const item = completeCommand(
startedHarness(),
Expand Down Expand Up @@ -2316,6 +2334,7 @@ describe("acp delta translation (dialects)", () => {
dialectId: string;
rawInput: Record<string, unknown>;
rawOutput: unknown;
content?: AcpToolCallUpdateEvent["content"];
status?: "completed" | "failed";
}) {
const harness = dialectHarness(args.dialectId);
Expand All @@ -2335,6 +2354,7 @@ describe("acp delta translation (dialects)", () => {
sessionUpdate: "tool_call_update",
toolCallId: "call-command",
status: args.status ?? "completed",
...(args.content === undefined ? {} : { content: args.content }),
rawOutput: args.rawOutput,
}),
),
Expand Down Expand Up @@ -2442,6 +2462,55 @@ describe("acp delta translation (dialects)", () => {
expect(generic).not.toHaveProperty("exitCode");
});

it("normalizes the recorded OpenCode command completion envelope", () => {
expect(
completedDialectCommand({
dialectId: "opencode",
rawInput: { command: "echo ok" },
rawOutput: {
output: "ok\n",
metadata: { exit: 0, output: "ok\n", truncated: false },
},
}),
).toMatchObject({
type: "commandExecution",
command: "echo ok",
status: "completed",
exitCode: 0,
aggregatedOutput: "ok\n",
});
});

it("keeps standard content and shared result fields ahead of OpenCode fallbacks", () => {
expect(
completedDialectCommand({
dialectId: "opencode",
rawInput: { command: "echo protocol" },
content: [
{
type: "content",
content: { type: "text", text: "protocol content\n" },
},
],
rawOutput: {
exit_code: 9,
stdout: "shared stdout\n",
output_for_prompt: "shared prompt output\n",
output: "OpenCode output\n",
metadata: {
exit: 0,
output: "OpenCode metadata output\n",
truncated: false,
},
},
}),
).toMatchObject({
type: "commandExecution",
exitCode: 9,
aggregatedOutput: "protocol content\n",
});
});

it("opens a Cursor task call as a delegation and takes the report's detail", () => {
const harness = dialectHarness("cursor");
const opened = harness.translate(
Expand Down
8 changes: 6 additions & 2 deletions packages/provider-bridge-acp/src/delta-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,8 +705,10 @@ export function createAcpDeltaTranslator(
Extract<ThreadDelta, { kind: "item.close" }>,
"aggregatedOutput" | "exitCode" | "resultText"
> {
const normalizedEvent = dialect.normalizeCommandEvent?.(event) ?? event;
const result =
dialect.commandResult?.(event) ?? extractAcpCommandResult(event);
dialect.commandResult?.(normalizedEvent) ??
extractAcpCommandResult(normalizedEvent);
const exitCode = result.exitCode ?? (status === "failed" ? 1 : undefined);
return {
...(result.output === undefined
Expand Down Expand Up @@ -903,7 +905,9 @@ export function createAcpDeltaTranslator(
mergedType === "command" &&
open?.openedType === "command"
) {
const streamed = extractAcpStreamedCommandOutput(event);
const normalizedEvent =
dialect.normalizeCommandEvent?.(event) ?? event;
const streamed = extractAcpStreamedCommandOutput(normalizedEvent);
return streamed === undefined
? suppressedUnhandled(rawEvent)
: [
Expand Down
56 changes: 55 additions & 1 deletion packages/provider-bridge-acp/src/dialect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
GENERIC_ACP_DIALECT,
GROK_ACP_DIALECT,
OMP_ACP_DIALECT,
OPENCODE_ACP_DIALECT,
resolveAcpDialect,
} from "./dialect.js";
import type { AcpToolCallUpdateEvent } from "./wire.js";
Expand All @@ -19,6 +20,9 @@ describe("resolveAcpDialect", () => {
expect(
resolveAcpDialect({ dialectId: "cursor", command: "node" }),
).toBe(CURSOR_ACP_DIALECT);
expect(resolveAcpDialect({ dialectId: "opencode", command: "node" })).toBe(
OPENCODE_ACP_DIALECT,
);
});

it("falls back to the launch executable's base name", () => {
Expand All @@ -32,6 +36,9 @@ describe("resolveAcpDialect", () => {
expect(resolveAcpDialect({ command: "/opt/homebrew/bin/omp" })).toBe(
OMP_ACP_DIALECT,
);
expect(resolveAcpDialect({ command: "/usr/local/bin/opencode" })).toBe(
OPENCODE_ACP_DIALECT,
);
});

it("is generic for a dialect id it does not ship", () => {
Expand All @@ -43,11 +50,12 @@ describe("resolveAcpDialect", () => {
// Selecting on the launch command, not a bb provider id, is what lets a
// user-configured instance of the same agent get the same dialect.
it("gives an unknown agent the generic dialect, which answers nothing", () => {
const dialect = resolveAcpDialect({ command: "opencode" });
const dialect = resolveAcpDialect({ command: "amp" });
expect(dialect).toBe(GENERIC_ACP_DIALECT);
expect(dialect.toolIdentity).toBeUndefined();
expect(dialect.classifyToolCall).toBeUndefined();
expect(dialect.commandResult).toBeUndefined();
expect(dialect.normalizeCommandEvent).toBeUndefined();
expect(dialect.handleClientRequest).toBeUndefined();
});
});
Expand Down Expand Up @@ -168,6 +176,52 @@ describe("omp command results", () => {
);
});

describe("OpenCode command results", () => {
const normalize = (rawOutput: unknown) =>
OPENCODE_ACP_DIALECT.normalizeCommandEvent?.(toolCall({ rawOutput }))
.rawOutput;

it("normalizes the recorded output and metadata envelope", () => {
expect(
normalize({
output: "ok\n",
metadata: { exit: 0, output: "ok\n", truncated: false },
}),
).toEqual({
output: "ok\n",
metadata: { exit: 0, output: "ok\n", truncated: false },
stdout: "ok\n",
exitCode: 0,
});
});

it("falls back to metadata.output when top-level output is not text", () => {
expect(
normalize({
output: [{ type: "text", text: "structured" }],
metadata: { exit: 17, output: "failed\n", truncated: false },
}),
).toEqual({
output: [{ type: "text", text: "structured" }],
metadata: { exit: 17, output: "failed\n", truncated: false },
stdout: "failed\n",
exitCode: 17,
});
});

it("does not replace shared ACP result shapes", () => {
const rawOutput = {
exit_code: 7,
stdout: "stdout\n",
stderr: "stderr\n",
output_for_prompt: "prompt output\n",
output: "OpenCode output\n",
metadata: { exit: 0, output: "OpenCode metadata output\n" },
};
expect(normalize(rawOutput)).toEqual(rawOutput);
});
});

describe("grok sub-agents", () => {
// Version 1 of the protocol has no sub-agent concept, so only the dialect
// can know that this tool call is delegated work.
Expand Down
70 changes: 70 additions & 0 deletions packages/provider-bridge-acp/src/dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ export interface AcpDialect {
* Returning `undefined` leaves the shared ACP result parser in charge.
*/
commandResult?(event: AcpToolCallUpdateEvent): AcpCommandResult | undefined;
/**
* A command event with the agent's non-standard result fields normalized
* into the shared ACP result shapes. The hook runs only after the call has
* classified as a command; existing shared result fields must win.
*/
normalizeCommandEvent?(event: AcpToolCallUpdateEvent): AcpToolCallUpdateEvent;
/**
* A vendor JSON-RPC request the agent sends to the client. A dialect that
* answers one returns the JSON-RPC result to reply with (`{}` is a valid
Expand Down Expand Up @@ -408,6 +414,68 @@ export const OMP_ACP_DIALECT: AcpDialect = {
commandResult: ompCommandResult,
};

// ---------------------------------------------------------------------------
// opencode (`opencode acp`)
// ---------------------------------------------------------------------------

/**
* OpenCode reports command output twice on its AgentToolResult envelope and
* puts the process exit code in metadata. The shared ACP parser deliberately
* does not claim these generic-looking vendor fields for every agent.
*/
const openCodeCommandRawOutputSchema = z
.object({
output: z.unknown().optional(),
metadata: z
.object({
exit: z.number().int().nullable().optional(),
output: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();

function normalizeOpenCodeCommandEvent(
event: AcpToolCallUpdateEvent,
): AcpToolCallUpdateEvent {
const parsed = openCodeCommandRawOutputSchema.safeParse(event.rawOutput);
if (!parsed.success) {
return event;
}
const rawOutput = parsed.data;
const output =
typeof rawOutput.output === "string"
? rawOutput.output
: rawOutput.metadata?.output;
const hasSharedOutput =
rawOutput["stdout"] !== undefined ||
rawOutput["stderr"] !== undefined ||
rawOutput["output_for_prompt"] !== undefined;
const hasSharedExitCode =
rawOutput["exitCode"] !== undefined || rawOutput["exit_code"] !== undefined;
const exitCode = rawOutput.metadata?.exit ?? undefined;
if (
(output === undefined || hasSharedOutput) &&
(exitCode === undefined || hasSharedExitCode)
) {
return event;
}
return {
...event,
rawOutput: {
...rawOutput,
...(output === undefined || hasSharedOutput ? {} : { stdout: output }),
...(exitCode === undefined || hasSharedExitCode ? {} : { exitCode }),
},
};
}

export const OPENCODE_ACP_DIALECT: AcpDialect = {
id: "opencode",
normalizeCommandEvent: normalizeOpenCodeCommandEvent,
};

// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
Expand All @@ -421,13 +489,15 @@ const DIALECTS_BY_ID: ReadonlyMap<string, AcpDialect> = new Map([
[CURSOR_ACP_DIALECT.id, CURSOR_ACP_DIALECT],
[GROK_ACP_DIALECT.id, GROK_ACP_DIALECT],
[OMP_ACP_DIALECT.id, OMP_ACP_DIALECT],
[OPENCODE_ACP_DIALECT.id, OPENCODE_ACP_DIALECT],
]);

/** The executable name each dialect's agent is normally launched as. */
const DIALECT_IDS_BY_COMMAND: Readonly<Record<string, string>> = {
"cursor-agent": CURSOR_ACP_DIALECT.id,
grok: GROK_ACP_DIALECT.id,
omp: OMP_ACP_DIALECT.id,
opencode: OPENCODE_ACP_DIALECT.id,
};

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/templates/src/templates/bb-guide-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ omp, grok and hermes-agent are not reserved, so an entry with one of those ids
replaces the shipped agent. Use args, env, and cwd for the launch, modelCli
for CLI model listing/selection, reasoningCli for launch-time reasoning flags,
nativeReasoning for ACP session/set_config_option reasoning, permissionCli for
permission-mode launch flags, and dialect (cursor or grok) for the vendor side
channels bb reads. Use nativeSkillRoots to add native skills to the composer.
permission-mode launch flags, and dialect (cursor, opencode, omp, or grok) for
the vendor side channels bb reads. Use nativeSkillRoots to add native skills to
the composer.
Give it a user list and a project list. User roots resolve from the target host
home directory. Project roots resolve from the selected workspace. Each root
must use a relative path without dot segments. Set supportsManualCompaction to true only
Expand Down
7 changes: 3 additions & 4 deletions plugins/provider-acp/src/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,9 @@ describe("acpProviderDeclaration", () => {
expect(byId.get("acp-grok")?.experimental_bridgeOptions).toMatchObject({
acpDialect: "grok",
});
// An agent with no vendor side channels bb reads names no dialect.
expect(
byId.get("acp-opencode")?.experimental_bridgeOptions,
).not.toHaveProperty("acpDialect");
expect(byId.get("acp-opencode")?.experimental_bridgeOptions).toMatchObject({
acpDialect: "opencode",
});
expect(byId.get("acp-opencode")?.capabilities.supportsManualCompaction).toBe(
true,
);
Expand Down
1 change: 1 addition & 0 deletions plugins/provider-acp/src/known-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const KNOWN_ACP_AGENTS: readonly AcpAgentDefinition[] = [
signInCommand: "opencode auth login",
installUrl: "https://opencode.ai/docs",
visibility: "installed",
dialect: "opencode",
supportsManualCompaction: true,
// Unverified: bb has never read this agent's `initialize` reply, and this
// is the value the ACP tier declared for it. Q21's per-instance probe
Expand Down
Loading