diff --git a/apps/host-daemon/src/command-dispatch-support.test.ts b/apps/host-daemon/src/command-dispatch-support.test.ts index 95e785fee9..40b66d1a90 100644 --- a/apps/host-daemon/src/command-dispatch-support.test.ts +++ b/apps/host-daemon/src/command-dispatch-support.test.ts @@ -1,9 +1,33 @@ +import { AgentRuntimeRecoveryError } from "@bb/agent-runtime"; import { describe, expect, it } from "vitest"; import { CommandDispatchError, isExpectedOnlineRpcFailureError, } from "./command-dispatch-support.js"; +const ACP_MODEL_LIST_AUTH_MESSAGE = "ACP agent is not authenticated."; + +/** + * The error `provider.list_models` surfaces when an ACP bridge rejects the + * model probe with a typed `authRequired` recovery hint: the agent runtime + * turns that into an `AgentRuntimeRecoveryError` whose string `code` the + * daemon's `getErrorCode` reads. Constructed exactly as the runtime does, so + * this test fails if the classifier ever returns to message-shape matching. + */ +function createAcpAuthRequiredError(): AgentRuntimeRecoveryError { + return new AgentRuntimeRecoveryError({ + code: "auth_required", + message: ACP_MODEL_LIST_AUTH_MESSAGE, + recovery: { + kind: "authRequired", + message: ACP_MODEL_LIST_AUTH_MESSAGE, + providerId: "acp-cursor", + retryable: false, + }, + cause: new Error("Authentication required."), + }); +} + describe("command dispatch support", () => { it("classifies oversized file reads as expected RPC failures", () => { expect( @@ -12,4 +36,22 @@ describe("command dispatch support", () => { ), ).toBe(true); }); + + it("classifies typed auth_required recovery failures as expected RPC failures", () => { + expect(isExpectedOnlineRpcFailureError(createAcpAuthRequiredError())).toBe( + true, + ); + }); + + it("keeps unclassified failures unexpected", () => { + expect(isExpectedOnlineRpcFailureError(new Error("boom"))).toBe(false); + expect( + isExpectedOnlineRpcFailureError( + new CommandDispatchError( + "provider_bridge_unavailable", + "No plugin host artifact fetcher configured", + ), + ), + ).toBe(false); + }); }); diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index 1e140dba64..effc13702d 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -133,6 +133,13 @@ export function isExpectedCommandDispatchError( const EXPECTED_ONLINE_RPC_FAILURE_CODES = new Set([ "file_too_large", "provision_cancelled", + // An unauthenticated agent is a user-state outcome, not a daemon bug. The + // typed code still reaches the server, which logs and surfaces it (model + // load error, turn error), so the daemon-side "online host RPC failed" + // warn would only duplicate it with two nested stacks. Scoped to the + // auth_required code, so it covers every online RPC that can surface it, + // not just the provider.list_models probe. + "auth_required", ]); export function isExpectedOnlineRpcFailureError(error: unknown): boolean { diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts index 811d1303d2..fc643e4844 100644 --- a/apps/host-daemon/test/command/command-router.test.ts +++ b/apps/host-daemon/test/command/command-router.test.ts @@ -3,6 +3,7 @@ import type { HostDaemonOnlineRpcRequestMessage, HostDaemonOnlineRpcResponseMessage, } from "@bb/host-daemon-contract"; +import { AgentRuntimeRecoveryError } from "@bb/agent-runtime"; import { WorkspaceError } from "@bb/host-workspace"; import { encodeClientTurnRequestIdNumber, @@ -45,7 +46,7 @@ type ThreadStartCommand = Extract; type TurnSubmitCommand = Extract; interface RunRouterCommandArgs { - command: HostDaemonCommand; + command: HostDaemonOnlineRpcRequestMessage["command"]; requestId: string; router: CommandRouter; } @@ -61,6 +62,7 @@ interface CreateTurnSubmitCommandArgs { interface CreateRouterArgs { logger?: CommandRouterOptions["logger"]; + listModels?: CommandRouterOptions["listModels"]; resolveInteractiveRequest?: CommandRouterOptions["resolveInteractiveRequest"]; runtimeManager?: RuntimeManager; } @@ -85,6 +87,7 @@ function createRouter( fetchProjectAttachment: unexpectedProjectAttachmentFetch, fetchPluginHostArtifact: fetchDispatchTestArtifact, ...unexpectedProviderMaintenance, + ...(args.listModels === undefined ? {} : { listModels: args.listModels }), logger: { debug: () => undefined, warn: () => undefined, @@ -255,6 +258,96 @@ describe("CommandRouter", () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it("does not warn for a typed auth_required model-list failure", async () => { + const harness = createHarness({ workspacePath: "/tmp/env-router" }); + const logger = { + debug: vi.fn(), + warn: vi.fn(), + }; + const authMessage = "ACP agent is not authenticated."; + const router = createRouter(harness, { + logger, + listModels: async () => { + // What `@bb/agent-runtime` throws when an ACP bridge rejects the + // model probe with a typed `authRequired` recovery hint. + throw new AgentRuntimeRecoveryError({ + code: "auth_required", + message: authMessage, + recovery: { + kind: "authRequired", + message: authMessage, + providerId: "acp-cursor", + retryable: false, + }, + cause: new Error("Authentication required."), + }); + }, + }); + + const response = await runRouterCommand({ + command: { + type: "provider.list_models", + providerId: "acp-cursor", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + requestId: "auth-required-model-list", + router, + }); + + // The typed outcome still reaches the server intact. + expect(response).toMatchObject({ + ok: false, + commandType: "provider.list_models", + errorCode: "auth_required", + errorMessage: authMessage, + }); + // Only the duplicate warn is silenced; the debug accounting fires. + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + commandType: "provider.list_models", + errorCode: "auth_required", + ok: false, + }), + "Online host RPC", + ); + }); + + it("still warns for an unclassified model-list failure", async () => { + const harness = createHarness({ workspacePath: "/tmp/env-router" }); + const logger = { + debug: vi.fn(), + warn: vi.fn(), + }; + const router = createRouter(harness, { + logger, + listModels: async () => { + throw new Error("model list command crashed"); + }, + }); + + const response = await runRouterCommand({ + command: { + type: "provider.list_models", + providerId: "acp-cursor", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + requestId: "unclassified-model-list", + router, + }); + + expect(response).toMatchObject({ + ok: false, + errorCode: "command_failed", + errorMessage: "model list command crashed", + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { type: "provider.list_models", err: expect.any(Error) }, + "online host RPC failed", + ); + }); + it("orders turn.submit after an in-flight environment destroy", async () => { const harness = createHarness({ workspacePath: "/tmp/env-router" }); await harness.manager.ensureEnvironment({