Studio: stop the chat UI falling behind a fast stream by oobabooga · Pull Request #8845 · unslothai/unsloth · GitHub
Skip to content

Studio: stop the chat UI falling behind a fast stream - #8845

Merged
danielhanchen merged 21 commits into
unslothai:mainfrom
oobabooga:worktree-studio-stream-ui-lag
Aug 16, 2026
Merged

Studio: stop the chat UI falling behind a fast stream#8845
danielhanchen merged 21 commits into
unslothai:mainfrom
oobabooga:worktree-studio-stream-ui-lag

Conversation

@oobabooga

@oobabooga oobabooga commented Aug 14, 2026

Copy link
Copy Markdown
Member

This PR coalesces streamed text chunks that arrive before the browser's next frame. Slower streams still publish every chunk. When the renderer falls behind, more chunks are combined into each update instead of queueing separate message rebuilds.

Without this, a fast local reply can keep filling the chat bubble for several seconds after llama.cpp has finished and GPU usage has dropped to 0%. If the UI falls far enough behind, the page stops painting and Stop cannot run promptly.

Studio currently rebuilds and publishes the full accumulated message for every text chunk. With this change, the first chunk publishes immediately, later chunks accumulate until the next frame, and the next publish includes all of them. The final update remains unconditional, so completed replies persist in full. A 500 ms timer reopens the gate when a hidden or heavily loaded window does not produce a frame.

The gate sits immediately before the yield to assistant-ui, after the message rebuild and the reasoning bookkeeping. Those still run on every arrival, so nothing about how the stream is interpreted depends on which chunks happen to publish. The cost being removed is downstream of the yield: assistant-ui, React, markdown rendering and paint. Keeping the rebuild on every chunk measures 34 to 41 ms in total across a 64,000 character reply, and a per-engine comparison of the two placements retains 99.5% to 100% of the improvement.

Per-delta tool-call previews are paced the same way, since they repeat per argument fragment and are replaced by the authoritative parse. Anything state-bearing publishes immediately: tool_start and tool_end, a newly introduced tool call, and replay metadata, including a per-call extra_content change, which is where Gemini puts the thought signature.

Measurements

Ranges come from alternating Chromium production-build runs on the same machine. The synthetic stream sent the same 60,000 characters at 800 chunks per second to both branches. Real-model runs used Qwen3.5-0.8B-MTP at about 440 tok/s.

Workload CPU throttle Main painted at socket close This PR painted at socket close Main lag after close This PR lag after close
Synthetic 8x 1.2% to 1.6% 98.1% to 98.5% 11.4s to 11.8s 1.38s to 1.44s
Synthetic 12x 2.8% to 3.5% 96.2% to 96.9% 26.9s to 28.7s 2.30s to 2.57s
Qwen3.5-0.8B-MTP 8x 3.8% to 11.4% 94.1% to 95.3% 2.08s to 2.76s 0.63s to 0.77s
Qwen3.5-0.8B-MTP 12x 0.0% 86.5% to 91.8% 5.68s to 5.70s 0.96s to 0.98s

In a 40,000-character stream at 6x throttling, a scheduled Stop click ran after 12.95s on main and 0.74s with this PR.

Limitation

Reopening the gate does not publish by itself. If Stop is pressed before another chunk or the final update, text received during the current gate interval can be omitted. That loss is bounded: the gate publishes regardless once 256 characters have arrived since the last publish, measured in UTF-16 code units, so it is conservative in glyphs. Normal completion and non-abort failure keep the full reply.

The controlled Chromium benchmark needed CPU throttling to reproduce the backlog consistently. The original issue was observed during real llama.cpp generation on this machine. The same backlog and improvement reproduced in CPU-constrained webkit2gtk. Hidden-window testing also confirmed that the 500 ms fallback limits updates when animation frames stop.

Checks

  • npm test: 2,415 passed, including 26 gate tests
  • npm run typecheck
  • npm run build
  • Synthetic-stream, real llama.cpp, and webkit2gtk constrained and hidden-window tests

@mahiatlinux

Copy link
Copy Markdown
Collaborator

I went through this against main and the problem is real. liveAssistantContent() calls parseAssistantContent, which re-slices the whole accumulated reply on every chunk (studio/frontend/src/features/chat/utils/parse-assistant-content.ts:107-137), so publishing per chunk is quadratic in the reply length. The render-layer coalescer you added in #8750 (markdown-text.tsx:401) sits downstream of that and cannot remove it, so this is not redundant with that work. The measurements look consistent with what the code does.

