Skip to content
Open
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
42 changes: 42 additions & 0 deletions apps/host-daemon/src/command-dispatch-support.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
});
});
7 changes: 7 additions & 0 deletions apps/host-daemon/src/command-dispatch-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
95 changes: 94 additions & 1 deletion apps/host-daemon/test/command/command-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -45,7 +46,7 @@ type ThreadStartCommand = Extract<HostDaemonCommand, { type: "thread.start" }>;
type TurnSubmitCommand = Extract<HostDaemonCommand, { type: "turn.submit" }>;

interface RunRouterCommandArgs {
command: HostDaemonCommand;
command: HostDaemonOnlineRpcRequestMessage["command"];
requestId: string;
router: CommandRouter;
}
Expand All @@ -61,6 +62,7 @@ interface CreateTurnSubmitCommandArgs {

interface CreateRouterArgs {
logger?: CommandRouterOptions["logger"];
listModels?: CommandRouterOptions["listModels"];
resolveInteractiveRequest?: CommandRouterOptions["resolveInteractiveRequest"];
runtimeManager?: RuntimeManager;
}
Expand All @@ -85,6 +87,7 @@ function createRouter(
fetchProjectAttachment: unexpectedProjectAttachmentFetch,
fetchPluginHostArtifact: fetchDispatchTestArtifact,
...unexpectedProviderMaintenance,
...(args.listModels === undefined ? {} : { listModels: args.listModels }),
logger: {
debug: () => undefined,
warn: () => undefined,
Expand Down Expand Up @@ -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({
Expand Down
Loading