Skip to content

Commit eee0c20

Browse files
committed
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
1 parent 9bf31d3 commit eee0c20

File tree

2 files changed

+177
-3
lines changed

2 files changed

+177
-3
lines changed

src/integrations/claude-code/__tests__/run.spec.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,80 @@ vi.mock("vscode", () => ({
1313
},
1414
}))
1515

16+
// Mock execa to test stdin behavior
17+
const mockExeca = vi.fn()
18+
const mockStdin = {
19+
write: vi.fn(),
20+
end: vi.fn(),
21+
}
22+
23+
// Mock process that simulates successful execution
24+
const createMockProcess = () => {
25+
let resolveProcess: (value: { exitCode: number }) => void
26+
const processPromise = new Promise<{ exitCode: number }>((resolve) => {
27+
resolveProcess = resolve
28+
})
29+
30+
const mockProcess = {
31+
stdin: mockStdin,
32+
stdout: {
33+
on: vi.fn(),
34+
},
35+
stderr: {
36+
on: vi.fn((event, callback) => {
37+
// Don't emit any stderr data in tests
38+
}),
39+
},
40+
on: vi.fn((event, callback) => {
41+
if (event === "close") {
42+
// Simulate successful process completion after a short delay
43+
setTimeout(() => {
44+
callback(0)
45+
resolveProcess({ exitCode: 0 })
46+
}, 10)
47+
}
48+
if (event === "error") {
49+
// Don't emit any errors in tests
50+
}
51+
}),
52+
killed: false,
53+
kill: vi.fn(),
54+
then: processPromise.then.bind(processPromise),
55+
catch: processPromise.catch.bind(processPromise),
56+
finally: processPromise.finally.bind(processPromise),
57+
}
58+
return mockProcess
59+
}
60+
61+
vi.mock("execa", () => ({
62+
execa: mockExeca,
63+
}))
64+
65+
// Mock readline with proper interface simulation
66+
let mockReadlineInterface: any = null
67+
68+
vi.mock("readline", () => ({
69+
default: {
70+
createInterface: vi.fn(() => {
71+
mockReadlineInterface = {
72+
async *[Symbol.asyncIterator]() {
73+
// Simulate Claude CLI JSON output
74+
yield '{"type":"text","text":"Hello"}'
75+
yield '{"type":"text","text":" world"}'
76+
// Simulate end of stream - must return to terminate the iterator
77+
return
78+
},
79+
close: vi.fn(),
80+
}
81+
return mockReadlineInterface
82+
}),
83+
},
84+
}))
85+
1686
describe("runClaudeCode", () => {
1787
beforeEach(() => {
1888
vi.clearAllMocks()
89+
mockExeca.mockReturnValue(createMockProcess())
1990
})
2091

2192
test("should export runClaudeCode function", async () => {
@@ -34,4 +105,99 @@ describe("runClaudeCode", () => {
34105
expect(Symbol.asyncIterator in result).toBe(true)
35106
expect(typeof result[Symbol.asyncIterator]).toBe("function")
36107
})
108+
109+
test("should use stdin instead of command line arguments for messages", async () => {
110+
const { runClaudeCode } = await import("../run")
111+
const messages = [{ role: "user" as const, content: "Hello world!" }]
112+
const options = {
113+
systemPrompt: "You are a helpful assistant",
114+
messages,
115+
}
116+
117+
const generator = runClaudeCode(options)
118+
119+
// Consume the generator to completion
120+
const results = []
121+
for await (const chunk of generator) {
122+
results.push(chunk)
123+
}
124+
125+
// Verify execa was called with correct arguments (no JSON.stringify(messages) in args)
126+
expect(mockExeca).toHaveBeenCalledWith(
127+
"claude",
128+
expect.arrayContaining([
129+
"-p",
130+
"--input-format",
131+
"text",
132+
"--system-prompt",
133+
"You are a helpful assistant",
134+
"--verbose",
135+
"--output-format",
136+
"stream-json",
137+
"--disallowedTools",
138+
expect.any(String),
139+
"--max-turns",
140+
"1",
141+
]),
142+
expect.objectContaining({
143+
stdin: "pipe",
144+
stdout: "pipe",
145+
stderr: "pipe",
146+
}),
147+
)
148+
149+
// Verify the arguments do NOT contain the stringified messages
150+
const [, args] = mockExeca.mock.calls[0]
151+
expect(args).not.toContain(JSON.stringify(messages))
152+
153+
// Verify messages were written to stdin
154+
expect(mockStdin.write).toHaveBeenCalledWith(JSON.stringify(messages), "utf8")
155+
expect(mockStdin.end).toHaveBeenCalled()
156+
157+
// Verify we got the expected mock output
158+
expect(results).toHaveLength(2)
159+
expect(results[0]).toEqual({ type: "text", text: "Hello" })
160+
expect(results[1]).toEqual({ type: "text", text: " world" })
161+
})
162+
163+
test("should include model parameter when provided", async () => {
164+
const { runClaudeCode } = await import("../run")
165+
const options = {
166+
systemPrompt: "You are a helpful assistant",
167+
messages: [{ role: "user" as const, content: "Hello" }],
168+
modelId: "claude-3-5-sonnet-20241022",
169+
}
170+
171+
const generator = runClaudeCode(options)
172+
173+
// Consume at least one item to trigger process spawn
174+
await generator.next()
175+
176+
// Clean up the generator
177+
await generator.return(undefined)
178+
179+
const [, args] = mockExeca.mock.calls[0]
180+
expect(args).toContain("--model")
181+
expect(args).toContain("claude-3-5-sonnet-20241022")
182+
})
183+
184+
test("should use custom claude path when provided", async () => {
185+
const { runClaudeCode } = await import("../run")
186+
const options = {
187+
systemPrompt: "You are a helpful assistant",
188+
messages: [{ role: "user" as const, content: "Hello" }],
189+
path: "/custom/path/to/claude",
190+
}
191+
192+
const generator = runClaudeCode(options)
193+
194+
// Consume at least one item to trigger process spawn
195+
await generator.next()
196+
197+
// Clean up the generator
198+
await generator.return(undefined)
199+
200+
const [claudePath] = mockExeca.mock.calls[0]
201+
expect(claudePath).toBe("/custom/path/to/claude")
202+
})
37203
})

src/integrations/claude-code/run.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
112112

113113
const args = [
114114
"-p",
115-
JSON.stringify(messages),
115+
"--input-format",
116+
"text",
116117
"--system-prompt",
117118
systemPrompt,
118119
"--verbose",
@@ -129,8 +130,8 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
129130
args.push("--model", modelId)
130131
}
131132

132-
return execa(claudePath, args, {
133-
stdin: "ignore",
133+
const child = execa(claudePath, args, {
134+
stdin: "pipe",
134135
stdout: "pipe",
135136
stderr: "pipe",
136137
env: {
@@ -142,6 +143,13 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
142143
maxBuffer: 1024 * 1024 * 1000,
143144
timeout: CLAUDE_CODE_TIMEOUT,
144145
})
146+
147+
// Stream the messages via stdin instead of command line argument
148+
const messagesJson = JSON.stringify(messages)
149+
child.stdin.write(messagesJson, "utf8")
150+
child.stdin.end()
151+
152+
return child
145153
}
146154

147155
function parseChunk(data: string, processState: ProcessState) {

0 commit comments

Comments
 (0)