Skip to content

Commit c02fd53

Browse files
committed
fix: resolve Claude Code provider JSON parsing and reasoning block display
- Implement proper JSON buffering to handle incomplete data chunks - Change thinking content to yield as 'reasoning' type for proper UI display - Add comprehensive tests for Claude Code provider functionality - Fix raw JSON output appearing instead of parsed content
1 parent 1f05caa commit c02fd53

File tree

3 files changed

+252
-9
lines changed

3 files changed

+252
-9
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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+
setTimeout(() => {
89+
mockProcess.stdout.emit("data", JSON.stringify(thinkingResponse) + "\n")
90+
}, 10)
91+
92+
// Emit process close after data
93+
setTimeout(() => {
94+
mockProcess.emit("close", 0)
95+
}, 50)
96+
97+
// Get the result
98+
const result = await streamGenerator.next()
99+
100+
expect(result.done).toBe(false)
101+
expect(result.value).toEqual({
102+
type: "reasoning",
103+
text: "I need to think about this carefully...",
104+
})
105+
})
106+
107+
test("should handle mixed content types", async () => {
108+
const systemPrompt = "You are a helpful assistant"
109+
const messages = [{ role: "user" as const, content: "Hello" }]
110+
111+
const stream = handler.createMessage(systemPrompt, messages)
112+
const streamGenerator = stream[Symbol.asyncIterator]()
113+
114+
// Simulate mixed content response
115+
const mixedResponse = {
116+
type: "assistant",
117+
message: {
118+
id: "msg_123",
119+
type: "message",
120+
role: "assistant",
121+
model: "claude-3-5-sonnet-20241022",
122+
content: [
123+
{
124+
type: "thinking",
125+
thinking: "Let me think about this...",
126+
},
127+
{
128+
type: "text",
129+
text: "Here's my response!",
130+
},
131+
],
132+
stop_reason: null,
133+
stop_sequence: null,
134+
usage: {
135+
input_tokens: 10,
136+
output_tokens: 20,
137+
service_tier: "standard" as const,
138+
},
139+
},
140+
session_id: "session_123",
141+
}
142+
143+
// Emit the mixed response and wait for processing
144+
setTimeout(() => {
145+
mockProcess.stdout.emit("data", JSON.stringify(mixedResponse) + "\n")
146+
}, 10)
147+
148+
// Emit process close after data
149+
setTimeout(() => {
150+
mockProcess.emit("close", 0)
151+
}, 50)
152+
153+
// Get the first result (thinking)
154+
const thinkingResult = await streamGenerator.next()
155+
expect(thinkingResult.done).toBe(false)
156+
expect(thinkingResult.value).toEqual({
157+
type: "reasoning",
158+
text: "Let me think about this...",
159+
})
160+
161+
// Get the second result (text)
162+
const textResult = await streamGenerator.next()
163+
expect(textResult.done).toBe(false)
164+
expect(textResult.value).toEqual({
165+
type: "text",
166+
text: "Here's my response!",
167+
})
168+
})
169+
170+
test("should handle stop_reason with thinking content in error messages", async () => {
171+
const systemPrompt = "You are a helpful assistant"
172+
const messages = [{ role: "user" as const, content: "Hello" }]
173+
174+
const stream = handler.createMessage(systemPrompt, messages)
175+
const streamGenerator = stream[Symbol.asyncIterator]()
176+
177+
// Simulate error response with thinking content
178+
const errorResponse = {
179+
type: "assistant",
180+
message: {
181+
id: "msg_123",
182+
type: "message",
183+
role: "assistant",
184+
model: "claude-3-5-sonnet-20241022",
185+
content: [
186+
{
187+
type: "thinking",
188+
thinking: "This is an error scenario",
189+
},
190+
],
191+
stop_reason: "max_tokens",
192+
stop_sequence: null,
193+
usage: {
194+
input_tokens: 10,
195+
output_tokens: 20,
196+
service_tier: "standard" as const,
197+
},
198+
},
199+
session_id: "session_123",
200+
}
201+
202+
// Emit the error response and wait for processing
203+
setTimeout(() => {
204+
mockProcess.stdout.emit("data", JSON.stringify(errorResponse) + "\n")
205+
}, 10)
206+
207+
// Emit process close after data
208+
setTimeout(() => {
209+
mockProcess.emit("close", 0)
210+
}, 50)
211+
212+
// Should throw error with thinking content
213+
await expect(streamGenerator.next()).rejects.toThrow("This is an error scenario")
214+
})
215+
})

src/api/providers/claude-code.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,20 @@ 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+
if (line.trim() !== "") {
43+
dataQueue.push(line.trim())
44+
}
3845
}
3946
})
4047

@@ -44,6 +51,11 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
4451

4552
claudeProcess.on("close", (code) => {
4653
exitCode = code
54+
// Process any remaining data in buffer
55+
if (buffer.trim()) {
56+
dataQueue.push(buffer.trim())
57+
buffer = ""
58+
}
4759
})
4860

4961
claudeProcess.on("error", (error) => {
@@ -101,8 +113,13 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
101113
const message = chunk.message
102114

103115
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
116+
const firstContent = message.content[0]
104117
const errorMessage =
105-
message.content[0]?.text ||
118+
(firstContent?.type === "text"
119+
? firstContent.text
120+
: firstContent?.type === "thinking"
121+
? firstContent.thinking
122+
: undefined) ||
106123
t("common:errors.claudeCode.stoppedWithReason", { reason: message.stop_reason })
107124

108125
if (errorMessage.includes("Invalid model name")) {
@@ -118,8 +135,13 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
118135
type: "text",
119136
text: content.text,
120137
}
138+
} else if (content.type === "thinking") {
139+
yield {
140+
type: "reasoning",
141+
text: content.thinking,
142+
}
121143
} else {
122-
console.warn("Unsupported content type:", content.type)
144+
console.warn("Unsupported content type:", (content as any).type)
123145
}
124146
}
125147

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)