diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index a15c1999b..ee7bb1c84 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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; + } + ).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( diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index de191d80c..dd9cfd56d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 { +async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise { + 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;