From fa7e6e006b458628d6665b480bfc48a03c9cf125 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 21:12:44 -0700 Subject: [PATCH] fix(chat): empty assistant bubble for plain LLM responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cacheplane/partial-markdown@0.3 does not flush trailing text on finish() unless the buffer ends with a newline. Plain LLM responses typically omit the trailing newline, so the parser produced a document with zero children — the assistant bubble shell rendered but the message text was missing entirely. Reproduce on main: send 'Say hello in one sentence.' to gpt-5-mini. Backend logs show success (1.8s); agent.messages() carries 'Hello — nice to meet you!'; the DOM shows the bubble structure (checkpoint marker, action buttons) but no text. Fix: push a sentinel newline before calling finish() in both branches of the root() computed (content-changed-then-flush and streaming-flipped- without-new-content). The newline only ever exists inside the parser buffer; the original content() signal is unchanged. Two new regression tests cover plain text without trailing newline in both branches. Found while walking the smoke checklist post-#288. --- .../streaming-markdown.component.spec.ts | 28 +++++++++++++++++++ .../streaming/streaming-markdown.component.ts | 8 ++++++ 2 files changed, 36 insertions(+) diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts index d26121f6b..394992c44 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts @@ -73,4 +73,32 @@ describe('ChatStreamingMdComponent', () => { expect(fixture.nativeElement.querySelector('p')).toBeNull(); expect(fixture.nativeElement.querySelector('h1')).toBeNull(); }); + + it('renders a paragraph for plain text WITHOUT a trailing newline (LLM-response shape)', () => { + // Regression: @cacheplane/partial-markdown@0.3 does not flush trailing + // text on finish() unless the buffer ends with '\n'. LLM responses + // typically omit the trailing newline. The component must push a + // sentinel newline before finish() so the message renders. + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('Hello — nice to meet you!'); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + const p = fixture.nativeElement.querySelector('p'); + expect(p).toBeTruthy(); + expect(p.textContent?.trim()).toBe('Hello — nice to meet you!'); + }); + + it('renders plain text when streaming flips to false (mirrored else-branch)', () => { + // The else-if branch (no content change, streaming flipped to false) + // must also push a sentinel newline before finish(). + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.content.set('Plain answer.'); + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + const p = fixture.nativeElement.querySelector('p'); + expect(p).toBeTruthy(); + expect(p.textContent?.trim()).toBe('Plain answer.'); + }); }); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index 08c091595..c8a0ff073 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -99,12 +99,20 @@ export class ChatStreamingMdComponent { if (c.length > 0) this.parser.push(c); } if (!isStreaming && !this.finished) { + // @cacheplane/partial-markdown@0.3 does not flush trailing text on + // finish() unless the buffer ends with a newline. Plain LLM + // responses often omit the trailing newline, which causes the + // parser to emit a document with zero children — i.e. the message + // renders empty. Push a sentinel newline first to force the open + // paragraph closed before we finalize. + if (!c.endsWith('\n')) this.parser.push('\n'); this.parser.finish(); this.finished = true; } this.prior = c; } else if (!isStreaming && !this.finished) { // Streaming flipped to false without new content; ensure parser is finalized. + if (!this.prior.endsWith('\n')) this.parser.push('\n'); this.parser.finish(); this.finished = true; }