Skip to content

Commit cabf191

Browse files
fix: resolve Claude Code provider JSON parsing and reasoning block display (#5049)
Co-authored-by: Daniel Riccio <[email protected]>
1 parent 3fda1ef commit cabf191

File tree

3 files changed

+315
-10
lines changed

3 files changed

+315
-10
lines changed
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { describe, test, expect, vi, beforeEach } from "vitest"
2+
import { ClaudeCodeHandler } from "../claude-code"
3+
import { ApiHandlerOptions } from "../../../shared/api"
4+
5+
// Mock the runClaudeCode function
6+
vi.mock("../../../integrations/claude-code/run", () => ({
7+
runClaudeCode: vi.fn(),
8+
}))
9+
10+
const { runClaudeCode } = await import("../../../integrations/claude-code/run")
11+
const mockRunClaudeCode = vi.mocked(runClaudeCode)
12+
13+
// Mock the EventEmitter for the process
14+
class MockEventEmitter {
15+
private handlers: { [event: string]: ((...args: any[]) => void)[] } = {}
16+
17+
on(event: string, handler: (...args: any[]) => void) {
18+
if (!this.handlers[event]) {
19+
this.handlers[event] = []
20+
}
21+
this.handlers[event].push(handler)
22+
}
23+
24+
emit(event: string, ...args: any[]) {
25+
if (this.handlers[event]) {
26+
this.handlers[event].forEach((handler) => handler(...args))
27+
}
28+
}
29+
}
30+
31+
describe("ClaudeCodeHandler", () => {
32+
let handler: ClaudeCodeHandler
33+
let mockProcess: any
34+
35+
beforeEach(() => {
36+
const options: ApiHandlerOptions = {
37+
claudeCodePath: "claude",
38+
apiModelId: "claude-3-5-sonnet-20241022",
39+
}
40+
handler = new ClaudeCodeHandler(options)
41+
42+
const mainEmitter = new MockEventEmitter()
43+
mockProcess = {
44+
stdout: new MockEventEmitter(),
45+
stderr: new MockEventEmitter(),
46+
on: mainEmitter.on.bind(mainEmitter),
47+
emit: mainEmitter.emit.bind(mainEmitter),
48+
}
49+
50+
mockRunClaudeCode.mockReturnValue(mockProcess)
51+
})
52+
53+
test("should handle thinking content properly", async () => {
54+
const systemPrompt = "You are a helpful assistant"
55+
const messages = [{ role: "user" as const, content: "Hello" }]
56+
57+
// Start the stream
58+
const stream = handler.createMessage(systemPrompt, messages)
59+
const streamGenerator = stream[Symbol.asyncIterator]()
60+
61+
// Simulate thinking content response
62+
const thinkingResponse = {
63+
type: "assistant",
64+
message: {
65+
id: "msg_123",
66+
type: "message",
67+
role: "assistant",
68+
model: "claude-3-5-sonnet-20241022",
69+
content: [
70+
{
71+
type: "thinking",
72+
thinking: "I need to think about this carefully...",
73+
signature: "abc123",
74+
},
75+
],
76+
stop_reason: null,
77+
stop_sequence: null,
78+
usage: {
79+
input_tokens: 10,
80+
output_tokens: 20,
81+
service_tier: "standard" as const,
82+
},
83+
},
84+
session_id: "session_123",
85+
}
86+
87+
// Emit the thinking response and wait for processing
88+
setImmediate(() => {
89+
mockProcess.stdout.emit("data", JSON.stringify(thinkingResponse) + "\n")
90+
setImmediate(() => {
91+
mockProcess.emit("close", 0)
92+
})
93+
})
94+
95+
// Get the result
96+
const result = await streamGenerator.next()
97+
98+
expect(result.done).toBe(false)
99+
expect(result.value).toEqual({
100+
type: "reasoning",
101+
text: "I need to think about this carefully...",
102+
})
103+
})
104+
105+
test("should handle mixed content types", async () => {
106+
const systemPrompt = "You are a helpful assistant"
107+
const messages = [{ role: "user" as const, content: "Hello" }]
108+
109+
const stream = handler.createMessage(systemPrompt, messages)
110+
const streamGenerator = stream[Symbol.asyncIterator]()
111+
112+
// Simulate mixed content response
113+
const mixedResponse = {
114+
type: "assistant",
115+
message: {
116+
id: "msg_123",
117+
type: "message",
118+
role: "assistant",
119+
model: "claude-3-5-sonnet-20241022",
120+
content: [
121+
{
122+
type: "thinking",
123+
thinking: "Let me think about this...",
124+
},
125+
{
126+
type: "text",
127+
text: "Here's my response!",
128+
},
129+
],
130+
stop_reason: null,
131+
stop_sequence: null,
132+
usage: {
133+
input_tokens: 10,
134+
output_tokens: 20,
135+
service_tier: "standard" as const,
136+
},
137+
},
138+
session_id: "session_123",
139+
}
140+
141+
// Emit the mixed response and wait for processing
142+
setImmediate(() => {
143+
mockProcess.stdout.emit("data", JSON.stringify(mixedResponse) + "\n")
144+
setImmediate(() => {
145+
mockProcess.emit("close", 0)
146+
})
147+
})
148+
149+
// Get the first result (thinking)
150+
const thinkingResult = await streamGenerator.next()
151+
expect(thinkingResult.done).toBe(false)
152+
expect(thinkingResult.value).toEqual({
153+
type: "reasoning",
154+
text: "Let me think about this...",
155+
})
156+
157+
// Get the second result (text)
158+
const textResult = await streamGenerator.next()
159+
expect(textResult.done).toBe(false)
160+
expect(textResult.value).toEqual({
161+
type: "text",
162+
text: "Here's my response!",
163+
})
164+
})
165+
166+
test("should handle stop_reason with thinking content in error messages", async () => {
167+
const systemPrompt = "You are a helpful assistant"
168+
const messages = [{ role: "user" as const, content: "Hello" }]
169+
170+
const stream = handler.createMessage(systemPrompt, messages)
171+
const streamGenerator = stream[Symbol.asyncIterator]()
172+
173+
// Simulate error response with thinking content
174+
const errorResponse = {
175+
type: "assistant",
176+
message: {
177+
id: "msg_123",
178+
type: "message",
179+
role: "assistant",
180+
model: "claude-3-5-sonnet-20241022",
181+
content: [
182+
{
183+
type: "thinking",
184+
thinking: "This is an error scenario",
185+
},
186+
],
187+
stop_reason: "max_tokens",
188+
stop_sequence: null,
189+
usage: {
190+
input_tokens: 10,
191+
output_tokens: 20,
192+
service_tier: "standard" as const,
193+
},
194+
},
195+
session_id: "session_123",
196+
}
197+
198+
// Emit the error response and wait for processing
199+
setImmediate(() => {
200+
mockProcess.stdout.emit("data", JSON.stringify(errorResponse) + "\n")
201+
setImmediate(() => {
202+
mockProcess.emit("close", 0)
203+
})
204+
})
205+
206+
// Should throw error with thinking content
207+
await expect(streamGenerator.next()).rejects.toThrow("This is an error scenario")
208+
})
209+
210+
test("should handle incomplete JSON in buffer on process close", async () => {
211+
const systemPrompt = "You are a helpful assistant"
212+
const messages = [{ role: "user" as const, content: "Hello" }]
213+
214+
const stream = handler.createMessage(systemPrompt, messages)
215+
const streamGenerator = stream[Symbol.asyncIterator]()
216+
217+
// Simulate incomplete JSON data followed by process close
218+
setImmediate(() => {
219+
// Send incomplete JSON (missing closing brace)
220+
mockProcess.stdout.emit("data", '{"type":"assistant","message":{"id":"msg_123"')
221+
setImmediate(() => {
222+
mockProcess.emit("close", 0)
223+
})
224+
})
225+
226+
// Should complete without throwing, incomplete JSON should be discarded
227+
const result = await streamGenerator.next()
228+
expect(result.done).toBe(true)
229+
})
230+
})

src/api/providers/claude-code.ts

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,21 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
2828
let processError = null
2929
let errorOutput = ""
3030
let exitCode: number | null = null
31+
let buffer = ""
3132

3233
claudeProcess.stdout.on("data", (data) => {
33-
const output = data.toString()
34-
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
34+
buffer += data.toString()
35+
const lines = buffer.split("\n")
3536

37+
// Keep the last line in buffer as it might be incomplete
38+
buffer = lines.pop() || ""
39+
40+
// Process complete lines
3641
for (const line of lines) {
37-
dataQueue.push(line)
42+
const trimmedLine = line.trim()
43+
if (trimmedLine !== "") {
44+
dataQueue.push(trimmedLine)
45+
}
3846
}
3947
})
4048

@@ -44,6 +52,20 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
4452

4553
claudeProcess.on("close", (code) => {
4654
exitCode = code
55+
// Process any remaining data in buffer
56+
const trimmedBuffer = buffer.trim()
57+
if (trimmedBuffer) {
58+
// Validate that the remaining buffer looks like valid JSON before processing
59+
if (this.isLikelyValidJSON(trimmedBuffer)) {
60+
dataQueue.push(trimmedBuffer)
61+
} else {
62+
console.warn(
63+
"Discarding incomplete JSON data on process close:",
64+
trimmedBuffer.substring(0, 100) + (trimmedBuffer.length > 100 ? "..." : ""),
65+
)
66+
}
67+
buffer = ""
68+
}
4769
})
4870

4971
claudeProcess.on("error", (error) => {
@@ -101,8 +123,9 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
101123
const message = chunk.message
102124

103125
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
126+
const firstContent = message.content[0]
104127
const errorMessage =
105-
message.content[0]?.text ||
128+
this.getContentText(firstContent) ||
106129
t("common:errors.claudeCode.stoppedWithReason", { reason: message.stop_reason })
107130

108131
if (errorMessage.includes("Invalid model name")) {
@@ -118,8 +141,13 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
118141
type: "text",
119142
text: content.text,
120143
}
144+
} else if (content.type === "thinking") {
145+
yield {
146+
type: "reasoning",
147+
text: content.thinking,
148+
}
121149
} else {
122-
console.warn("Unsupported content type:", content.type)
150+
console.warn("Unsupported content type:", content)
123151
}
124152
}
125153

@@ -159,12 +187,53 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
159187
}
160188
}
161189

