Skip to content

perf(chat): render a streaming answer block by block instead of re-parsing the whole message per token - #4422

Merged
benjaminshafii merged 4 commits into
devfrom
perf/streaming-markdown-blocks
Sep 4, 2026
Merged

benjaminshafii merged 4 commits into
devfrom
perf/streaming-markdown-blocks

Conversation

@reachjalil

Copy link
Copy Markdown
Member

Summary

  • While an assistant answer streams, the chat re-lexed, re-parsed, and re-sanitized the entire message on every delta flush and replaced its whole DOM subtree. Each frame cost as much as the text so far, so long answers dropped frames while they streamed.
  • MarkdownBlock now renders a streaming answer block by block: it keeps the previous frame's top-level marked tokens, re-lexes only the appended tail, and renders one dangerouslySetInnerHTML payload per block. A settled block keeps the same payload object, so React leaves its DOM alone and only the growing block repaints.
  • Once the run settles, the message renders as one document exactly as history does (unchanged path, including the Shiki upgrade).

Why

Measured on a 24-section streamed answer (12 KB markdown → 98 KB HTML) fed in 40-character deltas, parser + sanitizer only (no DOM work):

answer size frames before, avg / frame after, avg / frame before, last frame after, last frame
4 KB 102 3.0 ms 0.47 ms 5.2 ms 0.43 ms
12 KB 308 8.9 ms 0.39 ms 18.2 ms 0.41 ms
37 KB 928 37.4 ms 0.38 ms 92.7 ms 0.50 ms

Per-frame cost is now flat regardless of answer length. On top of that, the old path also re-parsed the full HTML string into a fresh DOM subtree every frame (images reloaded, code blocks lost scroll position, layout invalidated for the whole message); the new path only touches the DOM of the block that changed.

Issue

  • N/A

Scope

  • apps/app/src/components/markdown/markdown-primitive.ts: createStreamingMarkdownRenderer() — incremental lexing of an appended source (re-lexes the last two blocks so lazy list continuation, setext underlines, and table delimiter rows still reshape their predecessor), per-block parse + sanitize, stable payload identity for unchanged blocks, full re-lex fallback for replaced text and for any source containing reference-style link definitions (those resolve across blocks).
  • apps/app/src/components/markdown/markdown.tsx: use the block renderer while streaming, keep the whole-document render for settled text and history; useSelectionStableValue and the text-highlight / code-wrap / mermaid effects key off the committed render instead of an HTML string.
  • apps/app/src/components/markdown/mermaid.ts: useMermaidEnhancer takes the rendered content identity as its dependency (was an HTML string; the only use was as a dependency key).
  • evals/specs/streamed-markdown-answer.e2e.test.ts + evals/worlds/chat.ts streamedMarkdown: the journey.
  • scripts/mock-oauth-mcp-server.mjs + evals/packages/labs/src/mock-mcp.ts: an agent workload may deliver its final reply as fixed-size content deltas (finalReplyChunkSize) and may have no tool steps; both additive.

Out of scope

  • The artifact-preview / extension-modal MarkdownBlock (react-app/domains/session/surface/markdown.tsx) does not stream and is unchanged.
  • The Shiki-highlighted settled render is unchanged.

Testing

Ran

  • OPENWORK_EVAL_REF=2f1a7f156a9127c7917a3f9496f626f3a5847b36 pnpm evals:e2e streamed-markdown-answer — placement: daytona (daytona CLI authenticated), checkout resolved 2f1a7f156a91
  • pnpm --dir apps/app exec bun test --isolate tests/markdown-streaming-blocks.test.ts tests/markdown-selection-stability.test.ts tests/markdown-code-block.test.ts tests/markdown-mermaid.test.ts tests/markdown-math.test.ts
  • pnpm --dir apps/app typecheck
  • node evals/scripts/spec-boundary-ratchet.mjs, node evals/scripts/spec-channel-ratchet.mjs
  • node --test scripts/mock-oauth-mcp-server.test.mjs

Result

  • Journey spec on Daytona at the exact PR head: ✓ a streaming answer renders as markdown block by block and settles to the same document — 1 passed, 0 failed, 0 skipped, exit 0, verdict passed. Steps: finished blocks render as markdown while later blocks are still arriving (1.4 s); the settled answer shows every block exactly once and no markdown syntax (7.7 s); history renders the same document after a reload (2.0 s). Evidence is published in the test-evidence comment.
  • Unit: 39 pass across the five markdown files. markdown-streaming-blocks.test.ts proves every streamed frame (1-, 7-, and 40-character chunks) renders byte-identical HTML to the whole-document renderer over a document with setext headings, lazy and loose lists, nested lists, a table, fences and HTML blocks containing blank lines, math, and a blockquote; settled blocks keep payload identity; a growing tail can reshape its predecessor; reference definitions resolve on every frame; replaced text and CRLF input fall back to a full render that still reuses unchanged blocks; reset() drops retained state.
  • pnpm --dir apps/app typecheck exit 0. Both spec ratchets green (75 specs / 50 e2e specs). Mock server tests pass. evals tsc -p . has pre-existing errors in unrelated files and none in the files touched here.

CI status

  • pass: pending on this PR
  • code-related failures: none known
  • external/env/auth blockers: none known

Manual verification

  1. Start a task whose answer is long (several headings, lists, and code blocks) and watch it stream: earlier blocks stay put and only the block being written repaints.
  2. Select text in an earlier block while the answer is still streaming: the selection survives.
  3. Let the answer finish, then reload: the settled transcript renders the same document.

Evidence

  • Test-evidence comment on this PR (Daytona run at 2f1a7f156).

Risk

  • Streaming render and settled render are produced by two paths. The unit test pins them byte-identical over an adversarial document at three chunk sizes, and any source with reference-style link definitions always takes the whole-document path. The switch from blocks to document at settle is a keyed remount, so the DOM is rebuilt once per streamed message at the end of the run (previously it was rebuilt on every frame).
  • Per-block wrapper <div>s exist only while streaming. No stylesheet or spec uses a child combinator on .markdown-content; the DOM helpers (applyTextHighlights, code-wrap sync, mermaid) query from the root.

Rollback

  • Revert the two commits; no data or config migration is involved.

…rsing the whole message per token

Every streamed token re-lexed, re-parsed, and re-sanitized the entire
assistant message and then replaced its whole DOM subtree, so each frame
cost as much as the text so far (about 9 ms per frame at 12 KB and 90 ms
at 37 KB in the parser and sanitizer alone) and long answers dropped
frames while they streamed.

While a message streams, MarkdownBlock now keeps the previous frame's
top-level tokens, re-lexes only the appended tail, and renders one
dangerouslySetInnerHTML payload per block. A settled block keeps its
payload object, so React leaves its DOM alone and only the growing block
repaints; per-frame cost stays flat (about 0.4 ms) regardless of length.
Once the run settles the message renders as one document, exactly as
history does, and a source with reference-style link definitions always
takes the full render because those resolve across blocks.

Journey: evals/specs/streamed-markdown-answer.e2e.test.ts streams a
multi-block answer through the app and asserts that finished blocks
render as markdown while later blocks are still arriving, that every
block is on screen exactly once when the answer settles, and that a
reload renders the same document.
…nt mock

The streamed-markdown world served its answer from a loopback HTTP server
on the test host, which the engine cannot reach once the desktop runs on
Daytona. Boot the shared agent mock through the placement instead, and let
a workload deliver its final reply as fixed-size content deltas with no
tool steps, so the answer streams block by block on either lane.
@vercel

vercel Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

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

4 Skipped Deployments
Project Deployment Actions Updated
openwork-app Ignored Ignored Preview Sep 4, 2026 8:53pm UTC
openwork-den Ignored Ignored Preview Sep 4, 2026 8:53pm UTC
openwork-diagnostics Ignored Ignored Preview Sep 4, 2026 8:53pm UTC
openwork-landing Ignored Ignored Preview Sep 4, 2026 8:53pm UTC

Comment thread evals/specs/streamed-markdown-answer.e2e.test.ts Outdated
diff-warden[bot]
diff-warden Bot previously approved these changes Sep 4, 2026

@diff-warden diff-warden Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warden security clearance: clear. No blocking findings (security or desktop↔den sync) in this diff (2f1a7f156a9127c7917a3f9496f626f3a5847b36). Automated clearance satisfies the required-review gate only — a human still reviews and merges. Analysis run

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Deploying openwork with  Cloudflare Pages  Cloudflare Pages

Latest commit: fdb0133
Status: ✅  Deploy successful!
Preview URL: https://6a3b1eb0.openwork.pages.dev
Branch Preview URL: https://perf-streaming-markdown-bloc.openwork.pages.dev

View logs

Comment thread evals/specs/streamed-markdown-answer.e2e.test.ts

@diff-warden diff-warden Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warden security clearance: clear. No blocking findings (security or desktop↔den sync) in this diff (4ed6af240f55181475bb4a63aebb3076319c883a). Automated clearance satisfies the required-review gate only — a human still reviews and merges. Analysis run

@different-ai different-ai deleted a comment from reachjalil Sep 4, 2026
@benjaminshafii

Copy link
Copy Markdown
Member

Test evidence — a-streaming-answer-renders-as-markdown-block-by-block-and-settles-to-the-same-do — ✅ passed

✅ passed · 0 expectations passed · 0 failed · 0 pending

[world] mock(mcp) · den(daytona) · desktop(as admin) · workspace(/tmp/openwork-streamed-markdown-answer-1788555542549) · [seed:raw] evalIn() ×3 · session("New task")
[user] type(composer, "Write the streamed markdown answer. STREAM_MARKDOWN_ANSWER") · click(Run task) · see(text=Write the streamed markdown answer. STREAM_MARKDOWN_ANSWER, timeoutMs=30000) · see(text=Streamed answer heading, timeoutMs=90000) · notSee(text=Closing paragraph epsilon.) · notSee(text=## Streamed) · see(text=Closing paragraph epsilon., timeoutMs=120000) · see(Run task, timeoutMs=60000)
[probe] text
[user] see(text=alpha list item) · see(text=const streamed = "delta";) · notSee(text=/Something went wrong/) · reload · see(text=Closing paragraph epsilon., timeoutMs=120000)
[probe] text
[user] see(text=Streamed answer heading)
steps 1 ✅ finished blocks render as markdown while later blocks are still arriving (7.3s) · 2 ✅ the settled answer shows every block exactly once and no markdown syntax (3.8s) · 3 ✅ history renders the same document after a reload (2.0s)
verdict passed · 11 user observations (see ×8, notSee ×3) · 2 probes · steps 3/3


Test run created 2026-09-04T20:55:22.042Z · Source: evals/results/test-runs/2026-09-04T20-55-22-042Z-a-streaming-answer-renders-as-markdown-block-by-block-and-settles-to-the-same-do/test-run.json · Repro: pnpm --dir evals artifacts:publish -- --pr 4422 --test-run 2026-09-04T20-55-22-042Z-a-streaming-answer-renders-as-markdown-block-by-block-and-settles-to-the-same-do

@benjaminshafii benjaminshafii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed incremental parsing/sanitization and the conflict resolution retaining both quiet-completion and chunked-answer mock behavior. The requested mid-stream assertions now use user.see/user.notSee. The final-commit Daytona journey passed (1 test, 0 failures, 0 skips), and matching evidence is published. Required CI checks pass.

@benjaminshafii
benjaminshafii merged commit a017a83 into dev Sep 4, 2026
27 checks passed
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.

2 participants