Centralize Claude SDK launch path

This commit is contained in:
Mohamed Boudra
2026-05-08 18:47:56 +07:00
parent e490bf1dab
commit 5c90449707
34 changed files with 864 additions and 851 deletions
@@ -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<AgentMcpServerH
};
}
(shouldRun ? describe : describe.skip)("getStructuredAgentResponse (e2e)", () => {
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<AgentMcpServerH
await shutdownProviders(logger);
}, 60000);
test.runIf(hasOpenAICredentials)(
"returns schema-valid JSON from a real Codex agent",
async () => {
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);
});
@@ -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,
@@ -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";
@@ -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<typeof query>[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<SDKUserMessage, void, undefined> {
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<AgentStreamEvent>,
timeoutMs: number,
label: string,
): Promise<IteratorResult<AgentStreamEvent>> {
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>,
options?: {
timeoutMs?: number;
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
},
): Promise<AgentStreamEvent[]> {
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<AgentStreamEvent>,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
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<AgentStreamEvent[]> {
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<void> {
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<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
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,
);
});
@@ -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", () => {
@@ -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" }),
});
@@ -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<typeof query>[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<typeof query>[0]) => {
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedEnv = options.env;
return createQueryMock([
{
@@ -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) => {
@@ -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", () => {
@@ -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<SDKUserMessage, void, undefined> {
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<AgentStreamEvent>,
timeoutMs: number,
label: string,
): Promise<IteratorResult<AgentStreamEvent>> {
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>,
options?: {
timeoutMs?: number;
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
},
): Promise<AgentStreamEvent[]> {
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<AgentStreamEvent>,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
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<AgentStreamEvent[]> {
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<void> {
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<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
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);
});
@@ -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<typeof vi.fn>;
@@ -41,13 +41,7 @@ type PromptHandler = (input: {
query: ScriptedQuery;
}) => void | Promise<void>;
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const queryFactory = vi.fn();
function createAsyncQueue<T>(): AsyncQueue<T> {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
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",
@@ -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) => {
@@ -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<typeof vi.fn>;
@@ -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",
@@ -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<typeof query>[0]) => {
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
return createQueryMock([
{
@@ -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<typeof vi.fn>;
@@ -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",
@@ -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[];
@@ -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<Options["settingSources"]> = ["user", "project"];
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = ["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<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: typeof query;
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
}
@@ -264,7 +253,7 @@ interface ClaudeAgentSessionOptions {
launchEnv?: Record<string, string>;
persistSession?: boolean;
logger: Logger;
queryFactory?: typeof query;
queryFactory?: ClaudeQueryFactory;
resolveBinary: () => Promise<string>;
}
@@ -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<string, string>,
): 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<string, AgentDefinition> };
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory: typeof query;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
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<string>;
private query: Query | null = null;
private input: AsyncMessageInput<SDKUserMessage> | 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(
@@ -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<typeof buildSdkQueryMock> | null,
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const queryFactory = vi.fn();
let lastQuery: ReturnType<typeof buildSdkQueryMock> | 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,
});
});
@@ -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", () => {
@@ -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<typeof query>[0] & { options: ClaudeOptions };
export type ClaudeQueryFactory = (input: ClaudeQueryInput) => Query;
export interface ClaudeQueryContext {
runtimeSettings?: ProviderRuntimeSettings;
launchEnv?: Record<string, string>;
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<string, string>,
): 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,
),
});
}
@@ -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<typeof query>[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<T> implements AsyncIterable<T> {
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<SDKUserMessage>();
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",
@@ -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";
@@ -110,10 +110,9 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
const availability = (async (): Promise<boolean> => {
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")) &&
@@ -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";
@@ -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";
@@ -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";
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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";
@@ -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";
@@ -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";
+11 -3
View File
@@ -897,12 +897,20 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
function kill(): void {
if (!killed) {
killed = true;
ptyProcess.kill();
killPtyProcess();
emitExit(buildExitInfo());
}
disposeResources();
}
function killPtyProcess(signal?: NodeJS.Signals): void {
if (process.platform === "win32") {
ptyProcess.kill();
return;
}
ptyProcess.kill(signal);
}
function waitForProcessExit(timeoutMs: number): Promise<boolean> {
if (processExited) {
return Promise.resolve(true);
@@ -940,7 +948,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
try {
ptyProcess.kill();
killPtyProcess();
} catch {
// process may already be gone
}
@@ -948,7 +956,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const exitedGracefully = await waitForProcessExit(gracefulTimeoutMs);
if (!exitedGracefully) {
try {
ptyProcess.kill("SIGKILL");
killPtyProcess("SIGKILL");
} catch {
// process may already be gone
}