190+
private getContentText(content: any): string | undefined {
191+
if (!content) return undefined
192+
switch (content.type) {
193+
case "text":
194+
return content.text
195+
case "thinking":
196+
return content.thinking
197+
default:
198+
return undefined
199+
}
200+
}
201+
202+
private isLikelyValidJSON(data: string): boolean {
203+
// Basic validation to check if the data looks like it could be valid JSON
204+
const trimmed = data.trim()
205+
if (!trimmed) return false
206+
207+
// Must start and end with appropriate JSON delimiters
208+
const startsCorrectly = trimmed.startsWith("{") || trimmed.startsWith("[")
209+
const endsCorrectly = trimmed.endsWith("}") || trimmed.endsWith("]")
210+
211+
if (!startsCorrectly || !endsCorrectly) return false
212+
213+
// Check for balanced braces/brackets (simple heuristic)
214+
let braceCount = 0
215+
let bracketCount = 0
216+
for (const char of trimmed) {
217+
if (char === "{") braceCount++
218+
else if (char === "}") braceCount--
219+
else if (char === "[") bracketCount++
220+
else if (char === "]") bracketCount--
221+
}
222+
223+
return braceCount === 0 && bracketCount === 0
224+
}
225+
162226
// TODO: Validate instead of parsing
163227
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
164228
try {
165229
return JSON.parse(data)
166230
} catch (error) {
167-
console.error("Error parsing chunk:", error)
231+
console.error(
232+
"Error parsing chunk:",
233+
error,
234+
"Data:",
235+
data.substring(0, 100) + (data.length > 100 ? "..." : ""),
236+
)
168237
return null
169238
}
170239
}

src/integrations/claude-code/types.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ type InitMessage = {
66
mcp_servers: string[]
77
}
88

9-
type ClaudeCodeContent = {
10-
type: "text"
11-
text: string
12-
}
9+
type ClaudeCodeContent =
10+
| {
11+
type: "text"
12+
text: string
13+
}
14+
| {
15+
type: "thinking"
16+
thinking: string
17+
signature?: string
18+
}
1319

1420
type AssistantMessage = {
1521
type: "assistant"

0 commit comments

Comments
 (0)