diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts index d2fda08fc0f..a538e452140 100644 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, vi, beforeEach } from "vitest" +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest" // Mock vscode workspace vi.mock("vscode", () => ({ @@ -10,6 +10,13 @@ vi.mock("vscode", () => ({ }, }, ], + fs: { + writeFile: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path })), }, })) @@ -86,6 +93,15 @@ vi.mock("readline", () => ({ }, })) +// Mock path and os modules +vi.mock("path", () => ({ + join: vi.fn((...args: string[]) => args.join("/")), +})) + +vi.mock("os", () => ({ + tmpdir: vi.fn(() => "/tmp"), +})) + describe("runClaudeCode", () => { beforeEach(() => { vi.clearAllMocks() @@ -287,4 +303,193 @@ describe("runClaudeCode", () => { consoleErrorSpy.mockRestore() await generator.return(undefined) }) + + test("should use command line argument for short system prompts", async () => { + const { runClaudeCode } = await import("../run") + const shortSystemPrompt = "You are a helpful assistant" + const options = { + systemPrompt: shortSystemPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with system prompt as command line argument + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + expect(args).toContain(shortSystemPrompt) + + // Verify no environment variable was set for short prompt + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + }) + + test("should use temporary file for long system prompts to avoid Windows ENAMETOOLONG error", async () => { + const { runClaudeCode } = await import("../run") + // Create a system prompt longer than MAX_COMMAND_LINE_LENGTH (7000 chars) + const longSystemPrompt = "You are a helpful assistant. " + "A".repeat(7000) + const options = { + systemPrompt: longSystemPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with --system-prompt @filepath pattern + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + + // Find the system prompt argument + const systemPromptIndex = args.indexOf("--system-prompt") + expect(systemPromptIndex).toBeGreaterThan(-1) + const systemPromptArg = args[systemPromptIndex + 1] + + // Verify it uses the @filepath pattern for temp files + expect(systemPromptArg).toMatch(/^@.*claude-system-prompt-.*\.txt$/) + + // Verify the long system prompt is not directly in the arguments + expect(args).not.toContain(longSystemPrompt) + + // Verify no environment variable was set for system prompt + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + }) + + test("should handle exactly MAX_COMMAND_LINE_LENGTH system prompt using command line", async () => { + const { runClaudeCode } = await import("../run") + // Create a system prompt exactly at the threshold (7000 chars) + const exactLengthPrompt = "A".repeat(7000) + const options = { + systemPrompt: exactLengthPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with system prompt as command line argument (at threshold) + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + expect(args).toContain(exactLengthPrompt) + + // Verify no temporary file was used (no @ prefix) + const systemPromptIndex = args.indexOf("--system-prompt") + const systemPromptArg = args[systemPromptIndex + 1] + expect(systemPromptArg).not.toMatch(/^@/) + }) + + test("should handle system prompt one character over threshold using temporary file", async () => { + const { runClaudeCode } = await import("../run") + // Create a system prompt one character over the threshold (7001 chars) + const overThresholdPrompt = "A".repeat(7001) + const options = { + systemPrompt: overThresholdPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with --system-prompt @filepath pattern + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + + // Find the system prompt argument + const systemPromptIndex = args.indexOf("--system-prompt") + const systemPromptArg = args[systemPromptIndex + 1] + + // Verify it uses the @filepath pattern for temp files + expect(systemPromptArg).toMatch(/^@.*claude-system-prompt-.*\.txt$/) + + // Verify the long system prompt is not directly in the arguments + expect(args).not.toContain(overThresholdPrompt) + + // Verify no environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + }) + + test("should preserve existing environment variables when using temporary files", async () => { + const { runClaudeCode } = await import("../run") + + // Mock process.env to have some existing variables + const originalEnv = process.env + process.env = { + ...originalEnv, + EXISTING_VAR: "existing_value", + PATH: "/usr/bin:/bin", + } + + const longSystemPrompt = "You are a helpful assistant. " + "A".repeat(7000) + const options = { + systemPrompt: longSystemPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify environment variables include existing ones but no CLAUDE_CODE_SYSTEM_PROMPT + const [, , execaOptions] = mockExeca.mock.calls[0] + expect(execaOptions.env).toEqual({ + ...process.env, + CLAUDE_CODE_MAX_OUTPUT_TOKENS: expect.any(String), // Always set by the implementation + }) + + // Verify no system prompt environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + + // Restore original environment + process.env = originalEnv + }) + + test("should work with empty system prompt", async () => { + const { runClaudeCode } = await import("../run") + const options = { + systemPrompt: "", + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with empty system prompt as command line argument + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + expect(args).toContain("") + + // Verify no temporary file was used (no @ prefix) + const systemPromptIndex = args.indexOf("--system-prompt") + const systemPromptArg = args[systemPromptIndex + 1] + expect(systemPromptArg).not.toMatch(/^@/) + }) }) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 59a5bf701a2..564e5a7e2d1 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -4,6 +4,8 @@ import { execa } from "execa" import { ClaudeCodeMessage } from "./types" import readline from "readline" import { CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS } from "@roo-code/types" +import * as path from "path" +import * as os from "os" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) @@ -21,13 +23,18 @@ type ProcessState = { exitCode: number | null } +type TempFileCleanup = { + filePath: string + cleanup: () => Promise +} + export async function* runClaudeCode( options: ClaudeCodeOptions & { maxOutputTokens?: number }, ): AsyncGenerator { - const process = runProcess(options) + const { process, tempFileCleanup } = await runProcess(options) const rl = readline.createInterface({ - input: process.stdout, + input: process.stdout!, }) try { @@ -38,7 +45,7 @@ export async function* runClaudeCode( partialData: null, } - process.stderr.on("data", (data) => { + process.stderr?.on("data", (data) => { processState.stderrLogs += data.toString() }) @@ -84,6 +91,10 @@ export async function* runClaudeCode( if (!process.killed) { process.kill() } + // Clean up temporary file if it was created + if (tempFileCleanup) { + await tempFileCleanup.cleanup() + } } } @@ -110,19 +121,60 @@ const claudeCodeTools = [ const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes -function runProcess({ +// Windows has a command line length limit of ~8191 characters +// If the system prompt is too long, we'll write it to a temporary file instead +const MAX_COMMAND_LINE_LENGTH = 7000 // Conservative limit to account for other arguments + +async function runProcess({ systemPrompt, messages, - path, + path: claudeCodePath, modelId, maxOutputTokens, -}: ClaudeCodeOptions & { maxOutputTokens?: number }) { - const claudePath = path || "claude" +}: ClaudeCodeOptions & { maxOutputTokens?: number }): Promise<{ + process: ReturnType + tempFileCleanup: TempFileCleanup | null +}> { + const claudePath = claudeCodePath || "claude" + + // Check if system prompt is too long for command line + const useTempFileForSystemPrompt = systemPrompt.length > MAX_COMMAND_LINE_LENGTH + + const args = ["-p"] + let tempFileCleanup: TempFileCleanup | null = null + + // Handle system prompt - use temp file for long prompts, command line for short ones + if (useTempFileForSystemPrompt) { + // Create temporary file for system prompt + const tempFilePath = path.join( + os.tmpdir(), + `claude-system-prompt-${Date.now()}-${Math.random().toString(36).substring(2)}.txt`, + ) + + try { + await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), Buffer.from(systemPrompt, "utf8")) + args.push("--system-prompt", `@${tempFilePath}`) + + tempFileCleanup = { + filePath: tempFilePath, + cleanup: async () => { + try { + await vscode.workspace.fs.delete(vscode.Uri.file(tempFilePath)) + } catch (error) { + // Ignore cleanup errors - temp files will be cleaned up by OS eventually + console.warn(`Failed to clean up temporary system prompt file ${tempFilePath}:`, error) + } + }, + } + } catch (error) { + throw new Error(`Failed to create temporary file for system prompt: ${error}`) + } + } else { + // Use command line argument for short system prompts + args.push("--system-prompt", systemPrompt) + } - const args = [ - "-p", - "--system-prompt", - systemPrompt, + args.push( "--verbose", "--output-format", "stream-json", @@ -131,32 +183,36 @@ function runProcess({ // Roo Code will handle recursive calls "--max-turns", "1", - ] + ) if (modelId) { args.push("--model", modelId) } + const env: Record = { + ...process.env, + // Use the configured value, or the environment variable, or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS + CLAUDE_CODE_MAX_OUTPUT_TOKENS: + maxOutputTokens?.toString() || + process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || + CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS.toString(), + } + const child = execa(claudePath, args, { stdin: "pipe", stdout: "pipe", stderr: "pipe", - env: { - ...process.env, - // Use the configured value, or the environment variable, or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS - CLAUDE_CODE_MAX_OUTPUT_TOKENS: - maxOutputTokens?.toString() || - process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || - CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS.toString(), - }, + env, cwd, 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 + // This avoids the E2BIG error on Linux and ENAMETOOLONG error on Windows when passing large data as command line arguments // Linux has a per-argument limit of ~128KiB for execve() system calls + // Windows has a total command line length limit of ~8191 characters + // For system prompts, we use temporary files when they exceed the safe limit const messagesJson = JSON.stringify(messages) // Use setImmediate to ensure the process has been spawned before writing to stdin @@ -176,7 +232,7 @@ function runProcess({ } }) - return child + return { process: child, tempFileCleanup } } function parseChunk(data: string, processState: ProcessState) {