diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index ab76940e20..71f617c36b 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -353,9 +353,9 @@ environment pull-request show `. 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 diff --git a/docs/configuration.md b/docs/configuration.md index a946129738..cd5f4e96fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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`. diff --git a/packages/provider-bridge-acp/src/delta-translation.test.ts b/packages/provider-bridge-acp/src/delta-translation.test.ts index d684c41d49..cb0bbe8014 100644 --- a/packages/provider-bridge-acp/src/delta-translation.test.ts +++ b/packages/provider-bridge-acp/src/delta-translation.test.ts @@ -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. @@ -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(), @@ -2316,6 +2334,7 @@ describe("acp delta translation (dialects)", () => { dialectId: string; rawInput: Record; rawOutput: unknown; + content?: AcpToolCallUpdateEvent["content"]; status?: "completed" | "failed"; }) { const harness = dialectHarness(args.dialectId); @@ -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, }), ), @@ -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( diff --git a/packages/provider-bridge-acp/src/delta-translation.ts b/packages/provider-bridge-acp/src/delta-translation.ts index d68204cc74..d54877b942 100644 --- a/packages/provider-bridge-acp/src/delta-translation.ts +++ b/packages/provider-bridge-acp/src/delta-translation.ts @@ -705,8 +705,10 @@ export function createAcpDeltaTranslator( Extract, "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 @@ -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) : [ diff --git a/packages/provider-bridge-acp/src/dialect.test.ts b/packages/provider-bridge-acp/src/dialect.test.ts index cc3aceac45..b665809f92 100644 --- a/packages/provider-bridge-acp/src/dialect.test.ts +++ b/packages/provider-bridge-acp/src/dialect.test.ts @@ -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"; @@ -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", () => { @@ -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", () => { @@ -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(); }); }); @@ -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. diff --git a/packages/provider-bridge-acp/src/dialect.ts b/packages/provider-bridge-acp/src/dialect.ts index e6b6cdb9e0..82fb269d7d 100644 --- a/packages/provider-bridge-acp/src/dialect.ts +++ b/packages/provider-bridge-acp/src/dialect.ts @@ -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 @@ -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 // --------------------------------------------------------------------------- @@ -421,6 +489,7 @@ const DIALECTS_BY_ID: ReadonlyMap = 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. */ @@ -428,6 +497,7 @@ const DIALECT_IDS_BY_COMMAND: Readonly> = { "cursor-agent": CURSOR_ACP_DIALECT.id, grok: GROK_ACP_DIALECT.id, omp: OMP_ACP_DIALECT.id, + opencode: OPENCODE_ACP_DIALECT.id, }; /** diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 1a2a97446d..710fb45bad 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -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 diff --git a/plugins/provider-acp/src/agents.test.ts b/plugins/provider-acp/src/agents.test.ts index aac6231be7..903c672850 100644 --- a/plugins/provider-acp/src/agents.test.ts +++ b/plugins/provider-acp/src/agents.test.ts @@ -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, ); diff --git a/plugins/provider-acp/src/known-agents.ts b/plugins/provider-acp/src/known-agents.ts index bb7b5de35b..bf42139181 100644 --- a/plugins/provider-acp/src/known-agents.ts +++ b/plugins/provider-acp/src/known-agents.ts @@ -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