Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/api/providers/__tests__/claude-code.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,4 +563,132 @@ describe("ClaudeCodeHandler", () => {

consoleSpy.mockRestore()
})

test("should convert escaped newlines to actual newlines in string chunks", async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent test coverage! The tests thoroughly verify the newline conversion behavior.

One minor suggestion: Consider adding a test case for edge cases like empty strings or null/undefined text content to ensure the replace operation handles these gracefully.

const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]

// Mock async generator that yields string chunks with escaped newlines
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield "Line 1\\nLine 2\\nLine 3"
yield "Another chunk\\nwith newlines"
}

mockRunClaudeCode.mockReturnValue(mockGenerator())

const stream = handler.createMessage(systemPrompt, messages)
const results = []

for await (const chunk of stream) {
results.push(chunk)
}

expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "text",
text: "Line 1\nLine 2\nLine 3",
})
expect(results[1]).toEqual({
type: "text",
text: "Another chunk\nwith newlines",
})
})

test("should convert escaped newlines in text content from assistant messages", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]

// Mock async generator that yields assistant message with escaped newlines
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: "# Claude Chat History\\n\\n## 2025-08-05\\n\\nHello there!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
}

mockRunClaudeCode.mockReturnValue(mockGenerator())

const stream = handler.createMessage(systemPrompt, messages)
const results = []

for await (const chunk of stream) {
results.push(chunk)
}

expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "text",
text: "# Claude Chat History\n\n## 2025-08-05\n\nHello there!",
})
})

test("should handle mixed escaped sequences correctly", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]

// Mock async generator that yields text with various escape sequences
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield "Text with\\nnewlines and\\ttabs"
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: "More text\\nwith\\\\backslashes\\nand newlines",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
}

mockRunClaudeCode.mockReturnValue(mockGenerator())

const stream = handler.createMessage(systemPrompt, messages)
const results = []

for await (const chunk of stream) {
results.push(chunk)
}

expect(results).toHaveLength(2)
// Only \n should be converted, not \t or \\
expect(results[0]).toEqual({
type: "text",
text: "Text with\nnewlines and\\ttabs",
})
expect(results[1]).toEqual({
type: "text",
text: "More text\nwith\\\\backslashes\nand newlines",
})
})
})
4 changes: 2 additions & 2 deletions src/api/providers/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
if (typeof chunk === "string") {
yield {
type: "text",
text: chunk,
text: chunk.replace(/\\n/g, "\n"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix! Though I'm wondering - should we also handle other escape sequences like or that might appear in Claude Code output? Currently we only handle .

}

continue
Expand Down Expand Up @@ -100,7 +100,7 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
case "text":
yield {
type: "text",
text: content.text,
text: content.text.replace(/\\n/g, "\n"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same consideration here - should we handle and as well?

Also, a brief comment explaining why this transformation is necessary would help future maintainers understand the context (e.g., "Convert escaped newlines from Claude Code process output to actual newlines for proper rendering").

}
break
case "thinking":
Expand Down
Loading