mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Render Assistant answer as one block when reasoning interleaves
Reasoning models reached through gateways like OpenRouter (e.g. deepseek/deepseek-v4-pro) interleave reasoning and answer tokens rather than sending all reasoning first. The chat display grouped only CONSECUTIVE content events, so each interleaved content delta became its own markdown block. Block level whitespace trimming then collapsed every inter-word space and a markdown table split across blocks never parsed, so the answer rendered as unreadable run-on text with a dead table. Extract the grouping into groupStreamEventsForDisplay and merge content (and reasoning) each into a single block across intervening thinking events, while keeping tool/approval/question events as hard boundaries so text before and after an action stays ordered. Direct DeepSeek (reasoning-then-content) is unchanged. Adds unit tests for the interleaved, tool-boundary, and consecutive cases.
This commit is contained in:
@@ -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<MessageItemProps> = (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 || [];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user