Skip to content

Commit e28b9a9

Browse files
committed
fix: handle JSON parsing in Claude Code CLI responses
- Parse string chunks that contain JSON assistant messages - Extract and properly format text content from parsed messages - Handle malformed JSON gracefully by displaying as-is - Add comprehensive tests for new parsing behavior Fixes #6125
1 parent 4042fb0 commit e28b9a9

File tree

2 files changed

+221
-58
lines changed

2 files changed

+221
-58
lines changed

src/api/providers/__tests__/claude-code.spec.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,4 +563,134 @@ describe("ClaudeCodeHandler", () => {
563563

564564
consoleSpy.mockRestore()
565565
})
566+
567+
test("should parse string chunks that are JSON assistant messages", async () => {
568+
const systemPrompt = "You are a helpful assistant"
569+
const messages = [{ role: "user" as const, content: "Hello" }]
570+
571+
// Mock async generator that yields a string containing JSON assistant message
572+
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
573+
// Yield a string that's actually a JSON assistant message
574+
yield JSON.stringify({
575+
type: "assistant",
576+
message: {
577+
id: "msg_123",
578+
type: "message",
579+
role: "assistant",
580+
model: "claude-3-5-sonnet-20241022",
581+
content: [
582+
{
583+
type: "text",
584+
text: "This is a response from a JSON string",
585+
},
586+
],
587+
stop_reason: null,
588+
stop_sequence: null,
589+
usage: {
590+
input_tokens: 10,
591+
output_tokens: 20,
592+
},
593+
},
594+
session_id: "session_123",
595+
})
596+
}
597+
598+
mockRunClaudeCode.mockReturnValue(mockGenerator())
599+
600+
const stream = handler.createMessage(systemPrompt, messages)
601+
const results = []
602+
603+
for await (const chunk of stream) {
604+
results.push(chunk)
605+
}
606+
607+
// Should parse the JSON and yield the text content
608+
expect(results).toHaveLength(1)
609+
expect(results[0]).toEqual({
610+
type: "text",
611+
text: "This is a response from a JSON string",
612+
})
613+
})
614+
615+
test("should handle malformed JSON strings gracefully", async () => {
616+
const systemPrompt = "You are a helpful assistant"
617+
const messages = [{ role: "user" as const, content: "Hello" }]
618+
619+
// Mock async generator that yields malformed JSON
620+
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
621+
// Yield a malformed JSON string with escaped newlines
622+
yield '{"type":"assistant","message":{"id":"msg_123"\\n\\n\\n"content":[{"type":"text","text":"Malformed"}]}'
623+
}
624+
625+
mockRunClaudeCode.mockReturnValue(mockGenerator())
626+
627+
const stream = handler.createMessage(systemPrompt, messages)
628+
const results = []
629+
630+
for await (const chunk of stream) {
631+
results.push(chunk)
632+
}
633+
634+
// Should yield the malformed string as text since it can't be parsed
635+
expect(results).toHaveLength(1)
636+
expect(results[0]).toEqual({
637+
type: "text",
638+
text: '{"type":"assistant","message":{"id":"msg_123"\\n\\n\\n"content":[{"type":"text","text":"Malformed"}]}',
639+
})
640+
})
641+
642+
test("should handle string chunks with thinking content when parsed from JSON", async () => {
643+
const systemPrompt = "You are a helpful assistant"
644+
const messages = [{ role: "user" as const, content: "Hello" }]
645+
646+
// Mock async generator that yields a string containing JSON with thinking
647+
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
648+
yield JSON.stringify({
649+
type: "assistant",
650+
message: {
651+
id: "msg_123",
652+
type: "message",
653+
role: "assistant",
654+
model: "claude-3-5-sonnet-20241022",
655+
content: [
656+
{
657+
type: "thinking",
658+
thinking: "Let me think about this...",
659+
},
660+
{
661+
type: "text",
662+
text: "Here's my answer",
663+
},
664+
],
665+
stop_reason: null,
666+
stop_sequence: null,
667+
usage: {
668+
input_tokens: 10,
669+
output_tokens: 20,
670+
},
671+
},
672+
session_id: "session_123",
673+
})
674+
}
675+
676+
mockRunClaudeCode.mockReturnValue(mockGenerator())
677+
678+
const stream = handler.createMessage(systemPrompt, messages)
679+
const results = []
680+
681+
for await (const chunk of stream) {
682+
results.push(chunk)
683+
}
684+
685+
// Should parse and yield both thinking and text content
686+
expect(results).toHaveLength(2)
687+
expect(results[0]).toEqual({
688+
type: "reasoning",
689+
text: "Let me think about this...",
690+
})
691+
expect(results[1]).toEqual({
692+
type: "text",
693+
text: "Here's my answer",
694+
})
695+
})
566696
})

src/api/providers/claude-code.ts

Lines changed: 91 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
5454

5555
for await (const chunk of claudeProcess) {
5656
if (typeof chunk === "string") {
57+
// Try to parse string chunks that might be JSON assistant messages
58+
const parsedChunk = this.attemptParseAssistantMessage(chunk)
59+
if (parsedChunk) {
60+
// Process as assistant message
61+
yield* this.processAssistantMessage(parsedChunk, usage, isPaidUsage)
62+
continue
63+
}
64+
65+
// If not a JSON message, yield as text
5766
yield {
5867
type: "text",
5968
text: chunk,
@@ -69,64 +78,7 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
6978
}
7079

7180
if (chunk.type === "assistant" && "message" in chunk) {
72-
const message = chunk.message
73-
74-
if (message.stop_reason !== null) {
75-
const content = "text" in message.content[0] ? message.content[0] : undefined
76-
77-
const isError = content && content.text.startsWith(`API Error`)
78-
if (isError) {
79-
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
80-
const errorMessageStart = content.text.indexOf("{")
81-
const errorMessage = content.text.slice(errorMessageStart)
82-
83-
const error = this.attemptParse(errorMessage)
84-
if (!error) {
85-
throw new Error(content.text)
86-
}
87-
88-
if (error.error.message.includes("Invalid model name")) {
89-
throw new Error(
90-
content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`,
91-
)
92-
}
93-
94-
throw new Error(errorMessage)
95-
}
96-
}
97-
98-
for (const content of message.content) {
99-
switch (content.type) {
100-
case "text":
101-
yield {
102-
type: "text",
103-
text: content.text,
104-
}
105-
break
106-
case "thinking":
107-
yield {
108-
type: "reasoning",
109-
text: content.thinking || "",
110-
}
111-
break
112-
case "redacted_thinking":
113-
yield {
114-
type: "reasoning",
115-
text: "[Redacted thinking block]",
116-
}
117-
break
118-
case "tool_use":
119-
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
120-
break
121-
}
122-
}
123-
124-
usage.inputTokens += message.usage.input_tokens
125-
usage.outputTokens += message.usage.output_tokens
126-
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0)
127-
usage.cacheWriteTokens =
128-
(usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0)
129-
81+
yield* this.processAssistantMessage(chunk, usage, isPaidUsage)
13082
continue
13183
}
13284

@@ -172,4 +124,85 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
172124
return null
173125
}
174126
}
127+
128+
private *processAssistantMessage(
129+
chunk: any,
130+
usage: ApiStreamUsageChunk,
131+
isPaidUsage: boolean,
132+
): Generator<any, void, unknown> {
133+
const message = chunk.message
134+
135+
if (message.stop_reason !== null) {
136+
const content = "text" in message.content[0] ? message.content[0] : undefined
137+
138+
const isError = content && content.text.startsWith(`API Error`)
139+
if (isError) {
140+
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
141+
const errorMessageStart = content.text.indexOf("{")
142+
const errorMessage = content.text.slice(errorMessageStart)
143+
144+
const error = this.attemptParse(errorMessage)
145+
if (!error) {
146+
throw new Error(content.text)
147+
}
148+
149+
if (error.error.message.includes("Invalid model name")) {
150+
throw new Error(content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`)
151+
}
152+
153+
throw new Error(errorMessage)
154+
}
155+
}
156+
157+
for (const content of message.content) {
158+
switch (content.type) {
159+
case "text":
160+
yield {
161+
type: "text",
162+
text: content.text,
163+
}
164+
break
165+
case "thinking":
166+
yield {
167+
type: "reasoning",
168+
text: content.thinking || "",
169+
}
170+
break
171+
case "redacted_thinking":
172+
yield {
173+
type: "reasoning",
174+
text: "[Redacted thinking block]",
175+
}
176+
break
177+
case "tool_use":
178+
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
179+
break
180+
}
181+
}
182+
183+
usage.inputTokens += message.usage.input_tokens
184+
usage.outputTokens += message.usage.output_tokens
185+
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0)
186+
usage.cacheWriteTokens = (usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0)
187+
}
188+
189+
private attemptParseAssistantMessage(str: string): any {
190+
// Only try to parse if it looks like a JSON assistant message
191+
if (!str.trim().startsWith('{"type":"assistant"')) {
192+
return null
193+
}
194+
195+
try {
196+
const parsed = JSON.parse(str)
197+
// Validate it has the expected structure
198+
if (parsed.type === "assistant" && parsed.message) {
199+
return parsed
200+
}
201+
return null
202+
} catch (err) {
203+
// If parsing fails, log the error for debugging but don't throw
204+
console.error("Failed to parse potential assistant message:", err)
205+
return null
206+
}
207+
}
175208
}

0 commit comments

Comments
 (0)