Fix Claude binary resolution for replace-mode command override

isAvailable() honored runtimeSettings.command.mode === "replace" but
resolveClaudeBinary() only looked for `claude` on PATH. Users with a
custom claude wrapper and no `claude` itself on PATH passed the
availability check and then failed at session creation with "Claude
binary not found".

resolveClaudeBinary now mirrors isAvailable: if the runtime command is
in replace mode, look up the override binary first.
This commit is contained in:
Mohamed Boudra
2026-05-08 20:13:27 +07:00
parent 5b6e974248
commit ad08868778
2 changed files with 69 additions and 3 deletions
@@ -1,7 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import * as executableUtils from "../../../../utils/executable.js";
import {
ClaudeAgentClient,
convertClaudeHistoryEntry,
@@ -14,6 +15,10 @@ interface TestClaudeSession {
convertUsage(message: SDKMessage): AgentUsage | undefined;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test";
@@ -369,6 +374,59 @@ describe("ClaudeAgentClient.listModels", () => {
});
});
describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger();
test("uses the replace-command override binary when claude is not on PATH", async () => {
const customClaudePath = "/path/to/custom-claude";
vi.spyOn(executableUtils, "findExecutable").mockImplementation(async (name: string) => {
if (name === "claude") {
return null;
}
if (name === customClaudePath) {
return customClaudePath;
}
return null;
});
const queryReturn = vi.fn();
queryReturn.mockResolvedValue(undefined);
const queryFactory = vi.fn(() => ({
close: vi.fn(),
return: queryReturn,
}));
const client = new ClaudeAgentClient({
logger,
queryFactory,
runtimeSettings: {
command: {
mode: "replace",
argv: [customClaudePath],
},
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await expect(
(
session as unknown as {
ensureQuery(): Promise<unknown>;
}
).ensureQuery(),
).resolves.toBeDefined();
expect(queryFactory.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable).toBe(
customClaudePath,
);
await session.close();
});
});
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
test("maps frontend header-keyed answers to Claude question text keys", () => {
expect(
@@ -1169,7 +1169,7 @@ export class ClaudeAgentClient implements AgentClient {
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary ?? resolveClaudeBinary;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
}
async createSession(
@@ -1296,7 +1296,15 @@ export class ClaudeAgentClient implements AgentClient {
}
}
async function resolveClaudeBinary(): Promise<string> {
async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise<string> {
const command = runtimeSettings?.command;
if (command?.mode === "replace") {
const foundOverride = await findExecutable(command.argv[0]);
if (foundOverride) {
return foundOverride;
}
}
const found = await findExecutable("claude");
if (found) {
return found;