From e60350be08747ded2c2b7c6c773259bb6aa5e558 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 15:55:14 +0700 Subject: [PATCH] refactor(claude-agent): inject binary resolver via client constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session previously called resolveClaudeBinary() directly in buildOptions(), with no seam for tests. CI ran without claude on PATH, so every test that exercised buildOptions hit the production throw. Apply the ports-and-adapters pattern at the client boundary (the same place tests already configure queryFactory): - ClaudeAgentClient takes optional resolveBinary, defaults to resolveClaudeBinary which throws "Claude binary not found..." when the executable is missing — preserving production fail-fast. - Client passes resolveBinary through to ClaudeAgentSession options. - buildOptions calls this.resolveBinary() instead of importing the free function directly. - Tests construct the public client with a stub resolver. No __claudeAgentInternals export, no reaching into the session. User-facing fail-fast still lives where it always did — AgentManager gates start/resume/draft flows on client.isAvailable(). The throw in resolveClaudeBinary is defense-in-depth that mirrors Codex. --- .../agent/providers/claude-agent.env.test.ts | 2 ++ ...agent.interrupt-restart-regression.test.ts | 14 ++++++------ .../providers/claude-agent.redesign.test.ts | 4 ++++ .../providers/claude-agent.spawn.test.ts | 1 + .../claude-agent.sub-agent-sidechain.test.ts | 10 +++++++-- .../agent/providers/claude-agent.test.ts | 17 ++++++++++---- .../server/agent/providers/claude-agent.ts | 22 +++++++++++++++++-- ...ude-agent.voice-history-regression.test.ts | 8 +++---- 8 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude-agent.env.test.ts b/packages/server/src/server/agent/providers/claude-agent.env.test.ts index c3a1c9a45..26b0e7f83 100644 --- a/packages/server/src/server/agent/providers/claude-agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.env.test.ts @@ -66,6 +66,7 @@ describe("Claude SDK env", () => { const client = new ClaudeAgentClient({ logger: createTestLogger(), queryFactory, + resolveBinary: async () => "/test/claude/bin", }); const session = await client.createSession( { @@ -123,6 +124,7 @@ describe("Claude SDK env", () => { const client = new ClaudeAgentClient({ logger: createTestLogger(), queryFactory, + resolveBinary: async () => "/test/claude/bin", }); const session = await client.resumeSession( { diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index 4ad86767a..bcf4c2e91 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -261,7 +261,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async () return scriptedQuery; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -311,7 +311,7 @@ test("reuses the existing query after interrupt before starting the next prompt" return scriptedQuery; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -361,7 +361,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn", return queryRef; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -455,7 +455,7 @@ test("recovers when the query pump sees a single interrupt abort before the next return scriptedQuery; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -488,7 +488,7 @@ test("stale abort result after replacement start does not poison the new foregro return queryRef; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -564,7 +564,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo return queryRef; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -634,7 +634,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a return queryRef; }); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 12b6cfca2..5befae014 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -66,6 +66,7 @@ async function createSession() { const client = new ClaudeAgentClient({ logger: createTestLogger(), queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", }); return client.createSession({ provider: "claude", @@ -77,6 +78,7 @@ function createSessionWithLogger(logger: Logger) { const client = new ClaudeAgentClient({ logger, queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", }); return client.createSession({ provider: "claude", @@ -223,6 +225,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () => PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret, }, }, + resolveBinary: async () => "/test/claude/bin", }); const session = await client.createSession({ provider: "claude", @@ -797,6 +800,7 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives const client = new ClaudeAgentClient({ logger: loggerSpy.logger, queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", }); const session = await client.createSession({ provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude-agent.spawn.test.ts b/packages/server/src/server/agent/providers/claude-agent.spawn.test.ts index d57f07402..5382d30a5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.spawn.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.spawn.test.ts @@ -77,6 +77,7 @@ describe("Claude spawn override", () => { const client = new ClaudeAgentClient({ logger: createTestLogger(), queryFactory, + resolveBinary: async () => "/test/claude/bin", }); const session = await client.createSession({ provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts index 9b7744a22..9f340de09 100644 --- a/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts @@ -251,7 +251,10 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { }); test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => { - const session = await new ClaudeAgentClient({ logger }).createSession({ + const session = await new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), }); @@ -300,7 +303,10 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { test("tails sub-agent actions instead of dropping latest entries at cap", async () => { sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); - const session = await new ClaudeAgentClient({ logger }).createSession({ + const session = await new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), }); 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 aba3740cc..98878b0a9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -346,7 +346,7 @@ describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); expect(models.map((m) => m.id)).toEqual([ @@ -430,7 +430,10 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { }); test("respondToPermission preserves full question input when UI returns answers-only payload", async () => { - const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -505,7 +508,7 @@ describe("ClaudeAgentSession context window usage", () => { const logger = createTestLogger(); async function createSessionForTest(): Promise { - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -605,6 +608,7 @@ describe("ClaudeAgentSession context window usage", () => { const nonPersistedClient = new ClaudeAgentClient({ logger, queryFactory: nonPersistedQueryFactory, + resolveBinary: async () => "/test/claude/bin", }); const nonPersistedSession = await nonPersistedClient.createSession( { @@ -623,6 +627,7 @@ describe("ClaudeAgentSession context window usage", () => { const persistedClient = new ClaudeAgentClient({ logger, queryFactory: persistedQueryFactory, + resolveBinary: async () => "/test/claude/bin", }); const persistedSession = await persistedClient.createSession( { @@ -980,7 +985,11 @@ describe("ClaudeAgentSession context window usage", () => { }, ], ]); - const client = new ClaudeAgentClient({ logger, queryFactory }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 8145ea035..55be78869 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -254,6 +254,7 @@ interface ClaudeAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; queryFactory?: typeof query; + resolveBinary?: () => Promise; } interface ClaudeAgentSessionOptions { @@ -264,6 +265,7 @@ interface ClaudeAgentSessionOptions { persistSession?: boolean; logger: Logger; queryFactory?: typeof query; + resolveBinary: () => Promise; } type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; @@ -1251,12 +1253,14 @@ export class ClaudeAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly queryFactory: typeof query; + private readonly resolveBinary: () => Promise; constructor(options: ClaudeAgentClientOptions) { this.defaults = options.defaults; this.logger = options.logger.child({ module: "agent", provider: "claude" }); this.runtimeSettings = options.runtimeSettings; this.queryFactory = options.queryFactory ?? query; + this.resolveBinary = options.resolveBinary ?? resolveClaudeBinary; } async createSession( @@ -1272,6 +1276,7 @@ export class ClaudeAgentClient implements AgentClient { persistSession: options?.persistSession, logger: this.logger, queryFactory: this.queryFactory, + resolveBinary: this.resolveBinary, }); } @@ -1298,6 +1303,7 @@ export class ClaudeAgentClient implements AgentClient { launchEnv: launchContext?.env, logger: this.logger, queryFactory: this.queryFactory, + resolveBinary: this.resolveBinary, }); } @@ -1381,6 +1387,16 @@ export class ClaudeAgentClient implements AgentClient { } } +async function resolveClaudeBinary(): Promise { + const found = await findExecutable("claude"); + if (found) { + return found; + } + throw new Error( + "Claude binary not found. Install Claude Code (https://github.com/anthropics/claude-code) and ensure it is available in your shell PATH.", + ); +} + async function resolveClaudeVersion( runtimeSettings?: ProviderRuntimeSettings, ): Promise { @@ -1549,6 +1565,7 @@ class ClaudeAgentSession implements AgentSession { private readonly persistSession?: boolean; private readonly logger: Logger; private readonly queryFactory: typeof query; + private readonly resolveBinary: () => Promise; private query: Query | null = null; private input: AsyncMessageInput | null = null; private claudeSessionId: string | null; @@ -1597,6 +1614,7 @@ class ClaudeAgentSession implements AgentSession { this.persistSession = options.persistSession; this.logger = options.logger; this.queryFactory = options.queryFactory ?? query; + this.resolveBinary = options.resolveBinary; const handle = options.handle; if (handle) { @@ -2283,7 +2301,7 @@ class ClaudeAgentSession implements AgentSession { ], }); - const claudeBinary = await findExecutable("claude"); + const claudeBinary = await this.resolveBinary(); this.logger.debug( { claudeBinary, @@ -2304,7 +2322,7 @@ class ClaudeAgentSession implements AgentSession { allowDangerouslySkipPermissions: true, agents: this.defaults?.agents, canUseTool: this.handlePermissionRequest, - ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), + pathToClaudeCodeExecutable: claudeBinary, // Use Claude Code preset system prompt and load CLAUDE.md files // Append provider-agnostic system prompt and orchestrator instructions for agents. systemPrompt: { diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index e10f1af97..af0393141 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -157,7 +157,7 @@ describe("ClaudeAgentSession history replay regression", () => { test("does not replay persisted history during the first live stream turn", async () => { const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -194,7 +194,7 @@ describe("ClaudeAgentSession history replay regression", () => { test("still exposes persisted history through streamHistory", async () => { const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -223,7 +223,7 @@ describe("ClaudeAgentSession history replay regression", () => { test("listCommands includes rewind command", async () => { const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -245,7 +245,7 @@ describe("ClaudeAgentSession history replay regression", () => { test("slash /rewind uses latest user message id from persisted history", async () => { const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger }); + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session",