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 { AgentStorage } from "./agent-storage.js";
|
||||||
import { createAgentMcpServer } from "./mcp-server.js";
|
import { createAgentMcpServer } from "./mcp-server.js";
|
||||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||||
|
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||||
|
|
||||||
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY;
|
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 {
|
interface AgentMcpServerHandle {
|
||||||
url: string;
|
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 manager: AgentManager;
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
let agentMcpServer: AgentMcpServerHandle;
|
let agentMcpServer: AgentMcpServerHandle;
|
||||||
|
let canRunCodex = false;
|
||||||
|
let canRunClaude = false;
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
canRunCodex = !process.env.CI && hasOpenAICredentials;
|
||||||
|
canRunClaude = await isProviderAvailable("claude");
|
||||||
|
if (!canRunCodex && !canRunClaude) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
agentMcpServer = await startAgentMcpServer(logger);
|
agentMcpServer = await startAgentMcpServer(logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,72 +179,70 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
|||||||
await shutdownProviders(logger);
|
await shutdownProviders(logger);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
test.runIf(hasOpenAICredentials)(
|
test("returns schema-valid JSON from a real Codex agent", async (context) => {
|
||||||
"returns schema-valid JSON from a real Codex agent",
|
if (!canRunCodex) {
|
||||||
async () => {
|
context.skip();
|
||||||
const schema = z.object({
|
}
|
||||||
title: z.string(),
|
const schema = z.object({
|
||||||
count: z.number(),
|
title: z.string(),
|
||||||
});
|
count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await generateStructuredAgentResponse({
|
const result = await generateStructuredAgentResponse({
|
||||||
manager,
|
manager,
|
||||||
agentConfig: {
|
agentConfig: {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Structured Response Test",
|
title: "Structured Response Test",
|
||||||
},
|
},
|
||||||
prompt: "Return JSON with a short title and count 2.",
|
prompt: "Return JSON with a short title and count 2.",
|
||||||
schema,
|
schema,
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.title.length).toBeGreaterThan(0);
|
expect(result.title.length).toBeGreaterThan(0);
|
||||||
expect(typeof result.count).toBe("number");
|
expect(typeof result.count).toBe("number");
|
||||||
},
|
}, 180000);
|
||||||
180000,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.runIf(hasClaudeCredentials)(
|
test("returns schema-valid JSON from Claude Haiku", async (context) => {
|
||||||
"returns schema-valid JSON from Claude Haiku",
|
if (!canRunClaude) {
|
||||||
async () => {
|
context.skip();
|
||||||
const schema = z.object({
|
}
|
||||||
message: z.string(),
|
const schema = z.object({
|
||||||
});
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
let result: { message: string } | null = null;
|
let result: { message: string } | null = null;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
result = await generateStructuredAgentResponse({
|
result = await generateStructuredAgentResponse({
|
||||||
manager,
|
manager,
|
||||||
agentConfig: {
|
agentConfig: {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
model: "haiku",
|
model: "haiku",
|
||||||
thinkingOptionId: "on",
|
thinkingOptionId: "on",
|
||||||
cwd,
|
cwd,
|
||||||
title: "Claude Haiku Structured Test",
|
title: "Claude Haiku Structured Test",
|
||||||
internal: true,
|
internal: true,
|
||||||
},
|
},
|
||||||
prompt:
|
prompt:
|
||||||
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
|
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
|
||||||
schema,
|
schema,
|
||||||
maxRetries: 6,
|
maxRetries: 6,
|
||||||
});
|
});
|
||||||
lastError = null;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!result) {
|
|
||||||
throw lastError;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
expect(result.message.trim().toLowerCase()).toBe("hello");
|
expect(result.message.trim().toLowerCase()).toBe("hello");
|
||||||
},
|
}, 180000);
|
||||||
180000,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ vi.mock("../../utils/executable.js", () => ({
|
|||||||
isCommandAvailable: mockState.isCommandAvailable,
|
isCommandAvailable: mockState.isCommandAvailable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./providers/claude-agent.js", () => ({
|
vi.mock("./providers/claude/agent.js", () => ({
|
||||||
ClaudeAgentClient: class ClaudeAgentClient {
|
ClaudeAgentClient: class ClaudeAgentClient {
|
||||||
readonly capabilities = {
|
readonly capabilities = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type {
|
|||||||
ProviderProfileModel,
|
ProviderProfileModel,
|
||||||
ProviderRuntimeSettings,
|
ProviderRuntimeSettings,
|
||||||
} from "./provider-launch-config.js";
|
} 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 { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||||
import { GenericACPAgentClient } from "./providers/generic-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 { 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.
|
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
|
||||||
describe("claude agent commands E2E", () => {
|
describe("claude agent commands E2E", () => {
|
||||||
+3
-5
@@ -1,15 +1,15 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
|
||||||
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
|
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
|
||||||
describe("claude agent commands contract (real)", () => {
|
describe("claude agent commands contract (real)", () => {
|
||||||
let canRun = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = await isCommandAvailable("claude");
|
canRun = await isProviderAvailable("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
@@ -19,8 +19,6 @@ describe("claude agent commands contract (real)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("lists slash commands with the expected contract", async () => {
|
test("lists slash commands with the expected contract", async () => {
|
||||||
expect(await isCommandAvailable("claude")).toBe(true);
|
|
||||||
|
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: pino({ level: "silent" }),
|
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 { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
import type { ClaudeQueryInput } from "./query.js";
|
||||||
|
|
||||||
function createQueryMock(events: unknown[]): Query {
|
function createQueryMock(events: unknown[]): Query {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -36,7 +37,7 @@ describe("Claude SDK env", () => {
|
|||||||
PASEO_TEST_FLAG: "launch-value",
|
PASEO_TEST_FLAG: "launch-value",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||||
capturedEnv = options.env;
|
capturedEnv = options.env;
|
||||||
return createQueryMock([
|
return createQueryMock([
|
||||||
{
|
{
|
||||||
@@ -94,7 +95,7 @@ describe("Claude SDK env", () => {
|
|||||||
PASEO_TEST_FLAG: "resume-launch-value",
|
PASEO_TEST_FLAG: "resume-launch-value",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||||
capturedEnv = options.env;
|
capturedEnv = options.env;
|
||||||
return createQueryMock([
|
return createQueryMock([
|
||||||
{
|
{
|
||||||
+4
-8
@@ -6,9 +6,7 @@
|
|||||||
*
|
*
|
||||||
* All tests use REAL Claude SDK sessions — no mocks.
|
* All tests use REAL Claude SDK sessions — no mocks.
|
||||||
*
|
*
|
||||||
* CREDENTIALS: These tests require a running `claude` CLI and either
|
* These tests run when the shared Claude provider availability gate passes.
|
||||||
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
|
|
||||||
* They are skipped automatically when credentials are unavailable.
|
|
||||||
*/
|
*/
|
||||||
import { beforeAll, beforeEach, expect, test } from "vitest";
|
import { beforeAll, beforeEach, expect, test } from "vitest";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
@@ -17,8 +15,8 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
import { isCommandAvailable } from "../../../../utils/executable.js";
|
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||||
import { ClaudeAgentClient } from "../claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -26,8 +24,6 @@ import { ClaudeAgentClient } from "../claude-agent.js";
|
|||||||
|
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
const hasClaudeCredentials =
|
|
||||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
|
||||||
|
|
||||||
function tmpCwd(prefix: string): string {
|
function tmpCwd(prefix: string): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
@@ -203,7 +199,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
|
|||||||
let canRun = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
canRun = await isProviderAvailable("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { extractUserMessageText } from "./claude-agent.js";
|
import { extractUserMessageText } from "./agent.js";
|
||||||
|
|
||||||
describe("extractUserMessageText", () => {
|
describe("extractUserMessageText", () => {
|
||||||
test("returns trimmed string content", () => {
|
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 { afterEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
interface QueryMock {
|
interface QueryMock {
|
||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
@@ -41,13 +41,7 @@ type PromptHandler = (input: {
|
|||||||
query: ScriptedQuery;
|
query: ScriptedQuery;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
|
||||||
const sdkMocks = vi.hoisted(() => ({
|
const queryFactory = vi.fn();
|
||||||
query: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
||||||
query: sdkMocks.query,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createAsyncQueue<T>(): AsyncQueue<T> {
|
function createAsyncQueue<T>(): AsyncQueue<T> {
|
||||||
const items: T[] = [];
|
const items: T[] = [];
|
||||||
@@ -245,14 +239,14 @@ async function waitFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sdkMocks.query.mockReset();
|
queryFactory.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const queries: ScriptedQuery[] = [];
|
const queries: ScriptedQuery[] = [];
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const scriptedQuery = createScriptedQuery({
|
const scriptedQuery = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "interrupt-keep-query-session",
|
sessionId: "interrupt-keep-query-session",
|
||||||
@@ -261,7 +255,11 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
|||||||
return scriptedQuery;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -274,7 +272,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
|||||||
await session.interrupt();
|
await session.interrupt();
|
||||||
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
|
||||||
|
|
||||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||||
expect(queries[0]?.return).not.toHaveBeenCalled();
|
expect(queries[0]?.return).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const firstTurnEvents = await collectUntilTerminal(firstTurn);
|
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 logger = createTestLogger();
|
||||||
const queries: ScriptedQuery[] = [];
|
const queries: ScriptedQuery[] = [];
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const scriptedQuery = createScriptedQuery({
|
const scriptedQuery = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "interrupt-reuse-query-session",
|
sessionId: "interrupt-reuse-query-session",
|
||||||
@@ -311,7 +309,11 @@ test("reuses the existing query after interrupt before starting the next prompt"
|
|||||||
return scriptedQuery;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
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"));
|
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([
|
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||||
"first prompt",
|
"first prompt",
|
||||||
"second prompt",
|
"second prompt",
|
||||||
@@ -342,7 +344,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "claude-original-session",
|
sessionId: "claude-original-session",
|
||||||
@@ -361,7 +363,11 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
|
|||||||
return queryRef;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -390,7 +396,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
|||||||
const prompts: PromptRecord[] = [];
|
const prompts: PromptRecord[] = [];
|
||||||
let throwAbortOnNext = false;
|
let throwAbortOnNext = false;
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
const scriptedQuery = {
|
const scriptedQuery = {
|
||||||
next: vi.fn(async () => {
|
next: vi.fn(async () => {
|
||||||
if (throwAbortOnNext) {
|
if (throwAbortOnNext) {
|
||||||
@@ -455,7 +461,11 @@ test("recovers when the query pump sees a single interrupt abort before the next
|
|||||||
return scriptedQuery;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
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"));
|
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(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
|
||||||
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
|
||||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
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();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "interrupt-stale-result-session",
|
sessionId: "interrupt-stale-result-session",
|
||||||
@@ -488,7 +498,11 @@ test("stale abort result after replacement start does not poison the new foregro
|
|||||||
return queryRef;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -545,7 +559,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "autonomous-live-session",
|
sessionId: "autonomous-live-session",
|
||||||
@@ -564,7 +578,11 @@ test("creates an autonomous live turn when assistant output arrives without a fo
|
|||||||
return queryRef;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -606,7 +624,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
|||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryRef: ScriptedQuery | null = null;
|
let queryRef: ScriptedQuery | null = null;
|
||||||
|
|
||||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
queryRef = createScriptedQuery({
|
queryRef = createScriptedQuery({
|
||||||
prompt,
|
prompt,
|
||||||
sessionId: "autonomous-handoff-session",
|
sessionId: "autonomous-handoff-session",
|
||||||
@@ -634,7 +652,11 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
|
|||||||
return queryRef;
|
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({
|
const session = await client.createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
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",
|
(event) => event?.type === "turn_canceled",
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
|
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||||
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
|
||||||
"seed prompt",
|
"seed prompt",
|
||||||
"foreground prompt",
|
"foreground prompt",
|
||||||
+5
-8
@@ -1,13 +1,10 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
|
||||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
|
|
||||||
const hasClaudeCredentials =
|
|
||||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
|
||||||
|
|
||||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -32,7 +29,7 @@ describe("Claude max effort availability (real)", () => {
|
|||||||
let canRun = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
canRun = await isProviderAvailable("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
+6
-6
@@ -1,11 +1,11 @@
|
|||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
import { asInternals } from "../../../test-utils/class-mocks.js";
|
||||||
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
|
import { ClaudeAgentClient, readEventIdentifiers } from "./agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
interface QueryMock {
|
interface QueryMock {
|
||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
@@ -660,7 +660,7 @@ test("Grep tool_result string content flows to a search detail with content", as
|
|||||||
grepEntry,
|
grepEntry,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mapClaudeCompletedToolCall } = await import("./claude/tool-call-mapper.js");
|
const { mapClaudeCompletedToolCall } = await import("./tool-call-mapper.js");
|
||||||
const item = mapClaudeCompletedToolCall({
|
const item = mapClaudeCompletedToolCall({
|
||||||
callId: "tool-grep-1",
|
callId: "tool-grep-1",
|
||||||
name: "Grep",
|
name: "Grep",
|
||||||
+9
-9
@@ -1,16 +1,16 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import {
|
import type {
|
||||||
query,
|
Options,
|
||||||
type Options,
|
Query,
|
||||||
type Query,
|
SpawnOptions as ClaudeSpawnOptions,
|
||||||
type SpawnOptions as ClaudeSpawnOptions,
|
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import * as spawnUtils from "../../../utils/spawn.js";
|
import * as spawnUtils from "../../../../utils/spawn.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
import type { ClaudeQueryInput } from "./query.js";
|
||||||
|
|
||||||
function createQueryMock(events: unknown[]): Query {
|
function createQueryMock(events: unknown[]): Query {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -47,7 +47,7 @@ describe("Claude spawn override", () => {
|
|||||||
|
|
||||||
test("bypasses the shell when spawning Claude Code", async () => {
|
test("bypasses the shell when spawning Claude Code", async () => {
|
||||||
let capturedOptions: Options | undefined;
|
let capturedOptions: Options | undefined;
|
||||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
|
||||||
capturedOptions = options;
|
capturedOptions = options;
|
||||||
return createQueryMock([
|
return createQueryMock([
|
||||||
{
|
{
|
||||||
+12
-16
@@ -1,19 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
import type { AgentTimelineRow } from "../agent-manager.js";
|
import type { AgentTimelineRow } from "../../agent-manager.js";
|
||||||
import { projectTimelineRows } from "../timeline-projection.js";
|
import { projectTimelineRows } from "../../timeline-projection.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
|
|
||||||
const sdkMocks = vi.hoisted(() => ({
|
const queryFactory = vi.fn();
|
||||||
query: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
||||||
query: sdkMocks.query,
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface QueryMock {
|
interface QueryMock {
|
||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
@@ -150,7 +144,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
|
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
|
||||||
sdkMocks.query.mockImplementation(() =>
|
queryFactory.mockImplementation(() =>
|
||||||
buildQueryMock([
|
buildQueryMock([
|
||||||
{
|
{
|
||||||
type: "system",
|
type: "system",
|
||||||
@@ -247,12 +241,13 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sdkMocks.query.mockReset();
|
queryFactory.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||||
const session = await new ClaudeAgentClient({
|
const session = await new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
|
queryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
}).createSession({
|
}).createSession({
|
||||||
provider: "claude",
|
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 () => {
|
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({
|
const session = await new ClaudeAgentClient({
|
||||||
logger,
|
logger,
|
||||||
|
queryFactory,
|
||||||
resolveBinary: async () => "/test/claude/bin",
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
}).createSession({
|
}).createSession({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
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 {
|
import {
|
||||||
ClaudeAgentClient,
|
ClaudeAgentClient,
|
||||||
convertClaudeHistoryEntry,
|
convertClaudeHistoryEntry,
|
||||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||||
} from "./claude-agent.js";
|
} from "./agent.js";
|
||||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
interface TestClaudeSession {
|
interface TestClaudeSession {
|
||||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
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 { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { promises } from "node:fs";
|
import { promises } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
query,
|
|
||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
type CanUseTool,
|
type CanUseTool,
|
||||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||||
type Options,
|
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
type PermissionResult,
|
type PermissionResult,
|
||||||
type PermissionUpdate,
|
type PermissionUpdate,
|
||||||
type Query,
|
type Query,
|
||||||
type SpawnOptions,
|
|
||||||
type SDKMessage,
|
type SDKMessage,
|
||||||
type SDKPartialAssistantMessage,
|
type SDKPartialAssistantMessage,
|
||||||
type SDKTaskProgressMessage,
|
type SDKTaskProgressMessage,
|
||||||
@@ -28,22 +24,23 @@ import {
|
|||||||
mapClaudeCompletedToolCall,
|
mapClaudeCompletedToolCall,
|
||||||
mapClaudeFailedToolCall,
|
mapClaudeFailedToolCall,
|
||||||
mapClaudeRunningToolCall,
|
mapClaudeRunningToolCall,
|
||||||
} from "./claude/tool-call-mapper.js";
|
} from "./tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
mapTaskNotificationSystemRecordToToolCall,
|
mapTaskNotificationSystemRecordToToolCall,
|
||||||
mapTaskNotificationUserContentToToolCall,
|
mapTaskNotificationUserContentToToolCall,
|
||||||
} from "./claude/task-notification-tool-call.js";
|
} from "./task-notification-tool-call.js";
|
||||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
|
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
import { parsePartialJsonObject } from "./partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||||
import {
|
import {
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "./diagnostic-utils.js";
|
} from "../diagnostic-utils.js";
|
||||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||||
|
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionAction,
|
AgentPermissionAction,
|
||||||
@@ -73,20 +70,19 @@ import type {
|
|||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
createProviderEnv,
|
createProviderEnv,
|
||||||
createProviderEnvSpec,
|
createProviderEnvSpec,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../../provider-launch-config.js";
|
||||||
import { buildSelfNodeCommand } from "../../paseo-env.js";
|
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { execCommand } from "../../../../utils/spawn.js";
|
||||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
|
||||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
|
||||||
|
|
||||||
const fsPromises = promises;
|
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 {
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : 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);
|
return Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
|
|
||||||
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageMimeType(
|
function isImageMimeType(
|
||||||
value: string,
|
value: string,
|
||||||
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
|
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
|
||||||
@@ -239,7 +231,6 @@ interface SlashCommandInvocation {
|
|||||||
rawInput: string;
|
rawInput: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orchestrator instructions moved to shared module.
|
|
||||||
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
|
||||||
|
|
||||||
export interface ClaudeContentChunk {
|
export interface ClaudeContentChunk {
|
||||||
@@ -247,13 +238,11 @@ export interface ClaudeContentChunk {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClaudeOptions = Options;
|
|
||||||
|
|
||||||
interface ClaudeAgentClientOptions {
|
interface ClaudeAgentClientOptions {
|
||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
queryFactory?: typeof query;
|
queryFactory?: ClaudeQueryFactory;
|
||||||
resolveBinary?: () => Promise<string>;
|
resolveBinary?: () => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +253,7 @@ interface ClaudeAgentSessionOptions {
|
|||||||
launchEnv?: Record<string, string>;
|
launchEnv?: Record<string, string>;
|
||||||
persistSession?: boolean;
|
persistSession?: boolean;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
queryFactory?: typeof query;
|
queryFactory?: ClaudeQueryFactory;
|
||||||
resolveBinary: () => Promise<string>;
|
resolveBinary: () => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,86 +294,6 @@ function extractSessionIdRaw(msg: {
|
|||||||
return "";
|
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 {
|
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||||
return (
|
return (
|
||||||
value === "low" ||
|
value === "low" ||
|
||||||
@@ -1252,14 +1161,14 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly queryFactory: typeof query;
|
private readonly queryFactory?: ClaudeQueryFactory;
|
||||||
private readonly resolveBinary: () => Promise<string>;
|
private readonly resolveBinary: () => Promise<string>;
|
||||||
|
|
||||||
constructor(options: ClaudeAgentClientOptions) {
|
constructor(options: ClaudeAgentClientOptions) {
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.queryFactory = options.queryFactory ?? query;
|
this.queryFactory = options.queryFactory;
|
||||||
this.resolveBinary = options.resolveBinary ?? resolveClaudeBinary;
|
this.resolveBinary = options.resolveBinary ?? resolveClaudeBinary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,7 +1473,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly persistSession?: boolean;
|
private readonly persistSession?: boolean;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly queryFactory: typeof query;
|
private readonly queryFactory?: ClaudeQueryFactory;
|
||||||
private readonly resolveBinary: () => Promise<string>;
|
private readonly resolveBinary: () => Promise<string>;
|
||||||
private query: Query | null = null;
|
private query: Query | null = null;
|
||||||
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||||
@@ -1613,7 +1522,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.persistSession = options.persistSession;
|
this.persistSession = options.persistSession;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.queryFactory = options.queryFactory ?? query;
|
this.queryFactory = options.queryFactory;
|
||||||
this.resolveBinary = options.resolveBinary;
|
this.resolveBinary = options.resolveBinary;
|
||||||
const handle = options.handle;
|
const handle = options.handle;
|
||||||
|
|
||||||
@@ -2234,7 +2143,14 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const options = await this.buildOptions();
|
const options = await this.buildOptions();
|
||||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||||
this.input = input;
|
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
|
// Do not kick off background control-plane queries here. Methods like
|
||||||
// supportedCommands()/setPermissionMode() may execute immediately after
|
// supportedCommands()/setPermissionMode() may execute immediately after
|
||||||
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
||||||
@@ -2364,11 +2280,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
...this.runtimeSettings.disallowedTools,
|
...this.runtimeSettings.disallowedTools,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return this.applyRuntimeSettings(base);
|
return base;
|
||||||
}
|
|
||||||
|
|
||||||
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
|
|
||||||
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMcpServers(
|
private normalizeMcpServers(
|
||||||
+33
-23
@@ -3,19 +3,13 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
const sdkMocks = vi.hoisted(() => ({
|
const queryFactory = vi.fn();
|
||||||
query: vi.fn(),
|
let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
|
||||||
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
||||||
query: sdkMocks.query,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||||
@@ -99,9 +93,9 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
let previousClaudeConfigDir: string | undefined;
|
let previousClaudeConfigDir: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sdkMocks.query.mockImplementation(() => {
|
queryFactory.mockImplementation(() => {
|
||||||
const mock = buildSdkQueryMock();
|
const mock = buildSdkQueryMock();
|
||||||
sdkMocks.lastQuery = mock;
|
lastQuery = mock;
|
||||||
return mock;
|
return mock;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,8 +139,8 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sdkMocks.query.mockReset();
|
queryFactory.mockReset();
|
||||||
sdkMocks.lastQuery = null;
|
lastQuery = null;
|
||||||
if (previousClaudeConfigDir === undefined) {
|
if (previousClaudeConfigDir === undefined) {
|
||||||
delete process.env.CLAUDE_CONFIG_DIR;
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
} else {
|
} else {
|
||||||
@@ -157,7 +151,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("does not replay persisted history during the first live stream turn", async () => {
|
test("does not replay persisted history during the first live stream turn", async () => {
|
||||||
const logger = createTestLogger();
|
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 = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -194,7 +192,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("still exposes persisted history through streamHistory", async () => {
|
test("still exposes persisted history through streamHistory", async () => {
|
||||||
const logger = createTestLogger();
|
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 = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -223,7 +225,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("listCommands includes rewind command", async () => {
|
test("listCommands includes rewind command", async () => {
|
||||||
const logger = createTestLogger();
|
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 = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
sessionId: "history-session",
|
||||||
@@ -245,7 +251,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
|
|
||||||
test("slash /rewind uses latest user message id from persisted history", async () => {
|
test("slash /rewind uses latest user message id from persisted history", async () => {
|
||||||
const logger = createTestLogger();
|
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 = {
|
const handle: AgentPersistenceHandle = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: "history-session",
|
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_started")).toBe(true);
|
||||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||||
expect(sdkMocks.lastQuery).toBeTruthy();
|
expect(lastQuery).toBeTruthy();
|
||||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
expect(lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
|
||||||
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
expect(lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
|
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||||
|
|
||||||
describe("getClaudeModels", () => {
|
describe("getClaudeModels", () => {
|
||||||
it("returns all claude models", () => {
|
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 { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
|
||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { findExecutable } from "../../../../utils/executable.js";
|
||||||
|
import { claudeQuery } from "./query.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,
|
|
||||||
});
|
|
||||||
|
|
||||||
class Pushable<T> implements AsyncIterable<T> {
|
class Pushable<T> implements AsyncIterable<T> {
|
||||||
private queue: T[] = [];
|
private queue: T[] = [];
|
||||||
@@ -97,21 +85,15 @@ function extractTextFromEvents(events: SDKMessage[]): string {
|
|||||||
return responseText;
|
return responseText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasClaudeCredentials =
|
|
||||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
|
||||||
|
|
||||||
describe("Claude SDK direct behavior", () => {
|
describe("Claude SDK direct behavior", () => {
|
||||||
let canRunClaudeIntegration = false;
|
let canRun = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
|
canRun = await isProviderAvailable("claude");
|
||||||
if (canRunClaudeIntegration) {
|
|
||||||
expect(await isCommandAvailable("claude")).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach((context) => {
|
beforeEach((context) => {
|
||||||
if (!canRunClaudeIntegration) {
|
if (!canRun) {
|
||||||
context.skip();
|
context.skip();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -121,15 +103,14 @@ describe("Claude SDK direct behavior", () => {
|
|||||||
const input = new Pushable<SDKUserMessage>();
|
const input = new Pushable<SDKUserMessage>();
|
||||||
const claudeBinary = await findExecutable("claude");
|
const claudeBinary = await findExecutable("claude");
|
||||||
|
|
||||||
// Use same options as claude-agent.ts
|
// Use same options as the Claude provider
|
||||||
const q = query({
|
const q = claudeQuery({
|
||||||
prompt: input,
|
prompt: input,
|
||||||
options: {
|
options: {
|
||||||
cwd,
|
cwd,
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
permissionMode: "bypassPermissions",
|
permissionMode: "bypassPermissions",
|
||||||
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
|
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
|
||||||
spawnClaudeCodeProcess: spawnClaudeForTest,
|
|
||||||
systemPrompt: {
|
systemPrompt: {
|
||||||
type: "preset",
|
type: "preset",
|
||||||
preset: "claude_code",
|
preset: "claude_code",
|
||||||
@@ -8,7 +8,7 @@ import type { AgentProvider } from "../agent-sdk-types.js";
|
|||||||
import { AgentManager } from "../agent-manager.js";
|
import { AgentManager } from "../agent-manager.js";
|
||||||
import { AgentStorage } from "../agent-storage.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 { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,9 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
|
|||||||
const availability = (async (): Promise<boolean> => {
|
const availability = (async (): Promise<boolean> => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "claude":
|
case "claude":
|
||||||
return (
|
const hasClaudeEnvCredentials =
|
||||||
(await isCommandAvailable("claude")) &&
|
Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
|
||||||
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
|
return (await isCommandAvailable("claude")) && (!process.env.CI || hasClaudeEnvCredentials);
|
||||||
);
|
|
||||||
case "codex":
|
case "codex":
|
||||||
return (
|
return (
|
||||||
(await isCommandAvailable("codex")) &&
|
(await isCommandAvailable("codex")) &&
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
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 { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
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 { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
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 { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.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 { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.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";
|
import { isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.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";
|
import { isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.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 { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.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 { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.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 { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.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 { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||||
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.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 pino from "pino";
|
||||||
|
|
||||||
import type { AgentClient } from "../agent/agent-sdk-types.js";
|
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 { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|||||||
@@ -897,12 +897,20 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
function kill(): void {
|
function kill(): void {
|
||||||
if (!killed) {
|
if (!killed) {
|
||||||
killed = true;
|
killed = true;
|
||||||
ptyProcess.kill();
|
killPtyProcess();
|
||||||
emitExit(buildExitInfo());
|
emitExit(buildExitInfo());
|
||||||
}
|
}
|
||||||
disposeResources();
|
disposeResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function killPtyProcess(signal?: NodeJS.Signals): void {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
ptyProcess.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ptyProcess.kill(signal);
|
||||||
|
}
|
||||||
|
|
||||||
function waitForProcessExit(timeoutMs: number): Promise<boolean> {
|
function waitForProcessExit(timeoutMs: number): Promise<boolean> {
|
||||||
if (processExited) {
|
if (processExited) {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
@@ -940,7 +948,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ptyProcess.kill();
|
killPtyProcess();
|
||||||
} catch {
|
} catch {
|
||||||
// process may already be gone
|
// process may already be gone
|
||||||
}
|
}
|
||||||
@@ -948,7 +956,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
const exitedGracefully = await waitForProcessExit(gracefulTimeoutMs);
|
const exitedGracefully = await waitForProcessExit(gracefulTimeoutMs);
|
||||||
if (!exitedGracefully) {
|
if (!exitedGracefully) {
|
||||||
try {
|
try {
|
||||||
ptyProcess.kill("SIGKILL");
|
killPtyProcess("SIGKILL");
|
||||||
} catch {
|
} catch {
|
||||||
// process may already be gone
|
// process may already be gone
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user