diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index 8f8f039e3..aacbf046e 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -13,15 +13,13 @@ import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { createAgentMcpServer } from "./mcp-server.js"; import { createAllClients, shutdownProviders } from "./provider-registry.js"; +import { isProviderAvailable } from "../daemon-e2e/agent-configs.js"; import pino from "pino"; const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; const hasOpenAICredentials = !!process.env.OPENAI_API_KEY; -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; -const shouldRun = !process.env.CI && (hasOpenAICredentials || hasClaudeCredentials); interface AgentMcpServerHandle { url: string; @@ -147,13 +145,20 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { +describe("getStructuredAgentResponse (e2e)", () => { let manager: AgentManager; let cwd: string; let agentMcpServer: AgentMcpServerHandle; + let canRunCodex = false; + let canRunClaude = false; const logger = pino({ level: "silent" }); beforeAll(async () => { + canRunCodex = !process.env.CI && hasOpenAICredentials; + canRunClaude = await isProviderAvailable("claude"); + if (!canRunCodex && !canRunClaude) { + return; + } agentMcpServer = await startAgentMcpServer(logger); }); @@ -174,72 +179,70 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { - const schema = z.object({ - title: z.string(), - count: z.number(), - }); + test("returns schema-valid JSON from a real Codex agent", async (context) => { + if (!canRunCodex) { + context.skip(); + } + const schema = z.object({ + title: z.string(), + count: z.number(), + }); - const result = await generateStructuredAgentResponse({ - manager, - agentConfig: { - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Structured Response Test", - }, - prompt: "Return JSON with a short title and count 2.", - schema, - maxRetries: 1, - }); + const result = await generateStructuredAgentResponse({ + manager, + agentConfig: { + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Structured Response Test", + }, + prompt: "Return JSON with a short title and count 2.", + schema, + maxRetries: 1, + }); - expect(result.title.length).toBeGreaterThan(0); - expect(typeof result.count).toBe("number"); - }, - 180000, - ); + expect(result.title.length).toBeGreaterThan(0); + expect(typeof result.count).toBe("number"); + }, 180000); - test.runIf(hasClaudeCredentials)( - "returns schema-valid JSON from Claude Haiku", - async () => { - const schema = z.object({ - message: z.string(), - }); + test("returns schema-valid JSON from Claude Haiku", async (context) => { + if (!canRunClaude) { + context.skip(); + } + const schema = z.object({ + message: z.string(), + }); - let result: { message: string } | null = null; - let lastError: unknown = null; - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - result = await generateStructuredAgentResponse({ - manager, - agentConfig: { - provider: "claude", - model: "haiku", - thinkingOptionId: "on", - cwd, - title: "Claude Haiku Structured Test", - internal: true, - }, - prompt: - 'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}', - schema, - maxRetries: 6, - }); - lastError = null; - break; - } catch (error) { - lastError = error; - } - } - if (!result) { - throw lastError; + let result: { message: string } | null = null; + let lastError: unknown = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + result = await generateStructuredAgentResponse({ + manager, + agentConfig: { + provider: "claude", + model: "haiku", + thinkingOptionId: "on", + cwd, + title: "Claude Haiku Structured Test", + internal: true, + }, + prompt: + 'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}', + schema, + maxRetries: 6, + }); + lastError = null; + break; + } catch (error) { + lastError = error; } + } + if (!result) { + throw lastError; + } - expect(result.message.trim().toLowerCase()).toBe("hello"); - }, - 180000, - ); + expect(result.message.trim().toLowerCase()).toBe("hello"); + }, 180000); }); diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index ff689fdbc..43a02e12a 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -40,7 +40,7 @@ vi.mock("../../utils/executable.js", () => ({ isCommandAvailable: mockState.isCommandAvailable, })); -vi.mock("./providers/claude-agent.js", () => ({ +vi.mock("./providers/claude/agent.js", () => ({ ClaudeAgentClient: class ClaudeAgentClient { readonly capabilities = { supportsStreaming: true, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 941156aa6..0c46b9461 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -21,7 +21,7 @@ import type { ProviderProfileModel, ProviderRuntimeSettings, } from "./provider-launch-config.js"; -import { ClaudeAgentClient } from "./providers/claude-agent.js"; +import { ClaudeAgentClient } from "./providers/claude/agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts deleted file mode 100644 index e2a4b4978..000000000 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { describe, expect, test, beforeAll, beforeEach } from "vitest"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import pino from "pino"; -import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; - -import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { withTimeout } from "../../../utils/promise-timeout.js"; -import { spawnProcess } from "../../../utils/spawn.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; -import { streamSession } from "./test-utils/session-stream-adapter.js"; - -type SpawnClaudeOptions = NonNullable< - Parameters[0]["options"] ->["spawnClaudeCodeProcess"]; - -const spawnClaudeForTest: SpawnClaudeOptions = (options) => - spawnProcess(options.command, options.args, { - cwd: options.cwd, - env: options.env, - signal: options.signal, - stdio: ["pipe", "pipe", "pipe"], - shell: false, - }); - -const logger = pino({ level: "silent" }); -const client = new ClaudeAgentClient({ logger }); -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; - -function tmpCwd(prefix: string): string { - return mkdtempSync(path.join(tmpdir(), prefix)); -} - -function createEmptyPrompt(): AsyncGenerator { - return (async function* empty() {})(); -} - -function compactText(value: string): string { - return value.replace(/\s+/g, "").toLowerCase(); -} - -function isTerminalEvent(event: AgentStreamEvent): boolean { - return ( - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled" - ); -} - -async function nextStreamEvent( - stream: AsyncGenerator, - timeoutMs: number, - label: string, -): Promise> { - return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`); -} - -async function collectUntilTerminal( - stream: AsyncGenerator, - options?: { - timeoutMs?: number; - onEvent?: (event: AgentStreamEvent) => Promise | void; - }, -): Promise { - const events: AgentStreamEvent[] = []; - while (true) { - const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event"); - if (next.done || !next.value) { - return events; - } - const event = next.value; - events.push(event); - await options?.onEvent?.(event); - if (isTerminalEvent(event)) { - return events; - } - } -} - -async function collectUntil( - stream: AsyncGenerator, - predicate: (event: AgentStreamEvent) => boolean, - timeoutMs = 45_000, -): Promise { - const events: AgentStreamEvent[] = []; - while (true) { - const next = await nextStreamEvent(stream, timeoutMs, "matching stream event"); - if (next.done || !next.value) { - throw new Error("Stream ended before the expected event arrived"); - } - const event = next.value; - events.push(event); - if (predicate(event) || isTerminalEvent(event)) { - return events; - } - } -} - -function collectSubscribedUntil( - session: AgentSession, - predicate: (event: AgentStreamEvent) => boolean, - timeoutMs = 45_000, -): Promise { - return new Promise((resolve, reject) => { - const events: AgentStreamEvent[] = []; - const timeout = setTimeout(() => { - unsubscribe(); - reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`)); - }, timeoutMs); - - const unsubscribe = session.subscribe((event) => { - events.push(event); - if (!predicate(event)) { - return; - } - clearTimeout(timeout); - unsubscribe(); - resolve(events); - }); - }); -} - -function getAssistantText(events: AgentStreamEvent[]): string { - return events - .flatMap((event) => { - if (event.type !== "timeline" || event.item.type !== "assistant_message") { - return []; - } - return [event.item.text]; - }) - .join("\n"); -} - -function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] { - return events.flatMap((event) => { - if (event.type !== "timeline" || event.item.type !== "tool_call") { - return []; - } - return [event.item]; - }); -} - -function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined { - return [...getToolCalls(events)] - .toReversed() - .find((item) => item.status === "completed" && item.name.toLowerCase() === "bash"); -} - -function getInternalQuery(session: AgentSession): unknown { - return (session as AgentSession & { query?: unknown }).query ?? null; -} - -async function createSession(params?: { - cwdPrefix?: string; - modeId?: string; - title?: string; -}): Promise<{ cwd: string; session: AgentSession }> { - const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-"); - const session = await client.createSession({ - provider: "claude", - cwd, - title: params?.title ?? "ClaudeAgentSession integration", - modeId: params?.modeId ?? "acceptEdits", - model: "haiku", - }); - return { cwd, session }; -} - -async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { - await handle.session.close().catch(() => undefined); - try { - rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") { - throw error; - } - } -} - -describe("ClaudeAgentSession integration", () => { - let canRunClaudeIntegration = false; - - beforeAll(async () => { - canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; - if (canRunClaudeIntegration) { - expect(await isCommandAvailable("claude")).toBe(true); - } - }); - - beforeEach((context) => { - if (!canRunClaudeIntegration) { - context.skip(); - } - }); - - test("streams a basic response turn end-to-end", async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-basic-response-", - }); - - try { - const events = await collectUntilTerminal( - streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), - ); - - expect(events[0]).toMatchObject({ - type: "turn_started", - provider: "claude", - }); - expect( - events.some( - (event) => - event.type === "timeline" && - event.item.type === "assistant_message" && - compactText(event.item.text).includes("hello_world"), - ), - ).toBe(true); - expect(events.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("keeps bypassPermissions available after a thinking-option restart", async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-bypass-restart-", - modeId: "bypassPermissions", - }); - - try { - await handle.session.setMode("acceptEdits"); - await handle.session.setThinkingOption("high"); - await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined(); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("supportedModels returns the current abstract Claude SDK model shape", async () => { - const claudeBinary = await findExecutable("claude"); - if (!claudeBinary) throw new Error("claude binary required for this integration test"); - const claudeQuery = query({ - prompt: createEmptyPrompt(), - options: { - cwd: process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - settingSources: ["user", "project"], - pathToClaudeCodeExecutable: claudeBinary, - spawnClaudeCodeProcess: spawnClaudeForTest, - }, - }); - - try { - const models = await claudeQuery.supportedModels(); - - expect(models.length).toBeGreaterThanOrEqual(3); - expect(models).toContainEqual( - expect.objectContaining({ - value: "default", - displayName: "Default (recommended)", - supportedEffortLevels: ["low", "medium", "high", "max"], - }), - ); - expect(models).toContainEqual( - expect.objectContaining({ - value: "haiku", - displayName: "Haiku", - description: expect.stringContaining("Haiku 4.5"), - }), - ); - expect( - models.some( - (model) => - model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), - ), - ).toBe(true); - } finally { - await claudeQuery.return?.(); - } - }, 60_000); - - test.runIf(canRunClaudeIntegration)( - "runs a real Bash tool call and completes it", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-basic-tool-", - }); - - try { - const events = await collectUntilTerminal( - streamSession( - handle.session, - [ - "Use the Bash tool.", - "Run exactly: echo TOOL_TEST_OUTPUT", - "After the command completes, reply with exactly: TOOL_DONE", - ].join(" "), - ), - ); - - const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash"); - const completedBashCall = getLatestCompletedBashCall(events); - - expect(bashCalls.length).toBeGreaterThan(0); - expect(completedBashCall).toBeDefined(); - expect(completedBashCall?.detail.type).toBe("shell"); - expect( - completedBashCall?.detail.type === "shell" && - completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"), - ).toBe(true); - expect(compactText(getAssistantText(events))).toContain("tool_done"); - expect(events.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.runIf(canRunClaudeIntegration)( - "interrupts a running Bash turn and continues on the same query", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-interrupt-continue-", - }); - - try { - const firstStream = streamSession( - handle.session, - [ - "Use the Bash tool.", - "Run exactly: sleep 10", - "Do not use a background task.", - "Do not do anything after starting the command.", - ].join(" "), - ); - - const initialEvents = await collectUntil( - firstStream, - (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.name.toLowerCase() === "bash", - 45_000, - ); - const firstQuery = getInternalQuery(handle.session); - - expect(firstQuery).toBeTruthy(); - - await handle.session.interrupt(); - - const canceledEvents = await collectUntilTerminal(firstStream, { - timeoutMs: 20_000, - }); - const allFirstTurnEvents = [...initialEvents, ...canceledEvents]; - - expect( - allFirstTurnEvents.some( - (event) => event.type === "turn_canceled" && event.provider === "claude", - ), - ).toBe(true); - - const followUpEvents = await collectUntilTerminal( - streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"), - ); - const secondQuery = getInternalQuery(handle.session); - - expect(secondQuery).toBe(firstQuery); - expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok"); - expect(followUpEvents.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.runIf(canRunClaudeIntegration)( - "creates an autonomous live turn when a background task completes", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-autonomous-", - }); - const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - - try { - const foregroundEvents = await collectUntilTerminal( - streamSession( - handle.session, - [ - "Use the Task tool to start a background sub-agent.", - "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", - "Do not wait for task completion.", - "Reply immediately with exactly: SPAWNED", - `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - ), - { timeoutMs: 45_000 }, - ); - - expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned"); - - const liveEvents = await collectSubscribedUntil( - handle.session, - (event) => isTerminalEvent(event), - 45_000, - ); - - expect( - liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"), - ).toBe(true); - expect(compactText(getAssistantText(liveEvents))).toContain( - autonomousWakeToken.toLowerCase(), - ); - expect(liveEvents.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); - - test.runIf(canRunClaudeIntegration)( - "surfaces permission requests and resumes after approval", - async () => { - const handle = await createSession({ - cwdPrefix: "claude-agent-permission-", - modeId: "default", - }); - const permissionFile = path.join(handle.cwd, "permission.txt"); - - try { - const events = await collectUntilTerminal( - streamSession( - handle.session, - [ - "Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt", - "If approval is required, wait for approval.", - "After the command succeeds, reply with exactly: PERM_DONE", - ].join(" "), - ), - { - timeoutMs: 45_000, - onEvent: async (event) => { - if (event.type !== "permission_requested") { - return; - } - await handle.session.respondToPermission(event.request.id, { - behavior: "allow", - }); - }, - }, - ); - - const permissionRequest = events.find( - (event): event is Extract => - event.type === "permission_requested", - ); - const permissionResolved = events.find( - (event): event is Extract => - event.type === "permission_resolved", - ); - const completedBashCall = getLatestCompletedBashCall(events); - - expect(permissionRequest?.request.kind).toBe("tool"); - expect(permissionResolved).toMatchObject({ - type: "permission_resolved", - provider: "claude", - resolution: { behavior: "allow" }, - }); - expect(completedBashCall).toBeDefined(); - expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST"); - expect(compactText(getAssistantText(events))).toContain("perm_done"); - expect(events.at(-1)).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - } finally { - await cleanupSession(handle); - } - }, - 60_000, - ); -}); diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts similarity index 98% rename from packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts rename to packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts index 6bafe828d..39a07a6f0 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent-commands.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js"; +import { createDaemonTestContext, type DaemonTestContext } from "../../../test-utils/index.js"; // Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary. describe("claude agent commands E2E", () => { diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent-commands.real.e2e.test.ts similarity index 85% rename from packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts rename to packages/server/src/server/agent/providers/claude/agent-commands.real.e2e.test.ts index e8dc59a8c..f9823eca3 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent-commands.real.e2e.test.ts @@ -1,15 +1,15 @@ import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; -import { isCommandAvailable } from "../../../utils/executable.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; +import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; +import { ClaudeAgentClient } from "./agent.js"; // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. describe("claude agent commands contract (real)", () => { let canRun = false; beforeAll(async () => { - canRun = await isCommandAvailable("claude"); + canRun = await isProviderAvailable("claude"); }); beforeEach((context) => { @@ -19,8 +19,6 @@ describe("claude agent commands contract (real)", () => { }); test("lists slash commands with the expected contract", async () => { - expect(await isCommandAvailable("claude")).toBe(true); - const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }), }); 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 similarity index 90% rename from packages/server/src/server/agent/providers/claude-agent.env.test.ts rename to packages/server/src/server/agent/providers/claude/agent.env.test.ts index 26b0e7f83..018894af6 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 @@ -1,9 +1,10 @@ -import { query, type Query } from "@anthropic-ai/claude-agent-sdk"; +import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, test, vi } from "vitest"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import type { AgentLaunchContext } from "../agent-sdk-types.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentLaunchContext } from "../../agent-sdk-types.js"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryInput } from "./query.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -36,7 +37,7 @@ describe("Claude SDK env", () => { PASEO_TEST_FLAG: "launch-value", }, }; - const queryFactory = vi.fn(({ options }: Parameters[0]) => { + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { capturedEnv = options.env; return createQueryMock([ { @@ -94,7 +95,7 @@ describe("Claude SDK env", () => { PASEO_TEST_FLAG: "resume-launch-value", }, }; - const queryFactory = vi.fn(({ options }: Parameters[0]) => { + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { capturedEnv = options.env; return createQueryMock([ { diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/claude/agent.event-stream.integration.test.ts similarity index 96% rename from packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts rename to packages/server/src/server/agent/providers/claude/agent.event-stream.integration.test.ts index 5b32aad38..8ad45fed4 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.event-stream.integration.test.ts @@ -6,9 +6,7 @@ * * All tests use REAL Claude SDK sessions — no mocks. * - * CREDENTIALS: These tests require a running `claude` CLI and either - * CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment. - * They are skipped automatically when credentials are unavailable. + * These tests run when the shared Claude provider availability gate passes. */ import { beforeAll, beforeEach, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; @@ -17,8 +15,8 @@ import path from "node:path"; import pino from "pino"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../../utils/executable.js"; -import { ClaudeAgentClient } from "../claude-agent.js"; +import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; +import { ClaudeAgentClient } from "./agent.js"; // --------------------------------------------------------------------------- // Helpers @@ -26,8 +24,6 @@ import { ClaudeAgentClient } from "../claude-agent.js"; const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; function tmpCwd(prefix: string): string { return mkdtempSync(path.join(tmpdir(), prefix)); @@ -203,7 +199,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[ let canRun = false; beforeAll(async () => { - canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; + canRun = await isProviderAvailable("claude"); }); beforeEach((context) => { diff --git a/packages/server/src/server/agent/providers/claude-agent.history.test.ts b/packages/server/src/server/agent/providers/claude/agent.history.test.ts similarity index 92% rename from packages/server/src/server/agent/providers/claude-agent.history.test.ts rename to packages/server/src/server/agent/providers/claude/agent.history.test.ts index 2d9df9735..89df8aeb1 100644 --- a/packages/server/src/server/agent/providers/claude-agent.history.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { extractUserMessageText } from "./claude-agent.js"; +import { extractUserMessageText } from "./agent.js"; describe("extractUserMessageText", () => { test("returns trimmed string content", () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.integration.test.ts b/packages/server/src/server/agent/providers/claude/agent.integration.test.ts new file mode 100644 index 000000000..b8d1d4e35 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.integration.test.ts @@ -0,0 +1,471 @@ +import { describe, expect, test, beforeAll, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; + +import type { + AgentSession, + AgentStreamEvent, + ToolCallTimelineItem, +} from "../../agent-sdk-types.js"; +import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; +import { findExecutable } from "../../../../utils/executable.js"; +import { withTimeout } from "../../../../utils/promise-timeout.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeQuery } from "./query.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; + +const logger = pino({ level: "silent" }); +const client = new ClaudeAgentClient({ logger }); + +function tmpCwd(prefix: string): string { + return mkdtempSync(path.join(tmpdir(), prefix)); +} + +function createEmptyPrompt(): AsyncGenerator { + return (async function* empty() {})(); +} + +function compactText(value: string): string { + return value.replace(/\s+/g, "").toLowerCase(); +} + +function isTerminalEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ); +} + +async function nextStreamEvent( + stream: AsyncGenerator, + timeoutMs: number, + label: string, +): Promise> { + return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`); +} + +async function collectUntilTerminal( + stream: AsyncGenerator, + options?: { + timeoutMs?: number; + onEvent?: (event: AgentStreamEvent) => Promise | void; + }, +): Promise { + const events: AgentStreamEvent[] = []; + while (true) { + const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event"); + if (next.done || !next.value) { + return events; + } + const event = next.value; + events.push(event); + await options?.onEvent?.(event); + if (isTerminalEvent(event)) { + return events; + } + } +} + +async function collectUntil( + stream: AsyncGenerator, + predicate: (event: AgentStreamEvent) => boolean, + timeoutMs = 45_000, +): Promise { + const events: AgentStreamEvent[] = []; + while (true) { + const next = await nextStreamEvent(stream, timeoutMs, "matching stream event"); + if (next.done || !next.value) { + throw new Error("Stream ended before the expected event arrived"); + } + const event = next.value; + events.push(event); + if (predicate(event) || isTerminalEvent(event)) { + return events; + } + } +} + +function collectSubscribedUntil( + session: AgentSession, + predicate: (event: AgentStreamEvent) => boolean, + timeoutMs = 45_000, +): Promise { + return new Promise((resolve, reject) => { + const events: AgentStreamEvent[] = []; + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`)); + }, timeoutMs); + + const unsubscribe = session.subscribe((event) => { + events.push(event); + if (!predicate(event)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(events); + }); + }); +} + +function getAssistantText(events: AgentStreamEvent[]): string { + return events + .flatMap((event) => { + if (event.type !== "timeline" || event.item.type !== "assistant_message") { + return []; + } + return [event.item.text]; + }) + .join("\n"); +} + +function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] { + return events.flatMap((event) => { + if (event.type !== "timeline" || event.item.type !== "tool_call") { + return []; + } + return [event.item]; + }); +} + +function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined { + return [...getToolCalls(events)] + .toReversed() + .find((item) => item.status === "completed" && item.name.toLowerCase() === "bash"); +} + +function getInternalQuery(session: AgentSession): unknown { + return (session as AgentSession & { query?: unknown }).query ?? null; +} + +async function createSession(params?: { + cwdPrefix?: string; + modeId?: string; + title?: string; +}): Promise<{ cwd: string; session: AgentSession }> { + const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-"); + const session = await client.createSession({ + provider: "claude", + cwd, + title: params?.title ?? "ClaudeAgentSession integration", + modeId: params?.modeId ?? "acceptEdits", + model: "haiku", + }); + return { cwd, session }; +} + +async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { + await handle.session.close().catch(() => undefined); + try { + rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") { + throw error; + } + } +} + +describe("ClaudeAgentSession integration", () => { + let canRun = false; + + beforeAll(async () => { + canRun = await isProviderAvailable("claude"); + }); + + beforeEach((context) => { + if (!canRun) { + context.skip(); + } + }); + + test("streams a basic response turn end-to-end", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-basic-response-", + }); + + try { + const events = await collectUntilTerminal( + streamSession(handle.session, "Respond with exactly: HELLO_WORLD"), + ); + + expect(events[0]).toMatchObject({ + type: "turn_started", + provider: "claude", + }); + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + compactText(event.item.text).includes("hello_world"), + ), + ).toBe(true); + expect(events.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("keeps bypassPermissions available after a thinking-option restart", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-bypass-restart-", + modeId: "bypassPermissions", + }); + + try { + await handle.session.setMode("acceptEdits"); + await handle.session.setThinkingOption("high"); + await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined(); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("supportedModels returns the current abstract Claude SDK model shape", async () => { + const claudeBinary = await findExecutable("claude"); + if (!claudeBinary) throw new Error("claude binary required for this integration test"); + const query = claudeQuery({ + prompt: createEmptyPrompt(), + options: { + cwd: process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + settingSources: ["user", "project"], + pathToClaudeCodeExecutable: claudeBinary, + }, + }); + + try { + const models = await query.supportedModels(); + + expect(models.length).toBeGreaterThanOrEqual(3); + expect(models).toContainEqual( + expect.objectContaining({ + value: "default", + displayName: "Default (recommended)", + supportedEffortLevels: ["low", "medium", "high", "max"], + }), + ); + expect(models).toContainEqual( + expect.objectContaining({ + value: "haiku", + displayName: "Haiku", + description: expect.stringContaining("Haiku 4.5"), + }), + ); + expect( + models.some( + (model) => + model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"), + ), + ).toBe(true); + } finally { + await claudeQuery.return?.(); + } + }, 60_000); + + test("runs a real Bash tool call and completes it", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-basic-tool-", + }); + + try { + const events = await collectUntilTerminal( + streamSession( + handle.session, + [ + "Use the Bash tool.", + "Run exactly: echo TOOL_TEST_OUTPUT", + "After the command completes, reply with exactly: TOOL_DONE", + ].join(" "), + ), + ); + + const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash"); + const completedBashCall = getLatestCompletedBashCall(events); + + expect(bashCalls.length).toBeGreaterThan(0); + expect(completedBashCall).toBeDefined(); + expect(completedBashCall?.detail.type).toBe("shell"); + expect( + completedBashCall?.detail.type === "shell" && + completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"), + ).toBe(true); + expect(compactText(getAssistantText(events))).toContain("tool_done"); + expect(events.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("interrupts a running Bash turn and continues on the same query", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-interrupt-continue-", + }); + + try { + const firstStream = streamSession( + handle.session, + [ + "Use the Bash tool.", + "Run exactly: sleep 10", + "Do not use a background task.", + "Do not do anything after starting the command.", + ].join(" "), + ); + + const initialEvents = await collectUntil( + firstStream, + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name.toLowerCase() === "bash", + 45_000, + ); + const firstQuery = getInternalQuery(handle.session); + + expect(firstQuery).toBeTruthy(); + + await handle.session.interrupt(); + + const canceledEvents = await collectUntilTerminal(firstStream, { + timeoutMs: 20_000, + }); + const allFirstTurnEvents = [...initialEvents, ...canceledEvents]; + + expect( + allFirstTurnEvents.some( + (event) => event.type === "turn_canceled" && event.provider === "claude", + ), + ).toBe(true); + + const followUpEvents = await collectUntilTerminal( + streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"), + ); + const secondQuery = getInternalQuery(handle.session); + + expect(secondQuery).toBe(firstQuery); + expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok"); + expect(followUpEvents.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + } finally { + await cleanupSession(handle); + } + }, 60_000); + + test("creates an autonomous live turn when a background task completes", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-autonomous-", + }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; + + try { + const foregroundEvents = await collectUntilTerminal( + streamSession( + handle.session, + [ + "Use the Task tool to start a background sub-agent.", + "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", + "Do not wait for task completion.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + ), + { timeoutMs: 90_000 }, + ); + + expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned"); + + const liveEvents = await collectSubscribedUntil( + handle.session, + (event) => isTerminalEvent(event), + 90_000, + ); + + expect( + liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"), + ).toBe(true); + expect(compactText(getAssistantText(liveEvents))).toContain( + autonomousWakeToken.toLowerCase(), + ); + expect(liveEvents.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + } finally { + await cleanupSession(handle); + } + }, 180_000); + + test("surfaces permission requests and resumes after approval", async () => { + const handle = await createSession({ + cwdPrefix: "claude-agent-permission-", + modeId: "default", + }); + const permissionFile = path.join(handle.cwd, "permission.txt"); + + try { + const events = await collectUntilTerminal( + streamSession( + handle.session, + [ + "Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt", + "If approval is required, wait for approval.", + "After the command succeeds, reply with exactly: PERM_DONE", + ].join(" "), + ), + { + timeoutMs: 45_000, + onEvent: async (event) => { + if (event.type !== "permission_requested") { + return; + } + await handle.session.respondToPermission(event.request.id, { + behavior: "allow", + }); + }, + }, + ); + + const permissionRequest = events.find( + (event): event is Extract => + event.type === "permission_requested", + ); + const permissionResolved = events.find( + (event): event is Extract => + event.type === "permission_resolved", + ); + const completedBashCall = getLatestCompletedBashCall(events); + + expect(permissionRequest?.request.kind).toBe("tool"); + expect(permissionResolved).toMatchObject({ + type: "permission_resolved", + provider: "claude", + resolution: { behavior: "allow" }, + }); + expect(completedBashCall).toBeDefined(); + expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST"); + expect(compactText(getAssistantText(events))).toContain("perm_done"); + expect(events.at(-1)).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + } finally { + await cleanupSession(handle); + } + }, 60_000); +}); 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 similarity index 90% rename from packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts rename to packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index bcf4c2e91..c4f002d16 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 @@ -1,9 +1,9 @@ import { afterEach, expect, test, vi } from "vitest"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; -import { streamSession } from "./test-utils/session-stream-adapter.js"; -import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; interface QueryMock { next: ReturnType; @@ -41,13 +41,7 @@ type PromptHandler = (input: { query: ScriptedQuery; }) => void | Promise; -const sdkMocks = vi.hoisted(() => ({ - query: vi.fn(), -})); - -vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ - query: sdkMocks.query, -})); +const queryFactory = vi.fn(); function createAsyncQueue(): AsyncQueue { const items: T[] = []; @@ -245,14 +239,14 @@ async function waitFor( } afterEach(() => { - sdkMocks.query.mockReset(); + queryFactory.mockReset(); }); test("interrupt only calls query.interrupt and leaves the query open", async () => { const logger = createTestLogger(); const queries: ScriptedQuery[] = []; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { const scriptedQuery = createScriptedQuery({ prompt, sessionId: "interrupt-keep-query-session", @@ -261,7 +255,11 @@ test("interrupt only calls query.interrupt and leaves the query open", async () return scriptedQuery; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -274,7 +272,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async () await session.interrupt(); await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); - expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queryFactory).toHaveBeenCalledTimes(1); expect(queries[0]?.return).not.toHaveBeenCalled(); const firstTurnEvents = await collectUntilTerminal(firstTurn); @@ -291,7 +289,7 @@ test("reuses the existing query after interrupt before starting the next prompt" const logger = createTestLogger(); const queries: ScriptedQuery[] = []; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { const scriptedQuery = createScriptedQuery({ prompt, sessionId: "interrupt-reuse-query-session", @@ -311,7 +309,11 @@ test("reuses the existing query after interrupt before starting the next prompt" return scriptedQuery; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -326,7 +328,7 @@ test("reuses the existing query after interrupt before starting the next prompt" const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); - expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queryFactory).toHaveBeenCalledTimes(1); expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([ "first prompt", "second prompt", @@ -342,7 +344,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn", const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { queryRef = createScriptedQuery({ prompt, sessionId: "claude-original-session", @@ -361,7 +363,11 @@ test("emits an assistant system notice when Claude changes session id mid-turn", return queryRef; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -390,7 +396,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const prompts: PromptRecord[] = []; let throwAbortOnNext = false; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { const scriptedQuery = { next: vi.fn(async () => { if (throwAbortOnNext) { @@ -455,7 +461,11 @@ test("recovers when the query pump sees a single interrupt abort before the next return scriptedQuery; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -468,7 +478,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); - expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queryFactory).toHaveBeenCalledTimes(1); expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]); expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); @@ -480,7 +490,7 @@ test("stale abort result after replacement start does not poison the new foregro const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { queryRef = createScriptedQuery({ prompt, sessionId: "interrupt-stale-result-session", @@ -488,7 +498,11 @@ test("stale abort result after replacement start does not poison the new foregro return queryRef; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -545,7 +559,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { queryRef = createScriptedQuery({ prompt, sessionId: "autonomous-live-session", @@ -564,7 +578,11 @@ test("creates an autonomous live turn when assistant output arrives without a fo return queryRef; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -606,7 +624,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { queryRef = createScriptedQuery({ prompt, sessionId: "autonomous-handoff-session", @@ -634,7 +652,11 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a return queryRef; }); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const session = await client.createSession({ provider: "claude", cwd: process.cwd(), @@ -677,7 +699,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a (event) => event?.type === "turn_canceled", ), ).toBe(false); - expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([ "seed prompt", "foreground prompt", diff --git a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.max-effort.real.e2e.test.ts similarity index 79% rename from packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts rename to packages/server/src/server/agent/providers/claude/agent.max-effort.real.e2e.test.ts index 1cf02a848..aac27bfca 100644 --- a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.max-effort.real.e2e.test.ts @@ -1,13 +1,10 @@ import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import pino from "pino"; -import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; -import { streamSession } from "./test-utils/session-stream-adapter.js"; - -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; +import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js"; +import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; function isTerminalEvent(event: AgentStreamEvent): boolean { return ( @@ -32,7 +29,7 @@ describe("Claude max effort availability (real)", () => { let canRun = false; beforeAll(async () => { - canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; + canRun = await isProviderAvailable("claude"); }); beforeEach((context) => { 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 similarity index 98% rename from packages/server/src/server/agent/providers/claude-agent.redesign.test.ts rename to packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5befae014..4a698c17e 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 @@ -1,11 +1,11 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import type { Logger } from "pino"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { asInternals } from "../../test-utils/class-mocks.js"; -import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js"; -import { streamSession } from "./test-utils/session-stream-adapter.js"; -import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { asInternals } from "../../../test-utils/class-mocks.js"; +import { ClaudeAgentClient, readEventIdentifiers } from "./agent.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; interface QueryMock { next: ReturnType; @@ -660,7 +660,7 @@ test("Grep tool_result string content flows to a search detail with content", as grepEntry, ); - const { mapClaudeCompletedToolCall } = await import("./claude/tool-call-mapper.js"); + const { mapClaudeCompletedToolCall } = await import("./tool-call-mapper.js"); const item = mapClaudeCompletedToolCall({ callId: "tool-grep-1", name: "Grep", 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 similarity index 88% rename from packages/server/src/server/agent/providers/claude-agent.spawn.test.ts rename to packages/server/src/server/agent/providers/claude/agent.spawn.test.ts index 5382d30a5..0ccc74b05 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 @@ -1,16 +1,16 @@ import { EventEmitter } from "node:events"; import type { ChildProcess } from "node:child_process"; -import { - query, - type Options, - type Query, - type SpawnOptions as ClaudeSpawnOptions, +import type { + Options, + Query, + SpawnOptions as ClaudeSpawnOptions, } from "@anthropic-ai/claude-agent-sdk"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import * as spawnUtils from "../../../utils/spawn.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import * as spawnUtils from "../../../../utils/spawn.js"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryInput } from "./query.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -47,7 +47,7 @@ describe("Claude spawn override", () => { test("bypasses the shell when spawning Claude Code", async () => { let capturedOptions: Options | undefined; - const queryFactory = vi.fn(({ options }: Parameters[0]) => { + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { capturedOptions = options; return createQueryMock([ { 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 similarity index 92% rename from packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts rename to packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index 9f340de09..ca87fee3e 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 @@ -1,19 +1,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import type { AgentTimelineRow } from "../agent-manager.js"; -import { projectTimelineRows } from "../timeline-projection.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; -import { streamSession } from "./test-utils/session-stream-adapter.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentTimelineRow } from "../../agent-manager.js"; +import { projectTimelineRows } from "../../timeline-projection.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; -const sdkMocks = vi.hoisted(() => ({ - query: vi.fn(), -})); - -vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ - query: sdkMocks.query, -})); +const queryFactory = vi.fn(); interface QueryMock { next: ReturnType; @@ -150,7 +144,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { beforeEach(() => { const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50); - sdkMocks.query.mockImplementation(() => + queryFactory.mockImplementation(() => buildQueryMock([ { type: "system", @@ -247,12 +241,13 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { }); afterEach(() => { - sdkMocks.query.mockReset(); + queryFactory.mockReset(); }); test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => { const session = await new ClaudeAgentClient({ logger, + queryFactory, resolveBinary: async () => "/test/claude/bin", }).createSession({ provider: "claude", @@ -301,10 +296,11 @@ 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))); + queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); const session = await new ClaudeAgentClient({ logger, + queryFactory, resolveBinary: async () => "/test/claude/bin", }).createSession({ provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts similarity index 99% rename from packages/server/src/server/agent/providers/claude-agent.test.ts rename to packages/server/src/server/agent/providers/claude/agent.test.ts index 98878b0a9..a15c1999b 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test, vi } from "vitest"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry, normalizeClaudeAskUserQuestionUpdatedInput, -} from "./claude-agent.js"; -import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js"; +} from "./agent.js"; +import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts similarity index 96% rename from packages/server/src/server/agent/providers/claude-agent.ts rename to packages/server/src/server/agent/providers/claude/agent.ts index 55be78869..de191d80c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,20 +1,16 @@ -import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; import os from "node:os"; import path from "node:path"; import { - query, type AgentDefinition, type CanUseTool, type McpServerConfig as ClaudeSdkMcpServerConfig, - type Options, type PermissionMode, type PermissionResult, type PermissionUpdate, type Query, - type SpawnOptions, type SDKMessage, type SDKPartialAssistantMessage, type SDKTaskProgressMessage, @@ -28,22 +24,23 @@ import { mapClaudeCompletedToolCall, mapClaudeFailedToolCall, mapClaudeRunningToolCall, -} from "./claude/tool-call-mapper.js"; +} from "./tool-call-mapper.js"; import { mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, -} from "./claude/task-notification-tool-call.js"; -import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js"; -import { parsePartialJsonObject } from "./claude/partial-json.js"; -import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; +} from "./task-notification-tool-call.js"; +import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; +import { parsePartialJsonObject } from "./partial-json.js"; +import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, toDiagnosticErrorMessage, -} from "./diagnostic-utils.js"; -import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; -import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +} from "../diagnostic-utils.js"; +import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js"; +import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import type { AgentPermissionAction, @@ -73,20 +70,19 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, -} from "../agent-sdk-types.js"; +} from "../../agent-sdk-types.js"; import { createProviderEnv, createProviderEnvSpec, type ProviderRuntimeSettings, -} from "../provider-launch-config.js"; -import { buildSelfNodeCommand } from "../../paseo-env.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { withTimeout } from "../../../utils/promise-timeout.js"; -import { execCommand, spawnProcess } from "../../../utils/spawn.js"; -import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; +} from "../../provider-launch-config.js"; +import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js"; +import { withTimeout } from "../../../../utils/promise-timeout.js"; +import { execCommand } from "../../../../utils/spawn.js"; +import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js"; const fsPromises = promises; -const CLAUDE_SETTING_SOURCES: NonNullable = ["user", "project"]; +const CLAUDE_SETTING_SOURCES: NonNullable = ["user", "project"]; function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; @@ -155,10 +151,6 @@ function isUnknownArray(value: unknown): value is readonly unknown[] { return Array.isArray(value); } -function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams { - return child.stdin !== null && child.stdout !== null && child.stderr !== null; -} - function isImageMimeType( value: string, ): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" { @@ -239,7 +231,6 @@ interface SlashCommandInvocation { rawInput: string; } -// Orchestrator instructions moved to shared module. type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; export interface ClaudeContentChunk { @@ -247,13 +238,11 @@ export interface ClaudeContentChunk { [key: string]: unknown; } -type ClaudeOptions = Options; - interface ClaudeAgentClientOptions { defaults?: { agents?: Record }; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - queryFactory?: typeof query; + queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; } @@ -264,7 +253,7 @@ interface ClaudeAgentSessionOptions { launchEnv?: Record; persistSession?: boolean; logger: Logger; - queryFactory?: typeof query; + queryFactory?: ClaudeQueryFactory; resolveBinary: () => Promise; } @@ -305,86 +294,6 @@ function extractSessionIdRaw(msg: { return ""; } -function resolveClaudeSpawnCommand( - spawnOptions: SpawnOptions, - runtimeSettings?: ProviderRuntimeSettings, -): { command: string; args: string[] } { - const commandConfig = runtimeSettings?.command; - if (!commandConfig || commandConfig.mode === "default") { - return { - command: spawnOptions.command, - args: [...spawnOptions.args], - }; - } - - if (commandConfig.mode === "append") { - return { - command: spawnOptions.command, - args: [...spawnOptions.args, ...(commandConfig.args ?? [])], - }; - } - - return { - command: commandConfig.argv[0], - args: [...commandConfig.argv.slice(1), ...spawnOptions.args], - }; -} - -function applyRuntimeSettingsToClaudeOptions( - options: ClaudeOptions, - runtimeSettings?: ProviderRuntimeSettings, - launchEnv?: Record, -): ClaudeOptions { - return { - ...options, - spawnClaudeCodeProcess: (spawnOptions) => { - const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings); - // When the SDK passes a default JS runtime ("node"/"bun"), replace it with - // process.execPath — the actual node binary running the daemon. This avoids - // PATH lookup failures in the managed runtime bundle. - // When the SDK passes a native binary path (from pathToClaudeCodeExecutable) - // or the user overrides the command via runtime settings, use that directly. - const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun"; - const providerEnvSpec = createProviderEnvSpec({ - baseEnv: spawnOptions.env, - runtimeSettings, - overlays: [launchEnv], - }); - const providerEnv = createProviderEnv({ - baseEnv: spawnOptions.env, - runtimeSettings, - overlays: [launchEnv], - }); - const selfNodeCommand = isDefaultRuntime - ? buildSelfNodeCommand(resolved.args, providerEnv) - : null; - const command = selfNodeCommand?.command ?? resolved.command; - const args = selfNodeCommand?.args ?? resolved.args; - const child = spawnProcess(command, args, { - cwd: spawnOptions.cwd, - ...(selfNodeCommand - ? { env: selfNodeCommand.env, envMode: "internal" as const } - : providerEnvSpec), - signal: spawnOptions.signal, - stdio: ["pipe", "pipe", "pipe"], - // Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON - // containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing). - // The command is always a resolved binary path, so shell routing is unnecessary. - shell: false, - }); - if (typeof options.stderr === "function") { - child.stderr?.on("data", (chunk: Buffer | string) => { - options.stderr?.(chunk.toString()); - }); - } - if (!isChildProcessWithStreams(child)) { - throw new Error("Claude process was spawned without stdio streams"); - } - return child; - }, - }; -} - function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort { return ( value === "low" || @@ -1252,14 +1161,14 @@ export class ClaudeAgentClient implements AgentClient { private readonly defaults?: { agents?: Record }; private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly queryFactory: typeof query; + private readonly queryFactory?: ClaudeQueryFactory; 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.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary ?? resolveClaudeBinary; } @@ -1564,7 +1473,7 @@ class ClaudeAgentSession implements AgentSession { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly persistSession?: boolean; private readonly logger: Logger; - private readonly queryFactory: typeof query; + private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private query: Query | null = null; private input: AsyncMessageInput | null = null; @@ -1613,7 +1522,7 @@ class ClaudeAgentSession implements AgentSession { this.runtimeSettings = options.runtimeSettings; this.persistSession = options.persistSession; this.logger = options.logger; - this.queryFactory = options.queryFactory ?? query; + this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary; const handle = options.handle; @@ -2234,7 +2143,14 @@ class ClaudeAgentSession implements AgentSession { const options = await this.buildOptions(); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; - this.query = this.queryFactory({ prompt: input.iterable, options }); + this.query = claudeQuery( + { prompt: input.iterable, options }, + { + runtimeSettings: this.runtimeSettings, + launchEnv: this.launchEnv, + queryFactory: this.queryFactory, + }, + ); // Do not kick off background control-plane queries here. Methods like // supportedCommands()/setPermissionMode() may execute immediately after // ensureQuery() (for listCommands()/setMode()), and sharing the same query @@ -2364,11 +2280,7 @@ class ClaudeAgentSession implements AgentSession { ...this.runtimeSettings.disallowedTools, ]; } - return this.applyRuntimeSettings(base); - } - - private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions { - return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv); + return base; } private normalizeMcpServers( 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 similarity index 86% rename from packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts rename to packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index af0393141..449e52dfe 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 @@ -3,19 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; -import { streamSession } from "./test-utils/session-stream-adapter.js"; -import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; +import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js"; -const sdkMocks = vi.hoisted(() => ({ - query: vi.fn(), - lastQuery: null as ReturnType | null, -})); - -vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ - query: sdkMocks.query, -})); +const queryFactory = vi.fn(); +let lastQuery: ReturnType | null = null; const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; @@ -99,9 +93,9 @@ describe("ClaudeAgentSession history replay regression", () => { let previousClaudeConfigDir: string | undefined; beforeEach(() => { - sdkMocks.query.mockImplementation(() => { + queryFactory.mockImplementation(() => { const mock = buildSdkQueryMock(); - sdkMocks.lastQuery = mock; + lastQuery = mock; return mock; }); @@ -145,8 +139,8 @@ describe("ClaudeAgentSession history replay regression", () => { }); afterEach(() => { - sdkMocks.query.mockReset(); - sdkMocks.lastQuery = null; + queryFactory.mockReset(); + lastQuery = null; if (previousClaudeConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR; } else { @@ -157,7 +151,11 @@ 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, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -194,7 +192,11 @@ describe("ClaudeAgentSession history replay regression", () => { test("still exposes persisted history through streamHistory", async () => { const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -223,7 +225,11 @@ describe("ClaudeAgentSession history replay regression", () => { test("listCommands includes rewind command", async () => { const logger = createTestLogger(); - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -245,7 +251,11 @@ 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, resolveBinary: async () => "/test/claude/bin" }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); const handle: AgentPersistenceHandle = { provider: "claude", sessionId: "history-session", @@ -276,9 +286,9 @@ describe("ClaudeAgentSession history replay regression", () => { expect(events.some((event) => event.type === "turn_started")).toBe(true); expect(events.some((event) => event.type === "turn_completed")).toBe(true); - expect(sdkMocks.lastQuery).toBeTruthy(); - expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1); - expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", { + expect(lastQuery).toBeTruthy(); + expect(lastQuery?.rewindFiles).toHaveBeenCalledTimes(1); + expect(lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", { dryRun: false, }); }); diff --git a/packages/server/src/server/agent/providers/claude/claude-models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts similarity index 99% rename from packages/server/src/server/agent/providers/claude/claude-models.test.ts rename to packages/server/src/server/agent/providers/claude/models.test.ts index 6f96cd1a5..1d3ae41a1 100644 --- a/packages/server/src/server/agent/providers/claude/claude-models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js"; +import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; describe("getClaudeModels", () => { it("returns all claude models", () => { diff --git a/packages/server/src/server/agent/providers/claude/claude-models.ts b/packages/server/src/server/agent/providers/claude/models.ts similarity index 100% rename from packages/server/src/server/agent/providers/claude/claude-models.ts rename to packages/server/src/server/agent/providers/claude/models.ts diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts new file mode 100644 index 000000000..a1917e400 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -0,0 +1,120 @@ +import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk"; + +import { + createProviderEnv, + createProviderEnvSpec, + type ProviderRuntimeSettings, +} from "../../provider-launch-config.js"; +import { buildSelfNodeCommand } from "../../../paseo-env.js"; +import { spawnProcess } from "../../../../utils/spawn.js"; + +// Keep the raw SDK query import in this module only. Claude process launch behavior +// must stay shared between production and tests so Windows .cmd/.bat handling cannot +// diverge from the daemon path. + +export type ClaudeOptions = Options; +export type ClaudeQueryInput = Parameters[0] & { options: ClaudeOptions }; +export type ClaudeQueryFactory = (input: ClaudeQueryInput) => Query; + +export interface ClaudeQueryContext { + runtimeSettings?: ProviderRuntimeSettings; + launchEnv?: Record; + queryFactory?: ClaudeQueryFactory; +} + +function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams { + return child.stdin !== null && child.stdout !== null && child.stderr !== null; +} + +function resolveClaudeSpawnCommand( + spawnOptions: SpawnOptions, + runtimeSettings?: ProviderRuntimeSettings, +): { command: string; args: string[] } { + const commandConfig = runtimeSettings?.command; + if (!commandConfig || commandConfig.mode === "default") { + return { + command: spawnOptions.command, + args: [...spawnOptions.args], + }; + } + + if (commandConfig.mode === "append") { + return { + command: spawnOptions.command, + args: [...spawnOptions.args, ...(commandConfig.args ?? [])], + }; + } + + return { + command: commandConfig.argv[0], + args: [...commandConfig.argv.slice(1), ...spawnOptions.args], + }; +} + +function applyRuntimeSettingsToClaudeOptions( + options: ClaudeOptions, + runtimeSettings?: ProviderRuntimeSettings, + launchEnv?: Record, +): ClaudeOptions { + return { + ...options, + spawnClaudeCodeProcess: (spawnOptions) => { + const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings); + // When the SDK passes a default JS runtime ("node"/"bun"), replace it with + // process.execPath — the actual node binary running the daemon. This avoids + // PATH lookup failures in the managed runtime bundle. + // When the SDK passes a native binary path (from pathToClaudeCodeExecutable) + // or the user overrides the command via runtime settings, use that directly. + const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun"; + const providerEnvSpec = createProviderEnvSpec({ + baseEnv: spawnOptions.env, + runtimeSettings, + overlays: [launchEnv], + }); + const providerEnv = createProviderEnv({ + baseEnv: spawnOptions.env, + runtimeSettings, + overlays: [launchEnv], + }); + const selfNodeCommand = isDefaultRuntime + ? buildSelfNodeCommand(resolved.args, providerEnv) + : null; + const command = selfNodeCommand?.command ?? resolved.command; + const args = selfNodeCommand?.args ?? resolved.args; + const child = spawnProcess(command, args, { + cwd: spawnOptions.cwd, + ...(selfNodeCommand + ? { env: selfNodeCommand.env, envMode: "internal" as const } + : providerEnvSpec), + signal: spawnOptions.signal, + stdio: ["pipe", "pipe", "pipe"], + // Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON + // containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing). + // The command is always a resolved binary path, so shell routing is unnecessary. + shell: false, + }); + if (typeof options.stderr === "function") { + child.stderr?.on("data", (chunk: Buffer | string) => { + options.stderr?.(chunk.toString()); + }); + } + if (!isChildProcessWithStreams(child)) { + throw new Error("Claude process was spawned without stdio streams"); + } + return child; + }, + }; +} + +export function claudeQuery(input: ClaudeQueryInput, context: ClaudeQueryContext = {}): Query { + const launchQuery = context.queryFactory ?? query; + return launchQuery({ + ...input, + options: applyRuntimeSettingsToClaudeOptions( + input.options, + context.runtimeSettings, + context.launchEnv, + ), + }); +} diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude/sdk-behavior.test.ts similarity index 79% rename from packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts rename to packages/server/src/server/agent/providers/claude/sdk-behavior.test.ts index 4b17fdf31..77f521d0b 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude/sdk-behavior.test.ts @@ -1,26 +1,14 @@ /** - * Direct SDK behavior tests - uses same setup as claude-agent.ts + * Direct SDK behavior tests - uses same setup as the Claude provider */ import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; -import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { spawnProcess } from "../../../utils/spawn.js"; - -type SpawnClaudeOptions = NonNullable< - Parameters[0]["options"] ->["spawnClaudeCodeProcess"]; - -const spawnClaudeForTest: SpawnClaudeOptions = (options) => - spawnProcess(options.command, options.args, { - cwd: options.cwd, - env: options.env, - signal: options.signal, - stdio: ["pipe", "pipe", "pipe"], - shell: false, - }); +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; +import { findExecutable } from "../../../../utils/executable.js"; +import { claudeQuery } from "./query.js"; class Pushable implements AsyncIterable { private queue: T[] = []; @@ -97,21 +85,15 @@ function extractTextFromEvents(events: SDKMessage[]): string { return responseText; } -const hasClaudeCredentials = - !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; - describe("Claude SDK direct behavior", () => { - let canRunClaudeIntegration = false; + let canRun = false; beforeAll(async () => { - canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials; - if (canRunClaudeIntegration) { - expect(await isCommandAvailable("claude")).toBe(true); - } + canRun = await isProviderAvailable("claude"); }); beforeEach((context) => { - if (!canRunClaudeIntegration) { + if (!canRun) { context.skip(); } }); @@ -121,15 +103,14 @@ describe("Claude SDK direct behavior", () => { const input = new Pushable(); const claudeBinary = await findExecutable("claude"); - // Use same options as claude-agent.ts - const q = query({ + // Use same options as the Claude provider + const q = claudeQuery({ prompt: input, options: { cwd, includePartialMessages: true, permissionMode: "bypassPermissions", ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), - spawnClaudeCodeProcess: spawnClaudeForTest, systemPrompt: { type: "preset", preset: "claude_code", diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index 17541f334..3f371e542 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -8,7 +8,7 @@ import type { AgentProvider } from "../agent-sdk-types.js"; import { AgentManager } from "../agent-manager.js"; import { AgentStorage } from "../agent-storage.js"; -import { ClaudeAgentClient } from "./claude-agent.js"; +import { ClaudeAgentClient } from "./claude/agent.js"; import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index 7068af84d..58e3d1d37 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -110,10 +110,9 @@ export function isProviderAvailable(provider: AgentProvider): Promise { const availability = (async (): Promise => { switch (provider) { case "claude": - return ( - (await isCommandAvailable("claude")) && - (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) - ); + const hasClaudeEnvCredentials = + Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY); + return (await isCommandAvailable("claude")) && (!process.env.CI || hasClaudeEnvCredentials); case "codex": return ( (await isCommandAvailable("codex")) && diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 465cd59a8..bd9e7a971 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index fecc3fabe..d1de53e77 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import pino from "pino"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; diff --git a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts index fa908b68c..5a2437f47 100644 --- a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import pino from "pino"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; diff --git a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts index 1c76df462..5938cc707 100644 --- a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts @@ -6,7 +6,7 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index 9a5e5f7a5..761fe6a40 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -6,7 +6,7 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index 02aa75e14..2dc87b02c 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -6,7 +6,7 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; function tmpCwd(): string { diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 4f66d02f9..7fca214b1 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -6,7 +6,7 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { createMessageCollector } from "../test-utils/message-collector.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index ced32c2b3..2dfdab257 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -6,7 +6,7 @@ import pino from "pino"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js"; diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index 794adf0ba..1e360c005 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import pino from "pino"; import type { AgentClient } from "../agent/agent-sdk-types.js"; -import { ClaudeAgentClient } from "../agent/providers/claude-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 84bcbeee4..d0bb87280 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -897,12 +897,20 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (processExited) { return Promise.resolve(true); @@ -940,7 +948,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise