diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 1d3d4a150931..37c91cbd4142 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -204,6 +204,154 @@ describe("XAIHandler", () => { }) }) + it("createMessage should sanitize tool tags from reasoning content", async () => { + const reasoningWithTags = + "I need to fix this code and then change mode" + const expectedSanitized = "I need to fix this code and then change mode" + + // Setup mock for streaming response + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [{ delta: { reasoning_content: reasoningWithTags } }], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + // Create and consume the stream + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + // Verify the reasoning content is sanitized + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ + type: "reasoning", + text: expectedSanitized, + }) + }) + + it("createMessage should handle complex nested tool tags in reasoning", async () => { + const complexReasoning = `Let me think about this... + +This should be removed + +Now I'll use npm test +And finally complete` + + const expectedSanitized = `Let me think about this... + +This should be removed + +Now I'll use npm test +And finally complete` + + // Setup mock for streaming response + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [{ delta: { reasoning_content: complexReasoning } }], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + // Create and consume the stream + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + // Verify the reasoning content is properly sanitized + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ + type: "reasoning", + text: expectedSanitized, + }) + }) + + it("createMessage should not yield reasoning if content is empty after sanitization", async () => { + const onlyTags = "" + + // Setup mock for streaming response + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [{ delta: { reasoning_content: onlyTags } }], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [{ delta: { content: "Regular content" } }], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + // Create and consume the stream + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + // Should skip the empty reasoning and go straight to the regular content + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ + type: "text", + text: "Regular content", + }) + }) + + it("createMessage should preserve reasoning content without tool tags", async () => { + const cleanReasoning = "This is clean reasoning content without any tool tags. Just thinking about the problem." + + // Setup mock for streaming response + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [{ delta: { reasoning_content: cleanReasoning } }], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + // Create and consume the stream + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + // Verify the reasoning content is preserved as-is + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ + type: "reasoning", + text: cleanReasoning, + }) + }) + it("createMessage should yield usage data from stream", async () => { // Setup mock for streaming response that includes usage data mockCreate.mockImplementationOnce(() => { diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 7eb6e9866dd8..84704158021e 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -16,6 +16,26 @@ import { handleOpenAIError } from "./utils/openai-error-handler" const XAI_DEFAULT_TEMPERATURE = 0 +/** + * Sanitizes reasoning content by removing tool-related XML/HTML tags + * that may appear in the model's thinking output. + * This prevents tags like , , etc. from being displayed. + */ +function sanitizeReasoningContent(content: string): string { + // Remove XML/HTML-like tags that are tool-related + // Matches patterns like , , , etc. + const toolTagPattern = + /<\/?(?:appy_diff|switch_mode|apply_diff|write_to_file|search_files|read_file|execute_command|list_files|insert_content|attempt_completion|ask_followup_question|update_todo_list|new_task|fetch_instructions|list_code_definition_names)[^>]*>/gi + + // Remove the tool tags while preserving the content between them + let sanitized = content.replace(toolTagPattern, "") + + // Clean up any excessive whitespace that might result from tag removal + sanitized = sanitized.replace(/\n{3,}/g, "\n\n").trim() + + return sanitized +} + export class XAIHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: OpenAI @@ -79,9 +99,12 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler } if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: delta.reasoning_content as string, + const sanitizedContent = sanitizeReasoningContent(delta.reasoning_content as string) + if (sanitizedContent.trim()) { + yield { + type: "reasoning", + text: sanitizedContent, + } } }