Skip to content

Smooth client-side pacing for streamed chat responses - #411

Draft
cpsievert wants to merge 13 commits into
mainfrom
worktree-streaming-smoothing
Draft

cpsievert wants to merge 13 commits into
mainfrom
worktree-streaming-smoothing

Conversation

@cpsievert

Copy link
Copy Markdown
Collaborator

Summary

Streamed responses in Chat and MarkdownStream today render exactly as fast as chunks arrive over the wire — neither chatlas nor ellmer pace their generators, so the visual rhythm of a response is entirely at the mercy of the model provider's SSE chunking behavior. Some providers trickle steadily, others burst large chunks irregularly, which makes streaming feel inconsistent from provider to provider.

This adds a client-side-only pacing layer (StreamSmoother) that smooths that out: text now drains onto the screen at a steady, readable pace regardless of how bursty the underlying chunks are, while still never introducing a perceptible delay for short, single-shot responses. No changes to Python, R, chatlas, or ellmer — the wire protocol is untouched, and pacing parameters are fixed sane defaults (not app-configurable) for this first iteration.

What reviewers should look at

  • js/src/streaming/StreamSmoother.ts — the framework-agnostic pacing class (buffer + adaptive drain loop, word-boundary-safe cuts, flush/dispose semantics, congestion backoff).
  • Its two integration points, wired independently since Chat and MarkdownStream have separate message-handling paths: js/src/chat/ChatApp.tsx and js/src/markdown-stream/markdown-stream-entry.ts.

Test plan

  • Full JS suite passes (1378 tests, 0 failures).
  • tsc -noEmit and eslint clean.
  • Manual smoke test against a synthetic bursty generator: text paces visibly, thinking blocks and raw-HTML islands still render correctly mid-stream, and short single-chunk replies still appear promptly with no artificial delay.
  • Dist assets rebuilt (make update-dist) and committed for both pkg-py and pkg-r.

- Adjust snapToWordBoundary loop to check budget-bounded indices only
- Add logic to distinguish: whitespace within budget, beyond budget, or nowhere
- Return 0 when whitespace exists beyond budget to wait for larger budget
- Return maxLen only when no whitespace exists anywhere (guarantee progress)
- Add drainFor check to break when snapToWordBoundary returns 0
When snapToWordBoundary returns 0 (word boundary exists beyond budget),
the previous tick's elapsed time was discarded, creating a fixed-point
where budget never grew and long unbroken tokens stalled indefinitely.

Now accumulate elapsed time across ticks where no progress is made,
so the budget grows monotonically until reaching the word boundary
or running out of whitespace entirely.

Fixes stalls on long URLs, code tokens, and unbroken CJK runs.
Removes vitest import and vi.isFakeTimers() detection from production code.
Implements plain buffering of all chunks through StreamSmoother as specified
in the brief, with no environment-conditional logic.
Updated the 'streaming dot appears during streaming and disappears after
chunk_end' test to use fake timers and advance past one pacing tick before
the first assertion. This accounts for the ~50ms delay introduced by
StreamSmoother pacing, which prevents the dot from rendering until the
smoother's timer fires and emits the buffered content.
npm test doesn't run tsc/eslint, so noUncheckedIndexedAccess violations and
formatting issues in Task 1's files went unnoticed until `make update-dist`
ran the real build/lint pipeline.

This branch has not been deployed

No deployments
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