Skip to content

fix(chat): empty assistant bubble for plain LLM responses#290

Merged
blove merged 1 commit into
mainfrom
claude/hotfix-streaming-md-newline
May 13, 2026
Merged

fix(chat): empty assistant bubble for plain LLM responses#290
blove merged 1 commit into
mainfrom
claude/hotfix-streaming-md-newline

Conversation

@blove
Copy link
Copy Markdown
Contributor

@blove blove commented May 13, 2026

Summary

Regression discovered during smoke-checklist walk. Sending any prompt with a plain-text response (no markdown structure) produces an empty assistant bubble. The bubble shell renders (checkpoint marker, action buttons) but the message text is missing entirely.

Reproduction

  1. `localStorage.clear()` + reload `/embed`
  2. Send: `Say hello in one sentence.` (or any prompt that returns plain text)
  3. Backend completes successfully (~2s); `agent.messages()` carries the response
  4. DOM `` has `content="Hello — nice to meet you!"`, `streaming=false`, `finished=true` — but `root.children = []` (empty)

Root cause

`@cacheplane/partial-markdown@0.3.0` does NOT flush trailing text on `finish()` unless the buffer ends with `\n`. Verified with a direct test:

```
"Hello world." → 0 children ❌
"Hello world.\n" → 1 paragraph ✅
"Hello world.\n\n" → 1 paragraph ✅
"# Hello\n\nworld" → 1 heading ✅
```

OpenAI responses for short turns typically omit the trailing newline, so `finish()` returns without committing the in-progress paragraph and the document is empty.

Fix

In `streaming-markdown.component.ts`, push a sentinel `\n` before `finish()` in both branches of the `root()` computed:

  • The content-changed-then-flush branch
  • The streaming-flipped-to-false-without-new-content branch

The newline only exists inside the parser buffer; the `content()` signal stays unchanged.

Tests

  • 2 new regression tests in `streaming-markdown.component.spec.ts` covering plain text without trailing newline in both branches
  • Existing 5 tests still pass
  • `nx test chat` green

Why this regression slipped through

Test plan

  • `nx test chat` green (7 streaming-md tests now)
  • Live verify post-merge: send plain prompt → bubble shows response
  • CI green

@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.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cacheplane Ready Ready Preview, Comment May 13, 2026 4:18am

Request Review

@blove blove merged commit 626202c into main May 13, 2026
14 checks passed
blove added a commit that referenced this pull request May 13, 2026
* docs: add Phase 1 CI testing coverage spec

Defines the input-variance table-test approach for four streaming-render
units (chat-streaming-md, content-classifier, partial-args-bridge, a2ui
parser). Motivated by PR #290 — the empty-assistant-bubble bug shipped
because every chat-streaming-md test used input ending in '\n', and the
'no trailing newline' LLM-response shape was uncovered.

Phase 0 (test-infrastructure audit) and Phase 3 (AIMock E2E + CI wiring)
remain deferred.

* docs: add Phase 1 CI testing coverage implementation plan

Bite-sized TDD-style tasks for the four target units. All test files
new or append-only — no production code changes.

* test(chat): add chat-streaming-md input-variance table

* test(chat): add content-classifier input-variance table

* test(chat): add partial-args-bridge input-variance table

* test(a2ui): add message-parser input-variance table

* docs: amend Phase 1 spec/plan with row corrections learned in flight

- Drop `selectorAbsent: 'p'` from chat-streaming-md 'whitespace only' row
  (markdown-it emits a placeholder <p> for whitespace input; the
  trimmed-text invariant still holds and is the only assertion that
  matters).
- Drop the char-by-char progressive-prefix row from partial-args-bridge
  variance. Partial-json materializes partially-parsed strings as their
  incomplete text, so the bridge's mount-once gate fires with a partial
  id ('r') and never re-targets when the full id ('root') resolves. LLM
  streams are token-chunked, not char-chunked, so this edge case has
  never bitten production. Spec footnote logs it as a latent concern
  for a future phase.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant