Centralize Claude SDK launch path
This commit is contained in:
@@ -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
-1
@@ -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", () => {
|
||||
+3
-5
@@ -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" }),
|
||||
});
|
||||
+7
-6
@@ -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([
|
||||
{
|
||||
+4
-8
@@ -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
-1
@@ -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);
|
||||
});
|
||||
+52
-30
@@ -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",
|
||||
+5
-8
@@ -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) => {
|
||||
+6
-6
@@ -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",
|
||||
+9
-9
@@ -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([
|
||||
{
|
||||
+12
-16
@@ -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",
|
||||
+3
-3
@@ -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[];
|
||||
+31
-119
@@ -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(
|
||||
+33
-23
@@ -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
-1
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
+10
-29
@@ -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")) &&
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user