diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts index aa8d9fe8d2..d2fda08fc0 100644 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -13,9 +13,92 @@ vi.mock("vscode", () => ({ }, })) +// Mock execa to test stdin behavior +const mockExeca = vi.fn() +const mockStdin = { + write: vi.fn((data, encoding, callback) => { + // Simulate successful write + if (callback) callback(null) + }), + end: vi.fn(), +} + +// Mock process that simulates successful execution +const createMockProcess = () => { + let resolveProcess: (value: { exitCode: number }) => void + const processPromise = new Promise<{ exitCode: number }>((resolve) => { + resolveProcess = resolve + }) + + const mockProcess = { + stdin: mockStdin, + stdout: { + on: vi.fn(), + }, + stderr: { + on: vi.fn((event, callback) => { + // Don't emit any stderr data in tests + }), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + // Simulate successful process completion after a short delay + setTimeout(() => { + callback(0) + resolveProcess({ exitCode: 0 }) + }, 10) + } + if (event === "error") { + // Don't emit any errors in tests + } + }), + killed: false, + kill: vi.fn(), + then: processPromise.then.bind(processPromise), + catch: processPromise.catch.bind(processPromise), + finally: processPromise.finally.bind(processPromise), + } + return mockProcess +} + +vi.mock("execa", () => ({ + execa: mockExeca, +})) + +// Mock readline with proper interface simulation +let mockReadlineInterface: any = null + +vi.mock("readline", () => ({ + default: { + createInterface: vi.fn(() => { + mockReadlineInterface = { + async *[Symbol.asyncIterator]() { + // Simulate Claude CLI JSON output + yield '{"type":"text","text":"Hello"}' + yield '{"type":"text","text":" world"}' + // Simulate end of stream - must return to terminate the iterator + return + }, + close: vi.fn(), + } + return mockReadlineInterface + }), + }, +})) + describe("runClaudeCode", () => { beforeEach(() => { vi.clearAllMocks() + mockExeca.mockReturnValue(createMockProcess()) + // Mock setImmediate to run synchronously in tests + vi.spyOn(global, "setImmediate").mockImplementation((callback: any) => { + callback() + return {} as any + }) + }) + + afterEach(() => { + vi.restoreAllMocks() }) test("should export runClaudeCode function", async () => { @@ -34,4 +117,174 @@ describe("runClaudeCode", () => { expect(Symbol.asyncIterator in result).toBe(true) expect(typeof result[Symbol.asyncIterator]).toBe("function") }) + + test("should use stdin instead of command line arguments for messages", async () => { + const { runClaudeCode } = await import("../run") + const messages = [{ role: "user" as const, content: "Hello world!" }] + const options = { + systemPrompt: "You are a helpful assistant", + messages, + } + + const generator = runClaudeCode(options) + + // Consume the generator to completion + const results = [] + for await (const chunk of generator) { + results.push(chunk) + } + + // Verify execa was called with correct arguments (no JSON.stringify(messages) in args) + expect(mockExeca).toHaveBeenCalledWith( + "claude", + expect.arrayContaining([ + "-p", + "--system-prompt", + "You are a helpful assistant", + "--verbose", + "--output-format", + "stream-json", + "--disallowedTools", + expect.any(String), + "--max-turns", + "1", + ]), + expect.objectContaining({ + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ) + + // Verify the arguments do NOT contain the stringified messages + const [, args] = mockExeca.mock.calls[0] + expect(args).not.toContain(JSON.stringify(messages)) + + // Verify messages were written to stdin with callback + expect(mockStdin.write).toHaveBeenCalledWith(JSON.stringify(messages), "utf8", expect.any(Function)) + expect(mockStdin.end).toHaveBeenCalled() + + // Verify we got the expected mock output + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ type: "text", text: "Hello" }) + expect(results[1]).toEqual({ type: "text", text: " world" }) + }) + + test("should include model parameter when provided", async () => { + const { runClaudeCode } = await import("../run") + const options = { + systemPrompt: "You are a helpful assistant", + messages: [{ role: "user" as const, content: "Hello" }], + modelId: "claude-3-5-sonnet-20241022", + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + const [, args] = mockExeca.mock.calls[0] + expect(args).toContain("--model") + expect(args).toContain("claude-3-5-sonnet-20241022") + }) + + test("should use custom claude path when provided", async () => { + const { runClaudeCode } = await import("../run") + const options = { + systemPrompt: "You are a helpful assistant", + messages: [{ role: "user" as const, content: "Hello" }], + path: "/custom/path/to/claude", + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + const [claudePath] = mockExeca.mock.calls[0] + expect(claudePath).toBe("/custom/path/to/claude") + }) + + test("should handle stdin write errors gracefully", async () => { + const { runClaudeCode } = await import("../run") + + // Create a mock process with stdin that fails + const mockProcessWithError = createMockProcess() + mockProcessWithError.stdin.write = vi.fn((data, encoding, callback) => { + // Simulate write error + if (callback) callback(new Error("EPIPE: broken pipe")) + }) + + // Mock console.error to verify error logging + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + mockExeca.mockReturnValueOnce(mockProcessWithError) + + const options = { + systemPrompt: "You are a helpful assistant", + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Try to consume the generator + try { + await generator.next() + } catch (error) { + // Expected to fail + } + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith("Error writing to Claude Code stdin:", expect.any(Error)) + + // Verify process was killed + expect(mockProcessWithError.kill).toHaveBeenCalled() + + // Clean up + consoleErrorSpy.mockRestore() + await generator.return(undefined) + }) + + test("should handle stdin access errors gracefully", async () => { + const { runClaudeCode } = await import("../run") + + // Create a mock process without stdin + const mockProcessWithoutStdin = createMockProcess() + mockProcessWithoutStdin.stdin = null as any + + // Mock console.error to verify error logging + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + mockExeca.mockReturnValueOnce(mockProcessWithoutStdin) + + const options = { + systemPrompt: "You are a helpful assistant", + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Try to consume the generator + try { + await generator.next() + } catch (error) { + // Expected to fail + } + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith("Error accessing Claude Code stdin:", expect.any(Error)) + + // Verify process was killed + expect(mockProcessWithoutStdin.kill).toHaveBeenCalled() + + // Clean up + consoleErrorSpy.mockRestore() + await generator.return(undefined) + }) }) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 84f1fe0902..8e37d0f303 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -112,7 +112,6 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions const args = [ "-p", - JSON.stringify(messages), "--system-prompt", systemPrompt, "--verbose", @@ -129,8 +128,8 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions args.push("--model", modelId) } - return execa(claudePath, args, { - stdin: "ignore", + const child = execa(claudePath, args, { + stdin: "pipe", stdout: "pipe", stderr: "pipe", env: { @@ -142,6 +141,30 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions maxBuffer: 1024 * 1024 * 1000, timeout: CLAUDE_CODE_TIMEOUT, }) + + // Write messages to stdin after process is spawned + // This avoids the E2BIG error on Linux when passing large messages as command line arguments + // Linux has a per-argument limit of ~128KiB for execve() system calls + const messagesJson = JSON.stringify(messages) + + // Use setImmediate to ensure the process has been spawned before writing to stdin + // This prevents potential race conditions where stdin might not be ready + setImmediate(() => { + try { + child.stdin.write(messagesJson, "utf8", (error) => { + if (error) { + console.error("Error writing to Claude Code stdin:", error) + child.kill() + } + }) + child.stdin.end() + } catch (error) { + console.error("Error accessing Claude Code stdin:", error) + child.kill() + } + }) + + return child } function parseChunk(data: string, processState: ProcessState) {