From d25da068526bcf30416b0dab1f7e4973b914b05c Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 11 Mar 2026 18:11:34 -0700 Subject: [PATCH] fix: separate reasoning blocks with newline on markdown boundaries (#569) --- src/core/display-pipeline.test.ts | 62 +++++++++++++++++++++++++++++++ src/core/display-pipeline.ts | 12 +++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/core/display-pipeline.test.ts b/src/core/display-pipeline.test.ts index 2b351ba..b515aa0 100644 --- a/src/core/display-pipeline.test.ts +++ b/src/core/display-pipeline.test.ts @@ -109,6 +109,68 @@ describe('createDisplayPipeline', () => { } }); + it('inserts newline before reasoning chunk starting with bold header', async () => { + const events = await collect([ + { type: 'reasoning', content: 'First section ends here.', runId: 'run-1' }, + { type: 'reasoning', content: '**Second Section**\nMore text.', runId: 'run-1' }, + { type: 'assistant', content: 'reply', runId: 'run-1' }, + { type: 'result', success: true, result: 'reply', runIds: ['run-1'] }, + ]); + + const reasoning = events.find(e => e.type === 'reasoning'); + expect(reasoning).toBeDefined(); + if (reasoning?.type === 'reasoning') { + expect(reasoning.content).toBe('First section ends here.\n**Second Section**\nMore text.'); + } + }); + + it('inserts newline before reasoning chunk starting with markdown heading', async () => { + const events = await collect([ + { type: 'reasoning', content: 'End of thought.', runId: 'run-1' }, + { type: 'reasoning', content: '## Next Topic\nDetails here.', runId: 'run-1' }, + { type: 'assistant', content: 'reply', runId: 'run-1' }, + { type: 'result', success: true, result: 'reply', runIds: ['run-1'] }, + ]); + + const reasoning = events.find(e => e.type === 'reasoning'); + expect(reasoning).toBeDefined(); + if (reasoning?.type === 'reasoning') { + expect(reasoning.content).toBe('End of thought.\n## Next Topic\nDetails here.'); + } + }); + + it('does not insert separator for token-level streaming chunks', async () => { + const events = await collect([ + { type: 'reasoning', content: "I'm", runId: 'run-1' }, + { type: 'reasoning', content: ' thinking', runId: 'run-1' }, + { type: 'reasoning', content: ' about this', runId: 'run-1' }, + { type: 'assistant', content: 'reply', runId: 'run-1' }, + { type: 'result', success: true, result: 'reply', runIds: ['run-1'] }, + ]); + + const reasoning = events.find(e => e.type === 'reasoning'); + expect(reasoning).toBeDefined(); + if (reasoning?.type === 'reasoning') { + expect(reasoning.content).toBe("I'm thinking about this"); + } + }); + + it('skips separator when buffer already ends with newline', async () => { + const events = await collect([ + { type: 'reasoning', content: 'First block.\n', runId: 'run-1' }, + { type: 'reasoning', content: '**Second block**', runId: 'run-1' }, + { type: 'assistant', content: 'reply', runId: 'run-1' }, + { type: 'result', success: true, result: 'reply', runIds: ['run-1'] }, + ]); + + const reasoning = events.find(e => e.type === 'reasoning'); + expect(reasoning).toBeDefined(); + if (reasoning?.type === 'reasoning') { + // No double newline -- buffer already ended with \n + expect(reasoning.content).toBe('First block.\n**Second block**'); + } + }); + it('prefers streamed text over result field on divergence', async () => { const events = await collect([ { type: 'assistant', content: 'streamed reply', runId: 'run-1' }, diff --git a/src/core/display-pipeline.ts b/src/core/display-pipeline.ts index 4f03d09..10258f1 100644 --- a/src/core/display-pipeline.ts +++ b/src/core/display-pipeline.ts @@ -237,7 +237,17 @@ export async function* createDisplayPipeline( // ── Dispatch by type ── switch (msg.type) { case 'reasoning': { - reasoningBuffer += msg.content || ''; + const chunk = msg.content || ''; + // When a new chunk starts with a markdown block indicator (bold header, + // heading, list item), insert a newline to prevent it running into the + // previous text. This separates complete reasoning blocks (common with + // OpenAI models that emit whole sections) without affecting token-level + // streaming where tokens don't start with these patterns. + if (chunk && reasoningBuffer && !reasoningBuffer.endsWith('\n') + && /^(\*\*|#{1,6}\s|[-*]\s|\d+\.\s)/.test(chunk)) { + reasoningBuffer += '\n'; + } + reasoningBuffer += chunk; break; }