diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 12da74245..787ead63d 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -4,7 +4,8 @@ import { ThinkingBlock } from './ThinkingBlock'; import { ToolExecutionBlock } from './ToolExecutionBlock'; import { ApprovalCard } from './ApprovalCard'; import { QuestionCard } from './QuestionCard'; -import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from './types'; +import { groupStreamEventsForDisplay } from './streamEventGrouping'; +import type { ChatMessage, PendingApproval, PendingQuestion } from './types'; import { AI_CHAT_ASSISTANT_MESSAGE_LABEL, AI_CHAT_CONTEXT_USED_LABEL, @@ -33,59 +34,13 @@ export const MessageItem: Component = (props) => { const hasStreamEvents = () => props.message.streamEvents && props.message.streamEvents.length > 0; - // Group stream events for cleaner rendering - // Combine consecutive content events, separate thinking, tools, and approvals - const groupedEvents = createMemo(() => { - const events = props.message.streamEvents || []; - const grouped: StreamDisplayEvent[] = []; - - for (const evt of events) { - // Thinking events are kept separate - if (evt.type === 'thinking') { - grouped.push(evt); - continue; - } - - // Tool events are kept separate - if (evt.type === 'tool') { - grouped.push(evt); - continue; - } - - // Pending tool events are kept separate - if (evt.type === 'pending_tool') { - grouped.push(evt); - continue; - } - - // Approval events are kept separate - if (evt.type === 'approval') { - grouped.push(evt); - continue; - } - - // Question events are kept separate - if (evt.type === 'question') { - grouped.push(evt); - continue; - } - - // Content events can be merged with previous content - if (evt.type === 'content' && evt.content) { - const lastIdx = grouped.length - 1; - if (lastIdx >= 0 && grouped[lastIdx].type === 'content') { - grouped[lastIdx] = { - ...grouped[lastIdx], - content: (grouped[lastIdx].content || '') + evt.content, - }; - } else { - grouped.push(evt); - } - } - } - - return grouped; - }); + // Group stream events into display blocks. Content and reasoning each collapse + // into a single block even when a reasoning model interleaves them, so the + // answer stays a coherent markdown document instead of fragmenting into + // whitespace-trimmed pieces. See groupStreamEventsForDisplay for the rationale. + const groupedEvents = createMemo(() => + groupStreamEventsForDisplay(props.message.streamEvents || []), + ); const contextTools = createMemo(() => { const events = props.message.streamEvents || []; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index 87a97670d..afca2546e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -528,9 +528,12 @@ describe('MessageItem', () => { expect(proseBlocks[0].innerHTML).toContain('Hello world!'); }); - it('does not merge content events separated by other event types', () => { + it('merges content into one block across interleaved thinking events', () => { + // Reasoning models reached via gateways like OpenRouter interleave + // reasoning and answer tokens. Thinking must not fragment the answer into + // separate markdown blocks, or whitespace and table structure are lost. const events: StreamDisplayEvent[] = [ - { type: 'content', content: 'Part 1' }, + { type: 'content', content: 'Part 1 ' }, { type: 'thinking', thinking: 'hmm...' }, { type: 'content', content: 'Part 2' }, ]; @@ -543,7 +546,8 @@ describe('MessageItem', () => { )); const proseBlocks = container.querySelectorAll('.prose'); - expect(proseBlocks.length).toBe(2); + expect(proseBlocks.length).toBe(1); + expect(proseBlocks[0].innerHTML).toContain('Part 1 Part 2'); }); it('ignores content events with empty content', () => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts new file mode 100644 index 000000000..e30ed89ae --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { groupStreamEventsForDisplay } from '../streamEventGrouping'; +import type { StreamDisplayEvent } from '../types'; + +const content = (text: string): StreamDisplayEvent => ({ type: 'content', content: text }); +const thinking = (text: string): StreamDisplayEvent => ({ type: 'thinking', thinking: text }); + +describe('groupStreamEventsForDisplay', () => { + it('merges consecutive content into one block', () => { + const grouped = groupStreamEventsForDisplay([content('Hello '), content('world')]); + expect(grouped).toHaveLength(1); + expect(grouped[0]).toMatchObject({ type: 'content', content: 'Hello world' }); + }); + + it('keeps one content block when reasoning interleaves with the answer', () => { + // Reasoning models via OpenRouter interleave reasoning + answer tokens. + // Each content delta must NOT become its own block, or whitespace and + // markdown structure are destroyed. + const events: StreamDisplayEvent[] = [ + thinking('Let me '), + content("Here's "), + thinking('check the '), + content('the table:\n\n'), + thinking('numbers.'), + content('| Metric | Value |\n'), + content('| --- | --- |\n'), + content('| CPU | 0.1% |'), + ]; + const grouped = groupStreamEventsForDisplay(events); + + const contentBlocks = grouped.filter((e) => e.type === 'content'); + const thinkingBlocks = grouped.filter((e) => e.type === 'thinking'); + expect(contentBlocks).toHaveLength(1); + expect(thinkingBlocks).toHaveLength(1); + + // The answer is one coherent markdown document with whitespace intact. + expect(contentBlocks[0].content).toBe( + "Here's the table:\n\n| Metric | Value |\n| --- | --- |\n| CPU | 0.1% |", + ); + expect(thinkingBlocks[0].thinking).toBe('Let me check the numbers.'); + // Reasoning arrived first, so the thinking block leads. + expect(grouped[0].type).toBe('thinking'); + }); + + it('keeps content separated across a tool boundary so order is preserved', () => { + const tool: StreamDisplayEvent = { + type: 'tool', + tool: { name: 'get_status', input: '{}', output: 'ok', success: true }, + }; + const grouped = groupStreamEventsForDisplay([ + content('Let me check.'), + tool, + content('All healthy.'), + ]); + + expect(grouped.map((e) => e.type)).toEqual(['content', 'tool', 'content']); + expect(grouped[0].content).toBe('Let me check.'); + expect(grouped[2].content).toBe('All healthy.'); + }); + + it('skips empty deltas', () => { + const grouped = groupStreamEventsForDisplay([content(''), thinking(''), content('hi')]); + expect(grouped).toHaveLength(1); + expect(grouped[0]).toMatchObject({ type: 'content', content: 'hi' }); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts b/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts new file mode 100644 index 000000000..760d79ecc --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts @@ -0,0 +1,70 @@ +import type { StreamDisplayEvent } from './types'; + +// Group raw chronological stream events into display blocks. +// +// The streamed answer text and reasoning arrive as many small deltas. Reasoning +// models reached through gateways like OpenRouter INTERLEAVE reasoning and +// answer tokens (thinking, content, thinking, content, ...) rather than sending +// all reasoning first. If each interleaved content delta is rendered as its own +// markdown block, block-level whitespace trimming collapses every inter-word +// space and a markdown table split across blocks never parses, so the answer +// renders as unreadable run-on text. +// +// To avoid that, content deltas merge into a single content block and reasoning +// deltas merge into a single thinking block, even when they arrive interleaved. +// Tool / approval / question / pending-tool events are genuine sequence +// boundaries (the model narrates around an action), so they close the open +// content and thinking blocks: text before and after an action stays ordered. +export const groupStreamEventsForDisplay = ( + events: StreamDisplayEvent[], +): StreamDisplayEvent[] => { + const grouped: StreamDisplayEvent[] = []; + // Indices of the currently-open content and thinking blocks, or -1 when none + // is open. Only a hard-boundary event resets these. + let contentIdx = -1; + let thinkingIdx = -1; + + for (const evt of events) { + switch (evt.type) { + case 'content': { + if (!evt.content) break; // skip empty deltas + if (contentIdx >= 0) { + grouped[contentIdx] = { + ...grouped[contentIdx], + content: (grouped[contentIdx].content || '') + evt.content, + }; + } else { + grouped.push({ ...evt }); + contentIdx = grouped.length - 1; + } + break; + } + + case 'thinking': { + if (!evt.thinking) break; // skip empty deltas + if (thinkingIdx >= 0) { + grouped[thinkingIdx] = { + ...grouped[thinkingIdx], + thinking: (grouped[thinkingIdx].thinking || '') + evt.thinking, + }; + } else { + grouped.push({ ...evt }); + thinkingIdx = grouped.length - 1; + } + break; + } + + // Hard boundaries: a tool call (or the surfaces it can spawn) closes the + // open text and reasoning blocks so any following text starts fresh and + // stays after the action in the transcript. + default: { + grouped.push(evt); + contentIdx = -1; + thinkingIdx = -1; + break; + } + } + } + + return grouped; +};