mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: resolve E2BIG error by passing large prompts via stdin to Claude CLI (#5186)
* fix: resolve E2BIG error by passing large prompts via stdin to Claude CLI - Pass messages via stdin instead of command line arguments to avoid Linux argument length limits - Add --input-format text flag to claude CLI command - Update execa configuration to use stdin pipe - Fix corresponding unit tests with proper async iterator mocking - Resolves spawn E2BIG errors when using very large conversation histories * fix: address race condition and improve error handling - Use setImmediate to ensure process is spawned before writing to stdin - Add proper error handling for stdin write operations - Add tests for error scenarios - Update existing tests to handle async behavior properly * fix: remove --input-format text flag to prevent CLI parsing errors The --input-format text flag was causing the Claude CLI to misinterpret the JSON content passed via stdin, leading to errors like 'unknown option -------' when the system prompt contained dashes. Removing this flag allows the CLI to properly handle the JSON input via stdin. --------- Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
719d661478
commit
de99e348df
2 changed files with 279 additions and 3 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue