Skip to content

Commit d563fed

Browse files
committed
fix: convert escaped newlines to actual newlines in Claude Code output
- Fix issue where Claude Code outputs containing \n were displayed literally - Add text.replace(/\\n/g, n) for both string chunks and text content - Add comprehensive tests to verify the fix works correctly - Resolves #6709
1 parent d90bab7 commit d563fed

File tree

2 files changed

+130
-2
lines changed

2 files changed

+130
-2
lines changed

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

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

564564
consoleSpy.mockRestore()
565565
})
566+
567+
test("should convert escaped newlines to actual newlines in string chunks", 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 string chunks with escaped newlines
572+
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
573+
yield "Line 1\\nLine 2\\nLine 3"
574+
yield "Another chunk\\nwith newlines"
575+
}
576+
577+
mockRunClaudeCode.mockReturnValue(mockGenerator())
578+
579+
const stream = handler.createMessage(systemPrompt, messages)
580+
const results = []
581+
582+
for await (const chunk of stream) {
583+
results.push(chunk)
584+
}
585+
586+
expect(results).toHaveLength(2)
587+
expect(results[0]).toEqual({
588+
type: "text",
589+
text: "Line 1\nLine 2\nLine 3",
590+
})
591+
expect(results[1]).toEqual({
592+
type: "text",
593+
text: "Another chunk\nwith newlines",
594+
})
595+
})
596+
597+
test("should convert escaped newlines in text content from assistant messages", async () => {
598+
const systemPrompt = "You are a helpful assistant"
599+
const messages = [{ role: "user" as const, content: "Hello" }]
600+
601+
// Mock async generator that yields assistant message with escaped newlines
602+
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
603+
yield {
604+
type: "assistant" as const,
605+
message: {
606+
id: "msg_123",
607+
type: "message",
608+
role: "assistant",
609+
model: "claude-3-5-sonnet-20241022",
610+
content: [
611+
{
612+
type: "text",
613+
text: "# Claude Chat History\\n\\n## 2025-08-05\\n\\nHello there!",
614+
},
615+
],
616+
stop_reason: null,
617+
stop_sequence: null,
618+
usage: {
619+
input_tokens: 10,
620+
output_tokens: 20,
621+
},
622+
} as any,
623+
session_id: "session_123",
624+
}
625+
}
626+
627+
mockRunClaudeCode.mockReturnValue(mockGenerator())
628+
629+
const stream = handler.createMessage(systemPrompt, messages)
630+
const results = []
631+
632+
for await (const chunk of stream) {
633+
results.push(chunk)
634+
}
635+
636+
expect(results).toHaveLength(1)
637+
expect(results[0]).toEqual({
638+
type: "text",
639+
text: "# Claude Chat History\n\n## 2025-08-05\n\nHello there!",
640+
})
641+
})
642+
643+
test("should handle mixed escaped sequences correctly", async () => {
644+
const systemPrompt = "You are a helpful assistant"
645+
const messages = [{ role: "user" as const, content: "Hello" }]
646+
647+
// Mock async generator that yields text with various escape sequences
648+
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
649+
yield "Text with\\nnewlines and\\ttabs"
650+
yield {
651+
type: "assistant" as const,
652+
message: {
653+
id: "msg_123",
654+
type: "message",
655+
role: "assistant",
656+
model: "claude-3-5-sonnet-20241022",
657+
content: [
658+
{
659+
type: "text",
660+
text: "More text\\nwith\\\\backslashes\\nand newlines",
661+
},
662+
],
663+
stop_reason: null,
664+
stop_sequence: null,
665+
usage: {
666+
input_tokens: 10,
667+
output_tokens: 20,
668+
},
669+
} as any,
670+
session_id: "session_123",
671+
}
672+
}
673+
674+
mockRunClaudeCode.mockReturnValue(mockGenerator())
675+
676+
const stream = handler.createMessage(systemPrompt, messages)
677+
const results = []
678+
679+
for await (const chunk of stream) {
680+
results.push(chunk)
681+
}
682+
683+
expect(results).toHaveLength(2)
684+
// Only \n should be converted, not \t or \\
685+
expect(results[0]).toEqual({
686+
type: "text",
687+
text: "Text with\nnewlines and\\ttabs",
688+
})
689+
expect(results[1]).toEqual({
690+
type: "text",
691+
text: "More text\nwith\\\\backslashes\nand newlines",
692+
})
693+
})
566694
})

src/api/providers/claude-code.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
5656
if (typeof chunk === "string") {
5757
yield {
5858
type: "text",
59-
text: chunk,
59+
text: chunk.replace(/\\n/g, "\n"),
6060
}
6161

6262
continue
@@ -100,7 +100,7 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
100100
case "text":
101101
yield {
102102
type: "text",
103-
text: content.text,
103+
text: content.text.replace(/\\n/g, "\n"),
104104
}
105105
break
106106
case "thinking":

0 commit comments

Comments
 (0)