Three things I think need addressing before this merges.

1. Stop now truncates the saved reply

This is the one I would prioritise. The PR describes it as a limitation, but it costs more than the description implies.

chat-adapter.ts:4518-4520 states the invariant this gate breaks:

// Every streamed yield carries the repaired text, not just the terminal ones:
// assistant-ui drops whatever is yielded after an abort, so on Stop the last
// STREAMED yield is what gets saved.

Same thing again at thread.tsx:5597 ("the adapter yields nothing after an abort"), and the abort branch confirms it: chat-adapter.ts:6337 guards the partial yield with if (!abortSignal.aborted), so a real Stop publishes nothing. Before this PR every chunk yielded, so Stop kept everything received. Now whatever arrived since the last open frame is gone.

Two details that make it worse than "one frame":

The window is up to 500 ms, not ~16 ms. The gate reopens on requestAnimationFrame or the UNPAINTED_REOPEN_MS timer. When the main thread is starved, rAF is late and the timer is what reopens, so the closed window stretches toward 500 ms. That is exactly the condition this PR targets, so the loss is largest precisely when the gate is doing the most work. At the 440 tok/s you measured that is roughly a paragraph.

The lost text is unrecoverable, not just unpainted. Autosave exports published runtime state at thread.runEnd (runtime-provider.tsx:1849, :1909), so the truncated message is what gets written to the backend and survives a reload. Continue then prefills from message.content (thread.tsx:5571, passed at :5635), so a resumed turn restarts from the truncated point, potentially mid-word.

Worth noting that both coalescers already in this codebase are trailing-edge and do not have this problem:

  • markdown-text.tsx:433-436 schedules setDisplayed(pendingRef.current) on the frame, so the withheld tail always lands.
  • research-run-store.ts:762, :921-925 arms a flush timer and flushes on any non-coalescable event.

createFrameGate returns a boolean, so it structurally cannot flush anything. Two ways out:

  • Give the gate ownership of the publish so the reopen callback can emit the pending text (trailing edge, matching useCoalescedStreamingText). Bigger change, since you cannot yield from a timer callback inside an async generator, so it likely means moving the pacing to createPersistedRunAdapter (runtime-provider.tsx:1114-1115) where the yields pass through.
  • Or keep the leading-edge gate and add a length escape at chat-adapter.ts:6104, e.g. track lastPublishedLength and publish anyway once cumulativeText.length - lastPublishedLength exceeds some N. That bounds the Stop loss in characters instead of in wall-clock time, and keeps almost all of the coalescing win.

2. The integration test passes with the gate removed

chat-stream-publish-gate.test.ts:145-171 reads chat-adapter.ts as text and asserts on indexOf positions. I copied the two source files and the test into a scratch tree and mutated the copy. Both of these leave it green at 8/8:

Mutation A, comment out the entire gate. The PR is effectively reverted, and the test does not notice, because indexOf matches the text inside the comment:

              // if (!canPublish()) {
              //   continue;
              // }
ℹ tests 8
ℹ pass 8
ℹ fail 0

Mutation B, move createFrameGate() inside the loop. A fresh gate per chunk means painted is always true, so there is zero coalescing:

            for await (const chunk of stream) {
              const canPublish = createFrameGate();
ℹ tests 8
ℹ pass 8
ℹ fail 0

Mutation B is the one the test believes it covers: its own assertion message says "the run creates one gate per request", but loopStart points at line 5329, the construction site, not at the for await, so nothing anchors the gate to the loop.

Source-grepping is fine and well established here (68 of 294 files in studio/frontend/tests/ use readFileSync), so this is about tightening this instance rather than changing the approach. chat-autoscroll-frame-budget.test.ts:26-30 already shows the shape: slice the region first, then assert inside the slice. Slicing the for await (const chunk of stream) { body and stripping comments kills both mutations, and asserting the construction is outside that slice kills B specifically.

For what it is worth, the other 7 unit tests are good. The quiet-tail case at :47-74 and the late-frame-after-timer case at :133-143 in particular are exactly the right things to pin.

3. Only one of four publish sites in the loop is gated

All four of these yield liveAssistantContent(), so they all pay the same full rebuild:

Line Branch Gated
chat-adapter.ts:5494 tool_args, one frame per argument delta no
chat-adapter.ts:5824 tool_start / tool_end no
chat-adapter.ts:6057 delta.tool_calls fragment no
chat-adapter.ts:6140 plain text yes

"Tool-call updates are unchanged" is accurate, but it means the fix does not reach a tool-calling turn. tool_args is emitted per argument delta while the model writes the call, and the comment at :5467-5470 says why ("the card shows the code live"), so a long code_interpreter payload streams into the same hot path at the same rate as on main. Given your numbers for main under throttling, that turn stays in the old regime.

Gating in createPersistedRunAdapter would cover all four sites in one place, and it is also the natural home for the trailing-edge flush from point 1.

Smaller things

  • stream-pacing.ts:23-24 schedules both a frame and a timer and cancels neither. The woken latch makes this correct and pending depth is bounded at roughly 30, so it is not a leak, but the same generator clears warmupTimer in its finally (chat-adapter.ts:6399) and useCoalescedStreamingText calls cancelAnimationFrame (markdown-text.tsx:411-416). Cancelling the loser once one fires would match what is around it.
  • The schedule parameter has no production caller. chat-adapter.ts:5329 is the only call site and it uses the default. Three of the eight tests already stub globalThis.requestAnimationFrame and globalThis.setTimeout directly (chat-stream-publish-gate.test.ts:87-106) and exercise the default path, so the injection point is not buying coverage the tests cannot get otherwise. Dropping it takes the factory down to about ten lines.
  • painted reads as a stronger claim than the code makes. rAF callbacks run before paint, and the publish they enable happens on a later chunk and commits some frames after that. "The gate is open" is what it tracks.
  • One chunk-level value is read after the gate: structuredReasoningContinues (chat-adapter.ts:6133, destructured at :5879-5882), so a skipped chunk's value is dropped. It only gates finishGroup(), and measure() recomputes from startedAt[index] with later finishes overwriting earlier ones, so it does not change the result today. Might be worth a line at :6133 so the next person does not have to re-derive that.

Things I checked that are fine

  • Everything the continue skips other than the yield is safe. TTFT (:6073-6078), totalChunks (:5850), the finish_reason latch (:5853-5857), chunk.usage (:5841-5846), the whole tool-call accumulator, and all five cumulativeText writers are pre-gate.
  • The reasoning-duration tracker calls at :6110-6137 are skipped, but the drift is bounded by the gate interval and measure() rounds to whole seconds (reasoning-duration.ts:128). The back-fill-with-zero path at :155-160 can only mis-attribute a group that lived under 500 ms, which rounds to zero anyway. Not an issue.
  • Normal completion (:6265) and non-abort failure (:6350) both publish the full cumulativeText, so the PR's claim there holds.
  • requestAnimationFrame availability is not a concern. No SSR or worker path reaches this module, tsconfig.app.json:6 includes DOM, and bare unqualified rAF is already used elsewhere (adapters/dictation-level.ts:82).
  • npm test on the new file: 8 passed. tsc -p tsconfig.test.json clean.

@oobabooga

Copy link
Copy Markdown
Member Author

Thanks for the detailed review. Points 1 and 2 are real and I took both; point 3 in part. It is all in e555607.

1. Stop truncating the saved reply

Confirmed, and the mechanism is stricter than the comments you quote imply. I checked it at the library level rather than reading it off them. @assistant-ui/core's local runtime consumes the run like this (dist/runtimes/local/local-thread-runtime-core.js:324):

for await (const r of promiseOrGenerator) {
    if (abortSignal.aborted) {
        updateMessage({ status: { type: "incomplete", reason: "cancelled" } });
        break;
    }
    updateMessage(r);
}

The consumer drops the value. So the if (!abortSignal.aborted) guard at chat-adapter.ts:6337 is not what makes post-abort text unrecoverable: nothing yielded after a Stop can reach the message, whoever yields it.

That rules out both of the ways out you sketched, which is why I did not take the restructure.

  • Moving the pacing into createPersistedRunAdapter does not change it. An async generator cannot yield from a timer callback, and even if it could, the loop above discards the value.
  • A trailing edge fixes the quiet-tail case but not this one. Under a fast stream the next chunk arrives right after the gate reopens, so leading and trailing edge leave the same text unpublished: one gate interval of stream. That interval is exactly what stretches toward 500 ms when rAF is starved.

So the loss can be bounded, not removed. I bounded it in characters rather than in time, because characters are what is at risk and the bound then does not move with the stream rate: MAX_HELD_CHARS = 256, published regardless once that much has arrived since the last publish. A stop now discards under 256 characters in every case, including a stream that stalls without ending, instead of whatever a 500 ms unpainted interval produced. At this model's rate 256 characters is roughly 150 ms, so the cap only binds once frames are already several times slower than 60 Hz, which is the regime where the loss was largest.

Measured with Chromium against a local Studio running unsloth/Qwen3.5-0.8B-MTP-GGUF Q4_K_M at 12x CPU throttling, on three builds of the frontend: the merge-base, the PR head, and the PR head plus this commit. The received text is teed off the response body inside the page, so what arrived is known independently; the saved text is read back from the message after the run ends, both whitespace-normalised. A Stop click is scheduled from the page 900 ms into the reply. Three runs each, in run order:

build Stop click ran late by received but not saved
merge-base 16.1s, 8.1s, 5.5s 10, 5, 0
PR head 0.09s, 0.90s, 0.12s 56, 419, 589
PR head + this commit 0.47s, 0.95s, 0.60s 211, 125, 56

Two things to read carefully there. On the merge-base the click never landed during the reply at all: the timer carrying it is starved with everything else, so it fired only after the stream had already closed, which is why that row's last column is just the whitespace-normalisation floor and not a Stop measurement. And on the PR head the loss is real and much larger than one frame, 589 characters in the worst run, which is your point.

Two runs per build were left to finish normally instead of being stopped. Those dropped 0 to 7 characters out of 5.9k to 10.5k, so the unconditional final update still carries everything on a normal end.

2. The integration test

Reproduced both mutations in a scratch tree before touching anything: 8/8 green with the gate commented out, 8/8 green with createFrameGate() moved inside the loop.

Rewritten the way you suggest, in the chat-autoscroll-frame-budget.test.ts shape: slice the for await (const chunk of stream) body, strip comments (keeping a // that sits inside a string literal), assert inside the slice, and assert the construction sits outside it. Where the file stands against mutation now:

mutation before after
comment out the text gate 8/8 pass 1 fail
build the gate inside the loop 8/8 pass 1 fail
drop the tool_args gate n/a 1 fail
drop the held-text cap n/a 2 fail
never close the gate n/a 7 fail
leave the losing scheduler pending n/a 1 fail
drop the per-cycle latch n/a 1 fail

The schedule parameter is gone. Every gate test now stubs the real globals, so the default path is the only one under test.

3. Publish sites

I took tool_args. It is the one in your code_interpreter example, and it is purely a preview: parseLiveToolArgs feeds a partial parse into the part and tool_start overwrites args and argsText with the authoritative one, so a skipped publish there cannot lose anything.

I left the other two ungated on purpose.

  • tool_start and tool_end (:5824) are two events per call, so gating buys no coalescing, and they carry the card's running and done state.
  • delta.tool_calls (:6057) mutates the tool-call part list rather than previewing it. A skipped publish there can leave an aborted turn without a call that the merge-base would have kept, which is point 1 again in a different shape.

So the rule is to gate the preview rebuilds and never the publishes that carry state, with the reason at the call site.

Smaller things

All four taken.

  • Whichever of the frame and the timer fires now cancels the other, and the latch is per cycle so a callback left over from a spent cycle cannot reopen the next one. Both have a test.
  • painted is now open, which is what it tracks.
  • One line at the structuredReasoningContinues read. Your reading of it is right: every finish re-measures from startedAt[index], so the final one lands the correct duration either way.

npm test is 2402 passing, typecheck and build clean.

One consequence for the description: its Limitation paragraph is out of date, since what a stop can omit is now bounded at under 256 characters rather than a whole gate interval.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 60e54dde32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Review found the gate covered the text path and the tool_args preview but
not the OpenAI delta.tool_calls path, which streams function arguments in
many small fragments and rebuilt the whole message for each one. A tool
turn therefore stayed in the old regime.

Gate it the same way, with one exception: a fragment that introduces a
tool call always publishes. Only fragments extending an existing call's
arguments are coalesced, so an aborted turn still cannot lose a call the
merge base would have kept, which is why this path was left ungated.
Both branches feed streamedChars, so the cap covers a turn that only
streams arguments.

Two smaller gaps:

- A chunk the external ${...} strip empties has nothing to show, and the
  yield below is skipped for it anyway, but it was still consuming the
  gate's publish and holding the next real chunk. Check for that before
  consulting the gate.
- The reasoning-group adoption ran only on the success path. The
  non-abort failure path yields its own partial with its own metadata(),
  so an interrupted reply could render a reasoning group with no duration
  entry behind it. Both terminal publishes now share one helper.

Also initialise the frame and timer handles before reopen closes over
them; a scheduler that calls back synchronously otherwise threw out of
the stream loop, and reword UNPAINTED_REOPEN_MS, which is a scheduled
fallback rather than a deadline: a hidden page throttles that timer to
>=1s and pauses frames, so there the arrival cap is what paces publishes.

Tests: the suite's adapter assertions were passing against mutations they
were meant to catch. regionOf now bounds the slice it takes, so editing
an anchor's line cannot silently slide the region hundreds of lines down
and leave the ordering assertions passing against the wrong code. Added
behavioural coverage for the cap resetting its baseline on a cap-forced
publish, a synchronous scheduler, and the new tool-call gating. A
23-mutation matrix over the gate and its call sites now kills all 23; two
of those survived before, including one that silently restores unbounded
stop loss on a plain-text reply.

npm test 2411 passed, typecheck, build, eslint and biome clean on the
changed files. Gate behaviour verified in Chromium, Firefox and WebKit,
which covers Tauri's WebView2, WKWebView and WebKitGTK.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Codex is right, and it reproduces. The gate can only parse a reasoning
group out of the text on a publishing chunk, so a pass shorter than
MAX_HELD_CHARS in a window that produces no frames is first seen at the
terminal adoption, which started and finished it in the same instant and
persisted 0s. Measured against the merge base with a controlled clock: a
200 character pass spanning 30s recorded 10s before this PR and 0s after
it. Once a pass exceeds the cap the forced publish starts it on time,
which is why only short passes were affected.

Providers that send reasoning in its own delta field were never affected:
that path calls startGroup before the gate. This is the parse-driven
fallback for providers that inline <think> in delta.content.

Record when the held text arrived and hand that to startGroup, which now
takes an optional firstSeenAt and measures from it. Both terminal
adoptions pass whatever is still held. All five cases now match the merge
base exactly, including the 30s one.

npm test 2413 passed, typecheck, build and eslint clean.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Five follow-ons from the arrival-time fix, four of which it caused.

The worst is mine: backdating the whole discovery handed every skipped
index the arrival time as well, so three groups revealed by one publish
after a 30s hold each measured 30s and overlapped, against the invariant
that a group revealed and closed inside one discovery measures zero.
Measured [30,30,30], now [0,0,30]. Only the group left open takes the
arrival time.

The stamp was also taken on any held chunk, so a prose delta arriving
before the reasoning backdated the group to before it began. It is now
taken only when the held chunk carries reasoning.

finishGroup reads nothing the gate withholds, so it ran on the wrong
side of it: a pause after the reasoning ended was counted as reasoning
until the next publish. Closing a group now happens on every arrival and
only the start, which needs the parsed content, stays gated.

The forced tool-call publish could be the first to expose a group the
gate was holding, yielding content whose durations knew nothing about it;
it reconciles before yielding now.

Last one is the rule this PR already states, applied where it was
missing: a Gemini thought signature and a Codex reasoning ledger reach
the message only through a yield, so holding one behind the gate loses it
on Stop and the next turn replays without it. That is state, not a
preview, so it forces a publish like a new tool call does.

Three regression tests, and the adapter greps are regexes now so an extra
condition on the gate line does not read as the gate having gone.

npm test 2416 passed, typecheck and build clean.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 15, 2026
Both follow from the previous commit and both are real.

The forced publish for replay state could never fire for the case it was
added for. A chunk whose only payload is extra_content has no delta and
no reasoning, so `if (!delta && !reasoning) continue` returned before the
gate was ever consulted. That shape is not hypothetical: the Gemini
translator has a test for it, test_empty_text_part_with_thought_signature
_emits_extra_content, whose docstring says Gemini 3 ships a content-free
fragment whose only payload is thoughtSignature, and the Codex client
builds a delta of {} plus extra_content on response.completed, so its
reasoning ledger arrives on a text-free terminal delta. Handled before
the empty-content skip now, and the tool-call publish forces on replay
state too.

The forced tool-call publish also only started an adopted group. Left
open it yields with no duration entry behind it and keeps timing across
the tool execution, so a Stop during the tool run persists exactly the
inconsistent metadata the adoption was meant to prevent. Start, resume
and finish now live in one reconcileReasoning used by both publish
paths, which is also why the normal path shrank.

Two regression tests. The adapter greps stay regexes so an added forcing
condition does not read as the gate having gone.

npm test 2418 passed, typecheck and build clean. The earlier
reproductions still hold: the 30s gated pass measures 10s, and three
groups revealed by one publish stay [0,0,30].
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from oobabooga Aug 15, 2026
@unslothai unslothai deleted a comment from oobabooga Aug 15, 2026
@unslothai unslothai deleted a comment from oobabooga Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 15, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

A later reasoning pass can open with a bare "<think>" in its own chunk. It
parses to no parts, but an EARLIER pass leaves the group count above zero on
that publish, so the count alone could not tell a pending pass from a
finished one and the stamp was cleared. The body then arrives gated, nothing
restores the stamp, and the pass starts at whatever publish first sees it:

  pass one 1s -> 1s = 0s, pass two 3s -> 30s = 27s
    clearing whenever a group exists : [0,0]
    clearing only once one is active : [0,27]

An active group can only be one the adopt or resume just took the stamp for,
so that is the condition. It sits after the resume that can start the timing
and before the finish that ends it.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@oobabooga

Copy link
Copy Markdown
Member Author

I think we should simplify this before adding more reasoning-specific reconciliation.

The current placement makes the gate skip semantic processing as well as UI publication:

Current design:

accumulate text
gate
build assistant content
update reasoning metadata
yield to assistant-ui

That is why coalescing UI updates has spread into reasoning timing, split <think> tags, server summaries, terminal reconciliation, replay metadata, and every alternate publish path.

The simpler insertion point is:

accumulate text
build assistant content
update reasoning metadata
gate
yield to assistant-ui

This still coalesces the expensive downstream work that causes the visible backlog. Assistant-ui, React, markdown rendering, and browser painting receive fewer cumulative snapshots, while reasoning and replay bookkeeping continue to observe every arrival normally.

The available measurements suggest that retaining the content build on every chunk costs very little compared with the work after the yield. In the synthetic parser benchmark, 14,000 cumulative parses of a reply ending at 64,000 characters took about 55 to 65 ms total on this machine. By comparison, the actual browser backlog measured 11.4 to 11.8 seconds at 8x CPU throttling and 26.9 to 28.7 seconds at 12x. Coalescing publications reduced those backlogs to 1.38 to 1.44 seconds and 2.30 to 2.57 seconds respectively. A real Qwen3.5-0.8B-MTP run also dropped from 5.68 to 5.70 seconds of post-stream lag to 0.96 to 0.98 seconds at 12x throttling.

So we would give up a small extra parser CPU saving, but should retain nearly all of the user-visible improvement with a drastically smaller and easier-to-reason-about change. The final publish should remain unconditional, and state-bearing tool or replay events can still publish immediately. The held-text cap can also remain for Stop behavior.

I recommend moving the gate to immediately before the ordinary assistant-ui yield, removing the reasoning-specific gate timestamps and reconciliation paths, and rerunning the existing browser benchmarks to confirm that the practical improvement is unchanged.

The previous commit rewrote a block of the gate test file and took the
per-call thought signature and unchanged-strip tests with it. Mutation
testing caught it: removing the per-call replay latch left the suite green.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: e2ab2588cf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@danielhanchen

danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

Agreed, and this is the right call. I raised something close to this at the point where the reasoning findings started repeating, then talked myself out of it after a few of the fixes landed cleanly. Your framing is better than mine was, because it identifies the actual mistake: the gate is skipping semantic processing as well as publication, and only the publication needed skipping.

I re-measured the parse cost independently before agreeing, since that is the load-bearing claim. Cumulative parseAssistantContent calls against a reply growing to 64,000 characters, warm, three runs:

plain text    parses=12800  final=64000c  total=371.4 / 33.9 / 35.0 ms
with <think>  parses=12793  final=64000c  total=307.1 / 33.6 / 40.8 ms

So about 34 to 41 ms warm, slightly cheaper than your 55 to 65, with the first run showing JIT warmup rather than real cost. Against 11.4 to 28.7 seconds of backlog that is not a tradeoff worth defending.

What the move deletes, on my count: gateHeldSince, gateReasoningEndedAt, the reconcileReasoning helper and its five call sites, the terminal flag that lifts the unclosed-tag guard, the split <think> tag search at the chunk join, the reconcile before the server summary frame, and the arrival-stamp lifecycle that four separate review rounds went into. Every one of those exists only because the tracker was learning about a group after it arrived. With the build ahead of the gate, it observes every arrival and none of that bookkeeping has anything to do.

Two things I would keep, and I think you are saying the same:

  • state-bearing publishes stay ungated: tool_start / tool_end, a new delta.tool_calls entry, message-level replay metadata, and per-call extra_content. That last one is not in the current PR and matters on its own: Gemini puts the thought signature on the tool call, and a Stop that persists the call without it makes the next turn fail with a 400.
  • MAX_HELD_CHARS, for exactly the Stop reason you give.

I have two fixes from the latest review round sitting unpushed and I am leaving them there rather than adding more reconciliation to code we have agreed to remove. One of them (the tool_args preview publishing without reconciling) disappears entirely under the new placement. The other is a pacing guard that survives it, and I will fold it in.

Happy to do the restructure and re-run the browser benchmarks at both throttling levels, or to leave it to you if you would rather drive it. Say which and I will get out of the way.

Moves the gate from before the content build to immediately before the
assistant-ui yield, as proposed on the PR. The rebuild and the reasoning
tracker now run on every arrival again, and only the yield is coalesced.

The cost this change exists to remove is downstream of the yield: assistant-ui,
React, markdown rendering and paint. The rebuild is not. Cumulative
parseAssistantContent calls against a reply growing to 64,000 characters
measure 34 to 41 ms warm in total, against 11.4 to 28.7 seconds of observed
backlog, so keeping it on every chunk costs nothing worth having.

Pacing the interpretation as well is what pulled reasoning timing, split
<think> tags, server summaries, terminal reconciliation and replay metadata
into a change about paint cost. All of that bookkeeping goes: gateHeldSince,
gateReasoningEndedAt, reconcileReasoning and its five call sites,
adoptGatedReasoningGroups, the split-tag search at the chunk join, the
reconcile before the server summary frame, and the back-dating arguments on
startGroup and finishGroup. reasoning-duration.ts returns to its original
form. The adapter diff drops from +311/-53 to +137/-22.

What stays, because it is about the publish rather than the parse: one gate
per run, the arrival counter and MAX_HELD_CHARS that bound what a Stop can
lose, and ungated publishes for anything state-bearing. That last set now
also covers a per-call extra_content change, where Gemini puts the thought
signature; a Stop that persists the call without it makes the next turn fail
outright.

The suite is rewritten around the new invariant: pacing must not be able to
change any reasoning duration. Every case that cost a review round under the
old placement is driven through the loop twice, publishing on every arrival
and then on only the last, and required to agree.
Mutation testing found the gap: inserting a gate in front of the tool_start
and tool_end publish left the suite green, so nothing stopped a Stop from
persisting a card that never got its result. Only the per-delta argument
preview above them is paced.
@danielhanchen

danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

Done, in b9f4a73c2 and cfd307a0c. The gate now sits immediately before the assistant-ui yield.

What went

gateHeldSince, gateReasoningEndedAt, reconcileReasoning and its five call sites, adoptGatedReasoningGroups, the split-tag search at the chunk join, the reconcile before the server summary frame, and the back-dating arguments on startGroup / finishGroup. reasoning-duration.ts is byte-identical to its state before this PR. The adapter diff against the merge base drops from +311/-53 to +137/-22.

What stayed

One gate per run, the arrival counter and MAX_HELD_CHARS, and ungated publishes for anything state-bearing: tool_start / tool_end, a new delta.tool_calls entry, message-level replay metadata, and per-call extra_content. Also the skip for a chunk the ${...} strip left with nothing new, which is what the open review thread was about; it now covers tool-using turns too.

Does keeping the build on every chunk cost the improvement

This was the open question, so I measured it rather than reasoning about it. Same 64,000 character reply, 2000 chunks, in each engine, with a DOM write plus a forced layout read standing in for what is downstream of the yield. Median of three, after warming all three paths together:

=== chromium 151.0.7922.34 ===
  A publish every chunk (pre-PR)       2748 ms  publishes=2000  parses=2000  speedup=1.0x
  B gate before the build (original)     336 ms  publishes= 250  parses= 250  speedup=8.2x
  C gate after the build (this PR)       347 ms  publishes= 250  parses=2000  speedup=7.9x
  C keeps 99.5% of B's improvement

=== firefox 153.0 ===
  A publish every chunk (pre-PR)       3976 ms  publishes=2000  parses=2000  speedup=1.0x
  B gate before the build (original)     495 ms  publishes= 250  parses= 250  speedup=8.0x
  C gate after the build (this PR)       494 ms  publishes= 250  parses=2000  speedup=8.0x
  C keeps 100.0% of B's improvement

=== webkit 26.5 ===
  A publish every chunk (pre-PR)       9468 ms  publishes=2000  parses=2000  speedup=1.0x
  B gate before the build (original)    1238 ms  publishes= 250  parses= 250  speedup=7.6x
  C gate after the build (this PR)      1246 ms  publishes= 250  parses=2000  speedup=7.6x
  C keeps 99.9% of B's improvement

C does 8x the parses of B and lands within 11 ms of it on Chromium, inside noise on the other two. The stand-in cost is a lower bound on the real downstream work, so this understates C against A and overstates any gap between B and C. Your read was right.

Tests

Rewritten around the property the placement buys, rather than around the machinery it removes:

pacing must not be able to change a reasoning duration

Every case that cost a review round under the old placement (a split opening tag, the tag as its own delta, successive complete blocks, a later pass that opens bodyless, a long pause before the answer) is driven through the loop twice, publishing on every arrival and then on only the last, and required to agree. All five now do. Under the old placement each of them differed depending on which arrivals the gate let through.

41 tests became 26: the 17 reconciliation tests are gone with the code, and the ordering test now asserts the whole chain, that the rebuild and the tracker precede the gate and the gate precedes the yield. One test simply forbids the four removed names from coming back.

Mutation matrix rebuilt for the new shape: 24 mutations, all killed, including moving the gate back in front of the content build. It also caught a genuine gap while I was writing it, that inserting a gate in front of the tool_start / tool_end publish left the suite green; cfd307a0c pins that.

Suite 2415 passing, typecheck and build clean, 45/45 cross-engine scenarios in Chromium, Firefox and WebKit foreground and backgrounded, staging CI running now. Lint is unchanged: the same 10 pre-existing eslint errors in chat-adapter.ts before and after.

One thing worth doing that I have not: your throttled Studio numbers were measured against the old placement. The synthetic result says they should be unchanged, but if you still have that setup it would be worth one confirming run.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 2ff1abd3b2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: fb73e42fd2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member

End to end measurement, since everything up to now has been either synthetic or measured against the old gate placement.

Two isolated Studio installs, built by install.sh --local from the merge base 7fd3eeba75 and from head fb73e42fd. Three paired runs. Chromium at 8x CPU throttle applied identically to both sides and recorded per run. The reply is a fixed 60,000 character SSE body served by a local server, so both sides receive byte-identical input at the same rate; settled_chars came back 59,723 on all six runs, so the two halves painted exactly the same reply.

before and after

The visible effect is the freeze, not the total time

BEFORE (merge base) AFTER (this PR)
longest single stall in the bubble 3709 to 4323 ms (med 4014) 530 to 674 ms (med 622)
time to fully painted 12.72 to 13.09 s 10.78 to 11.62 s
stream duration 3423 to 4834 ms 2882 to 3283 ms
settled characters 59,723 59,723

The stall ranges do not overlap across any of the three pairs. That number is the complaint in the PR description, that the page stops painting and Stop cannot run promptly: the un-paced build goes four seconds without updating the bubble at all, and the paced one never exceeds two thirds of a second.

Total time moves too, consistently, but only about 1.15x. I predicted a several-fold drop there and that prediction was wrong: once the publishes are coalesced, what remains is the cost of rendering a 60,000 character markdown message, which both builds pay in full. Worth stating plainly since it is the number a reader would expect to move most.

The stream duration difference is the backpressure effect, visible in Studio's own timing chip in the settled screenshots (4.38s against 2.85s): the saturated main thread slows the un-paced build's own reader.

What this does not show

A negative control at a stream slower than the renderer, where the two sides come out at 296s against 306s, i.e. identical within noise. The PR is a no-op when there is no backlog, which matches "slower streams still publish every chunk".

painted_pct_at_close is not usable and I would drop it from the PR body. Across these runs it read 10.7 to 100 on BEFORE against 78.6 to 100 on AFTER, and it systematically flatters BEFORE, whose socket closes late for the reason above. Reading that column naively would report this PR as a regression.

Four earlier attempts that produced clean-looking runs and proved nothing

Recording these because each one would have been publishable and wrong.

  1. A real model produced roughly 2,000 characters. The rebuild this PR removes is quadratic in reply length, so at that scale there is nothing to measure, and free-form sampling gave the two sides different essays.
  2. Generating the stream inside the page measures the wrong thing. The generator competes for the exact CPU budget the throttle is shrinking, so the stream slowed in lockstep with the renderer and neither side ever fell behind: 97% painted at socket close on both.
  3. Studio serves connect-src 'self', so the helper server was blocked silently and delivered zero bytes with no error surfaced.
  4. A 1.5 second stability window declares the reply finished in the middle of a freeze. One run had BEFORE "settle" at 3,461 characters against AFTER's 59,723, which is the backlog itself corrupting the measurement of the backlog.

Scene and the full record of what was observed are in pr_ui_scenes/chat_stream_fixed_bytes.py.

@danielhanchen
danielhanchen merged commit 17363f8 into unslothai:main Aug 16, 2026
34 of 41 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.

3 participants