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 a42917066..93c9ed1b2 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 @@ -49,7 +49,8 @@ message agents, or inspect projects, providers, and environments. - If provider or model choice matters, inspect options with `bb provider list` and `bb provider models `. - Known ACP agents can appear automatically when their CLI is installed on the - host; for example `opencode` on PATH appears as provider `acp-opencode`. + host; `opencode`, `copilot`, and `qwen` on PATH appear as `acp-opencode`, + `acp-github-copilot`, and `acp-qwen-code`. - Custom ACP agents can be registered in the app data-dir `config.json` under `customAcpAgents`. The user supplies a slug `id`; bb exposes it as provider id `acp-`. Custom config wins if it uses the same provider id as a known diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index df176e6c7..090075325 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -82,6 +82,14 @@ function buildCustomAcpProviderInfo(agent: CustomAcpAgent): ProviderInfo { }); } +function buildProviderUnavailableError(providerId: string): ApiError { + return new ApiError( + 400, + "invalid_request", + `Provider "${providerId}" is not available.`, + ); +} + function listConfiguredSystemProviderInfos( customAcpAgents: CustomAcpAgent[], installedKnownAcpAgents: readonly KnownAcpAgent[], @@ -322,6 +330,14 @@ export async function resolveSystemExecutionOptions( const requestedProvider = query.providerId ? providers.find((provider) => provider.id === query.providerId) : undefined; + if ( + query.providerId !== undefined && + requestedProvider === undefined && + configuredRequestedProvider === undefined + ) { + await earlyModelResultPromise?.catch(() => undefined); + throw buildProviderUnavailableError(query.providerId); + } const modelsProvider = earlyModelResultPromise !== null ? configuredRequestedProvider diff --git a/apps/server/src/services/system/known-acp-agents.ts b/apps/server/src/services/system/known-acp-agents.ts index a101ff1cf..dcf751db4 100644 --- a/apps/server/src/services/system/known-acp-agents.ts +++ b/apps/server/src/services/system/known-acp-agents.ts @@ -29,6 +29,22 @@ export const KNOWN_ACP_AGENTS: readonly KnownAcpAgent[] = [ env: {}, executableName: "opencode", }, + { + id: "acp-github-copilot", + displayName: "GitHub Copilot", + command: "copilot", + args: ["--acp", "--stdio"], + env: {}, + executableName: "copilot", + }, + { + id: "acp-qwen-code", + displayName: "Qwen Code", + command: "qwen", + args: ["--acp"], + env: {}, + executableName: "qwen", + }, ]; export function listKnownAcpAgentExecutableQueries(): KnownAcpAgentExecutableQuery[] { diff --git a/apps/server/src/services/threads/project-execution-defaults.ts b/apps/server/src/services/threads/project-execution-defaults.ts index d1f304ac3..47d87b603 100644 --- a/apps/server/src/services/threads/project-execution-defaults.ts +++ b/apps/server/src/services/threads/project-execution-defaults.ts @@ -2,6 +2,8 @@ import { getProjectExecutionDefaults, upsertProjectExecutionDefaults, } from "@bb/db"; +import { isAcpProviderId, isAgentProviderId } from "@bb/agent-providers"; +import { formatCustomAcpAgentProviderId } from "@bb/config/bb-app-managed-config"; import type { ProjectExecutionDefaults, ResolvedThreadExecutionOptions, @@ -13,6 +15,7 @@ import type { ThreadCreateServiceRequestInput, } from "./thread-create-request.js"; import { resolveCreateThreadExecutionDefaults } from "./thread-default-policy.js"; +import { findKnownAcpAgentForProviderId } from "../system/known-acp-agents.js"; export interface RememberProjectExecutionDefaultsForCreateArgs { execution: ResolvedThreadExecutionOptions; @@ -72,8 +75,24 @@ function resolveRequestedCreateExecutionValue({ return sources[field] === undefined ? undefined : value; } +function isSupportedCreateProviderId( + deps: Pick, + providerId: string, +): boolean { + if (!isAcpProviderId(providerId)) { + return true; + } + return ( + isAgentProviderId(providerId) || + findKnownAcpAgentForProviderId(providerId) !== undefined || + deps.config.customAcpAgents.some( + (agent) => formatCustomAcpAgentProviderId(agent.id) === providerId, + ) + ); +} + export function resolveProjectExecutionDefaultsForCreate( - deps: Pick, + deps: Pick, args: ResolveProjectExecutionDefaultsForCreateArgs, ): ResolvedProjectExecutionDefaultsForCreate { const storedDefaults = getProjectExecutionDefaults(deps.db, { @@ -95,6 +114,14 @@ export function resolveProjectExecutionDefaultsForCreate( }); const { executionDefaults, providerId } = resolution; + if (!isSupportedCreateProviderId(deps, providerId)) { + throw new ApiError( + 400, + "invalid_request", + `Provider "${providerId}" is not supported.`, + ); + } + if (!requestedModel && !executionDefaults) { throw new ApiError( 400, diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 281584b4e..09f6c93d4 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -2555,7 +2555,11 @@ describe("public thread data routes", () => { ).toEqual([ { type: "known_acp_agents.status", - agents: [{ id: "acp-opencode", executableName: "opencode" }], + agents: [ + { id: "acp-opencode", executableName: "opencode" }, + { id: "acp-github-copilot", executableName: "copilot" }, + { id: "acp-qwen-code", executableName: "qwen" }, + ], }, { type: "provider.list_models", providerId: "codex" }, ]); diff --git a/apps/server/test/services/threads/thread-execution-plan.test.ts b/apps/server/test/services/threads/thread-execution-plan.test.ts index 4b22a9806..5ff1a7410 100644 --- a/apps/server/test/services/threads/thread-execution-plan.test.ts +++ b/apps/server/test/services/threads/thread-execution-plan.test.ts @@ -131,4 +131,46 @@ describe("thread execution plan input sources", () => { }); }); }); + + it("allows non-ACP provider ids for runtime-registered providers", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-source-aware-fake-provider", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + + const resolution = resolveProjectExecutionDefaultsForCreate( + harness.deps, + { + model: "fake-model", + projectId: project.id, + providerId: "fake", + }, + ); + + expect(resolution.providerId).toBe("fake"); + expect(resolution.executionDefaults).toBeNull(); + }); + }); + + it("rejects ACP providers that are not built in, custom, or known", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-source-aware-unsupported-provider", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + + expect(() => + resolveProjectExecutionDefaultsForCreate(harness.deps, { + model: "acp-default", + projectId: project.id, + providerId: "acp-gemini", + }), + ).toThrow('Provider "acp-gemini" is not supported.'); + }); + }); }); diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index 7a0b4d1cf..7f55d2ac2 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -13,6 +13,12 @@ import { import { seedHostSession } from "../helpers/seed.js"; import { withTestHarness } from "../helpers/test-app.js"; +const KNOWN_ACP_PROVIDER_IDS = [ + "acp-opencode", + "acp-github-copilot", + "acp-qwen-code", +] as const; + describe("appendCustomModels", () => { it("appends custom models for the requested provider after the catalog", () => { const catalogModel = availableModelFixture({ @@ -187,7 +193,7 @@ describe("resolveSystemExecutionOptions", () => { id: "host-execution-options-known-acp-installed", }); const catalogModel = availableModelFixture({ - model: "opencode/default", + model: "github-copilot/default", }); const responder = registerHostRpcResponder(harness, { hostId: host.id, @@ -199,10 +205,10 @@ describe("resolveSystemExecutionOptions", () => { result: { agents: request.command.agents.map((agent) => ({ ...agent, - installed: agent.id === "acp-opencode", + installed: agent.id === "acp-github-copilot", executablePath: - agent.id === "acp-opencode" - ? "/opt/homebrew/bin/opencode" + agent.id === "acp-github-copilot" + ? "/opt/homebrew/bin/copilot" : null, })), }, @@ -223,14 +229,14 @@ describe("resolveSystemExecutionOptions", () => { const response = await resolveSystemExecutionOptions(harness.deps, { hostId: host.id, - providerId: "acp-opencode", + providerId: "acp-github-copilot", }); expect(response.providers).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: "acp-opencode", - displayName: "opencode", + id: "acp-github-copilot", + displayName: "GitHub Copilot", available: true, }), ]), @@ -241,17 +247,103 @@ describe("resolveSystemExecutionOptions", () => { ); expect(responder.requests[1].command).toEqual({ type: "provider.list_models", - providerId: "acp-opencode", + providerId: "acp-github-copilot", acpLaunchSpec: { - displayName: "opencode", - command: "opencode", - args: ["acp"], + displayName: "GitHub Copilot", + command: "copilot", + args: ["--acp", "--stdio"], env: {}, }, }); }); }); + it.each([ + { + providerId: "acp-opencode", + displayName: "opencode", + command: "opencode", + args: ["acp"], + }, + { + providerId: "acp-github-copilot", + displayName: "GitHub Copilot", + command: "copilot", + args: ["--acp", "--stdio"], + }, + { + providerId: "acp-qwen-code", + displayName: "Qwen Code", + command: "qwen", + args: ["--acp"], + }, + ])( + "sends the known ACP launch spec for $providerId", + async ({ providerId, displayName, command, args }) => { + await withTestHarness({}, async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: `host-execution-options-known-${providerId}`, + }); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + if (request.command.type === "known_acp_agents.status") { + return { + ok: true, + result: { + agents: request.command.agents.map((agent) => ({ + ...agent, + installed: agent.id === providerId, + executablePath: + agent.id === providerId + ? `/usr/local/bin/${command}` + : null, + })), + }, + }; + } + if (request.command.type === "provider.list_models") { + return { + ok: true, + result: { + models: [], + selectedOnlyModels: [], + }, + }; + } + throw new Error(`Unexpected RPC command ${request.command.type}`); + }, + }); + + const response = await resolveSystemExecutionOptions(harness.deps, { + hostId: host.id, + providerId, + }); + + expect(response.providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: providerId, + displayName, + available: true, + }), + ]), + ); + expect(responder.requests[1].command).toEqual({ + type: "provider.list_models", + providerId, + acpLaunchSpec: { + displayName, + command, + args, + env: {}, + }, + }); + }); + }, + ); + it("omits known ACP agents that the host reports missing", async () => { await withTestHarness({}, async (harness) => { const { host, session } = seedHostSession(harness.deps, { @@ -281,8 +373,51 @@ describe("resolveSystemExecutionOptions", () => { hostId: host.id, }); - expect(providers.map((provider) => provider.id)).not.toContain( - "acp-opencode", + expect(providers.map((provider) => provider.id)).toEqual( + expect.not.arrayContaining([...KNOWN_ACP_PROVIDER_IDS]), + ); + }); + }); + + it("rejects requested providers that are not configured or available", async () => { + await withTestHarness({}, async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-execution-options-provider-unavailable", + }); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + if (request.command.type === "known_acp_agents.status") { + return { + ok: true, + result: { + agents: request.command.agents.map((agent) => ({ + ...agent, + installed: false, + executablePath: null, + })), + }, + }; + } + throw new Error(`Unexpected RPC command ${request.command.type}`); + }, + }); + + await expect( + resolveSystemExecutionOptions(harness.deps, { + hostId: host.id, + providerId: "acp-gemini", + }), + ).rejects.toMatchObject({ + status: 400, + body: { + code: "invalid_request", + message: 'Provider "acp-gemini" is not available.', + }, + }); + expect(responder.requests.map((request) => request.command.type)).toEqual( + ["known_acp_agents.status"], ); }); }); @@ -368,9 +503,9 @@ describe("resolveSystemExecutionOptions", () => { expect.objectContaining({ id: "acp-example-agent" }), ]), ); - expect( - response.providers.map((provider) => provider.id), - ).not.toContain("acp-opencode"); + expect(response.providers.map((provider) => provider.id)).toEqual( + expect.not.arrayContaining([...KNOWN_ACP_PROVIDER_IDS]), + ); expect(response.models).toEqual([catalogModel]); expect(response.modelLoadError).toBeNull(); expect( @@ -416,8 +551,8 @@ describe("resolveSystemExecutionOptions", () => { expect.objectContaining({ id: "acp-example-agent" }), ]), ); - expect(response.providers.map((provider) => provider.id)).not.toContain( - "acp-opencode", + expect(response.providers.map((provider) => provider.id)).toEqual( + expect.not.arrayContaining([...KNOWN_ACP_PROVIDER_IDS]), ); expect(response.models).toEqual([ expect.objectContaining({ @@ -454,6 +589,18 @@ describe("resolveSystemExecutionOptions", () => { hostId: host.id, sessionId: session.id, handle: (request) => { + if (request.command.type === "known_acp_agents.status") { + return { + ok: true, + result: { + agents: request.command.agents.map((agent) => ({ + ...agent, + installed: false, + executablePath: null, + })), + }, + }; + } if (request.command.type === "provider.list_models") { return { ok: true, @@ -476,8 +623,15 @@ describe("resolveSystemExecutionOptions", () => { expect(opencodeProviders[0].displayName).toBe("Custom opencode"); expect( responder.requests.map((request) => request.command.type), - ).toEqual(["provider.list_models"]); + ).toEqual(["known_acp_agents.status", "provider.list_models"]); expect(responder.requests[0].command).toEqual({ + type: "known_acp_agents.status", + agents: [ + { id: "acp-github-copilot", executableName: "copilot" }, + { id: "acp-qwen-code", executableName: "qwen" }, + ], + }); + expect(responder.requests[1].command).toEqual({ type: "provider.list_models", providerId: "acp-opencode", acpLaunchSpec: { diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts index 424370317..4ddd553d6 100644 --- a/apps/server/test/threads/thread-runtime-config.test.ts +++ b/apps/server/test/threads/thread-runtime-config.test.ts @@ -193,72 +193,103 @@ describe("thread runtime config", () => { ); }); - it("attaches known ACP launch specs to thread start and turn submit commands", async () => { - await withTestHarness(async (harness) => { - const { host } = seedHostSession(harness.deps, { - id: "host-runtime-known-acp", - }); - const { project } = seedProjectWithSource(harness.deps, { - hostId: host.id, - }); - const environment = seedEnvironment(harness.deps, { - hostId: host.id, - projectId: project.id, - path: "/tmp/known-acp", - }); - const thread = seedThread(harness.deps, { - projectId: project.id, - environmentId: environment.id, - providerId: "acp-opencode", - }); - seedThreadRuntimeState(harness.deps, { - environmentId: environment.id, - providerThreadId: "provider-opencode", - threadId: thread.id, - }); - const execution = { - model: "opencode/default", - permissionMode: "workspace-write", - reasoningLevel: "medium", - serviceTier: "default", - source: "client/turn/requested", - } as const; - const expectedSpec = { + it.each([ + { + providerId: "acp-opencode", + model: "opencode/default", + providerThreadId: "provider-opencode", + expectedSpec: { displayName: "opencode", command: "opencode", args: ["acp"], env: {}, - }; - - const startCommand = await buildThreadStartCommand(harness.deps, { - environment, - execution, - fork: null, - permissionEscalation: "ask", - input: textInput("hello"), - projectId: project.id, - providerId: "acp-opencode", - requestId: encodeClientTurnRequestIdNumber({ value: 102 }), - syncGeneratedTitle: false, - thread, - }); - expect(startCommand.acpLaunchSpec).toEqual(expectedSpec); + }, + }, + { + providerId: "acp-github-copilot", + model: "github-copilot/default", + providerThreadId: "provider-github-copilot", + expectedSpec: { + displayName: "GitHub Copilot", + command: "copilot", + args: ["--acp", "--stdio"], + env: {}, + }, + }, + { + providerId: "acp-qwen-code", + model: "qwen-code/default", + providerThreadId: "provider-qwen-code", + expectedSpec: { + displayName: "Qwen Code", + command: "qwen", + args: ["--acp"], + env: {}, + }, + }, + ])( + "attaches known ACP launch specs to thread start and turn submit commands for $providerId", + async ({ providerId, model, providerThreadId, expectedSpec }) => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-runtime-known-acp", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/known-acp", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + providerId, + }); + seedThreadRuntimeState(harness.deps, { + environmentId: environment.id, + providerThreadId, + threadId: thread.id, + }); + const execution = { + model, + permissionMode: "workspace-write", + reasoningLevel: "medium", + serviceTier: "default", + source: "client/turn/requested", + } as const; - const submitCommand = await prepareTurnSubmitCommandPayload( - harness.deps, - { + const startCommand = await buildThreadStartCommand(harness.deps, { environment, execution, + fork: null, permissionEscalation: "ask", - input: textInput("continue"), - target: { mode: "start" }, + input: textInput("hello"), + projectId: project.id, + providerId, + requestId: encodeClientTurnRequestIdNumber({ value: 102 }), + syncGeneratedTitle: false, thread, - }, - ); - expect(submitCommand.acpLaunchSpec).toEqual(expectedSpec); - expect(submitCommand.resumeContext.acpLaunchSpec).toEqual(expectedSpec); - }); - }); + }); + expect(startCommand.acpLaunchSpec).toEqual(expectedSpec); + + const submitCommand = await prepareTurnSubmitCommandPayload( + harness.deps, + { + environment, + execution, + permissionEscalation: "ask", + input: textInput("continue"), + target: { mode: "start" }, + thread, + }, + ); + expect(submitCommand.acpLaunchSpec).toEqual(expectedSpec); + expect(submitCommand.resumeContext.acpLaunchSpec).toEqual(expectedSpec); + }); + }, + ); it.each([ { diff --git a/docs/configuration.md b/docs/configuration.md index f4b7e7a7b..2c9321ddc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,8 +78,11 @@ bundled `bb` CLI or a standalone host daemon. ## Custom ACP Agents Known ACP agents can appear automatically when their CLI is installed on the -host. For example, bb exposes `acp-opencode` when `opencode` is on PATH and can -be launched as `opencode acp`. +host. bb currently detects: + +- `opencode` as `acp-opencode`, launched with `opencode acp` +- `copilot` as `acp-github-copilot`, launched with `copilot --acp --stdio` +- `qwen` as `acp-qwen-code`, launched with `qwen --acp` Register custom ACP agents by editing `customAcpAgents` in `~/.bb/config.json`. There is no `bb-app config set` or `unset` command for this list, matching the @@ -88,7 +91,7 @@ manual-file workflow used for custom models. After editing the file, run Use `customAcpAgents` for arbitrary ACP agents, or to override the launch command for a known provider id such as `acp-opencode`. To override `acp-opencode`, set `"id": "opencode"`; bb derives the provider id by adding -the `acp-` prefix. +the `acp-` prefix. Other known providers follow the same rule. Example: diff --git a/packages/agent-runtime/src/acp/adapter.test.ts b/packages/agent-runtime/src/acp/adapter.test.ts index 14356c89b..7be9df3a8 100644 --- a/packages/agent-runtime/src/acp/adapter.test.ts +++ b/packages/agent-runtime/src/acp/adapter.test.ts @@ -1028,12 +1028,12 @@ describe("acp adapter interactive requests", () => { sessionGrant: null, }, reason: null, - availableDecisions: ["allow_once", "allow_for_session", "deny"], + availableDecisions: ["allow_once", "deny"], }, }); }); - it("decodes non-execute permission requests as permission grants", () => { + it("decodes non-execute permission requests as command approvals", () => { const adapter = createAdapter(); const decoded = adapter.decodeInteractiveRequest?.({ id: 8, @@ -1052,10 +1052,12 @@ describe("acp adapter interactive requests", () => { expect(decoded?.payload).toEqual({ kind: "approval", subject: { - kind: "permission_grant", + kind: "command", itemId: "call-2", - toolName: "Fetch docs", - permissions: { network: null, fileSystem: null }, + command: "Fetch docs", + cwd: null, + actions: [{ type: "unknown", command: "Fetch docs" }], + sessionGrant: null, }, reason: null, availableDecisions: ["allow_once", "deny"], diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts index 85035a3f4..ad61a11b2 100644 --- a/packages/agent-runtime/src/acp/adapter.ts +++ b/packages/agent-runtime/src/acp/adapter.ts @@ -438,12 +438,9 @@ function buildAcpApprovalDecisions( ): PendingInteractionApprovalDecision[] { const kinds = new Set(params.options.map((option) => option.kind)); const decisions: PendingInteractionApprovalDecision[] = []; - if (kinds.has("allow_once")) { + if (kinds.has("allow_once") || kinds.has("allow_always")) { decisions.push("allow_once"); } - if (kinds.has("allow_always")) { - decisions.push("allow_for_session"); - } if (kinds.has("reject_once") || kinds.has("reject_always")) { decisions.push("deny"); } @@ -1405,10 +1402,12 @@ export function createAcpProviderAdapter( } const toolCall = parsed.data.toolCall; const command = - toolCall?.kind === "execute" - ? (toOptionalString(toolCall.command) ?? - toOptionalString(toolCall.title)) - : undefined; + (toolCall?.kind === "execute" + ? toOptionalString(toolCall.command) + : null) ?? + toOptionalString(toolCall?.title) ?? + toolCall?.kind ?? + "ACP permission request"; return { requestId: request.id, method: request.method, @@ -1417,23 +1416,14 @@ export function createAcpProviderAdapter( turnId: parsed.data.turnId, payload: { kind: "approval", - subject: - toolCall && command - ? { - kind: "command", - itemId: toolCall.toolCallId, - command, - cwd: null, - actions: [{ type: "unknown", command }], - sessionGrant: null, - } - : { - kind: "permission_grant", - itemId: toolCall?.toolCallId ?? "acp-permission", - toolName: - toOptionalString(toolCall?.title) ?? toolCall?.kind ?? null, - permissions: { network: null, fileSystem: null }, - }, + subject: { + kind: "command", + itemId: toolCall?.toolCallId ?? "acp-permission", + command, + cwd: null, + actions: [{ type: "unknown", command }], + sessionGrant: null, + }, reason: null, availableDecisions: buildAcpApprovalDecisions(parsed.data), }, diff --git a/packages/agent-runtime/src/acp/bridge/model-catalog.test.ts b/packages/agent-runtime/src/acp/bridge/model-catalog.test.ts index 8b2a78151..6b9cdd18a 100644 --- a/packages/agent-runtime/src/acp/bridge/model-catalog.test.ts +++ b/packages/agent-runtime/src/acp/bridge/model-catalog.test.ts @@ -3,6 +3,7 @@ import { buildAgentModelCatalog, buildAcpNativeReasoningSupport, buildModelCatalogFromConfigOptions, + buildModelCatalogFromSessionModels, acpNativeReasoningLevelToValue, findAcpModelConfigOption, findAcpThoughtLevelConfigOption, @@ -476,6 +477,31 @@ describe("acp configOptions model catalog", () => { }); }); +describe("acp sessionModels model catalog", () => { + it("treats null model descriptions as missing descriptions", () => { + expect( + buildModelCatalogFromSessionModels({ + currentModelId: "qwen-coder-plus", + availableModels: [ + { + modelId: "qwen-coder-plus", + name: "Qwen Coder Plus", + description: null, + }, + ], + }), + ).toMatchObject([ + { + id: "qwen-coder-plus", + model: "qwen-coder-plus", + displayName: "Qwen Coder Plus", + description: "", + isDefault: true, + }, + ]); + }); +}); + describe("acp primary model split", () => { it("splits families into primary and selected-only pools", () => { const catalog = catalogFromSample(); diff --git a/packages/agent-runtime/src/acp/wire.ts b/packages/agent-runtime/src/acp/wire.ts index 2e8afb583..ea052c623 100644 --- a/packages/agent-runtime/src/acp/wire.ts +++ b/packages/agent-runtime/src/acp/wire.ts @@ -231,7 +231,7 @@ export const acpSessionModelSchema = z .object({ modelId: z.string(), name: z.string().optional(), - description: z.string().optional(), + description: z.string().nullable().optional(), }) .passthrough(); export type AcpSessionModel = z.infer; diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 59966a5eb..af3cdacdf 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -73,7 +73,7 @@ export const templateDefinitions = [ }, { "id": "bbGuideProviders", - "body": "Provider commands\n\nProviders are agent backends (e.g., codex, claude-code). Each supports different models.\n\n bb provider list List available providers\n bb provider models [providerId] List models for a provider\n\nUse these before spawning threads if you are unsure which provider or model to use.\nWhen provider and model are omitted from bb thread spawn, the project's remembered\ndefaults apply.\n\nKnown ACP agents can appear automatically when their CLI is installed on the\nhost. For example, opencode on PATH appears as provider acp-opencode.\n\nCustom ACP agents are configured in the app data-dir config.json under\ncustomAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON\nand run bb-app config refresh; there is no set/unset CLI surface for this list.\nCustom config wins if it uses the same provider id as a known ACP agent; for\nexample, override acp-opencode with id opencode.", + "body": "Provider commands\n\nProviders are agent backends (e.g., codex, claude-code). Each supports different models.\n\n bb provider list List available providers\n bb provider models [providerId] List models for a provider\n\nUse these before spawning threads if you are unsure which provider or model to use.\nWhen provider and model are omitted from bb thread spawn, the project's remembered\ndefaults apply.\n\nKnown ACP agents can appear automatically when their CLI is installed on the\nhost. For example, opencode, copilot, and qwen on PATH appear as providers\nacp-opencode, acp-github-copilot, and acp-qwen-code.\n\nCustom ACP agents are configured in the app data-dir config.json under\ncustomAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON\nand run bb-app config refresh; there is no set/unset CLI surface for this list.\nCustom config wins if it uses the same provider id as a known ACP agent; for\nexample, override acp-opencode with id opencode.", "fileName": "bb-guide-providers.md", "kind": "instruction", "title": "bb Guide — Providers", diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 8d99fead7..a4b22e264 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -17,7 +17,8 @@ When provider and model are omitted from bb thread spawn, the project's remember defaults apply. Known ACP agents can appear automatically when their CLI is installed on the -host. For example, opencode on PATH appears as provider acp-opencode. +host. For example, opencode, copilot, and qwen on PATH appear as providers +acp-opencode, acp-github-copilot, and acp-qwen-code. Custom ACP agents are configured in the app data-dir config.json under customAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON