Centralize Claude SDK launch path

This commit is contained in:
Mohamed Boudra
2026-05-08 18:47:56 +07:00
parent e490bf1dab
commit 5c90449707
34 changed files with 864 additions and 851 deletions
@@ -13,15 +13,13 @@ import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js"; import { 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,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", () => {
@@ -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" }),
}); });
@@ -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([
{ {
@@ -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,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);
});
@@ -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",
@@ -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) => {
@@ -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",
@@ -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([
{ {
@@ -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",
@@ -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[];
@@ -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(
@@ -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,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,
),
});
}
@@ -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")) &&
@@ -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 {
@@ -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 {
@@ -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";
@@ -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";
+11 -3
View File
@@ -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
} }