fix(consult): paced settle is opt-in for the consultation reply; one rejection path for HTTP bodies and stream events (T2/T3 follow-up)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 09:28:37 +08:00
co-authored by Claude Fable 5.1
parent 0bc6659052
commit 891e9c62da
6 changed files with 50 additions and 40 deletions
+13 -11
View File
@@ -7,9 +7,10 @@
* happens per animation frame. Answer and thinking text are released at a
* steady per-frame pace so a burst of chunks reads as flowing text instead of
* a jump, while a large backlog (reconnect, slow tab) catches up in roughly a
* dozen frames. When the stream ends, what is still unreleased is written out
* at the same pace within a short budget instead of appearing in one frame
* (BUG-1075); only a stop, a failure or a hidden document releases at once.
* dozen frames. When a consultation stream ends, what is still unreleased is
* written out at the same pace within a short budget instead of appearing in
* one frame (a paced settle, BUG-1075); a stop, a failure, a hidden document
* and every other caller release at once.
*
* Pure release arithmetic lives in exported functions so the policy is
* testable without a DOM; scheduling is injectable for the same reason.
@@ -94,14 +95,15 @@ export type StreamFrameBuffer<Meta> = Readonly<{
/** Publish meta-only changes (timeline rows, activity) on the next frame. */
touch: () => void;
/**
* The stream has ended. By default what is still unreleased is written out
* at the typing pace within STREAM_SETTLE_MAX_FRAMES, and the last frame
* flushes with `settled: true`; the promise resolves on that frame. With
* `immediate` (stop, failure, disconnect) everything is released in one
* synchronous flush, as before BUG-1075. A hidden document always releases
* The stream has ended. By default everything unreleased goes out in one
* synchronous flush (stop, failure, disconnect, and surfaces that merge a
* snapshot right after). With `paced` (the consultation reply, BUG-1075)
* what is left is written out at the typing pace within
* STREAM_SETTLE_MAX_FRAMES, only the last frame flushes with `settled: true`,
* and the promise resolves on that frame. A hidden document always releases
* at once.
*/
settle: (options?: Readonly<{ immediate?: boolean }>) => Promise<void>;
settle: (options?: Readonly<{ paced?: boolean }>) => Promise<void>;
/** Drop everything, including scheduled work, without flushing. */
reset: (meta?: Meta) => void;
dispose: () => void;
@@ -233,7 +235,7 @@ export function createStreamFrameBuffer<Meta>(
if (settling) {
// A second settle while one is writing out: an immediate one takes
// over and flushes now; a paced one just waits for the first.
if (!settleOptions?.immediate) {
if (settleOptions?.paced) {
return new Promise<void>((resolve) => {
const previous = settling!.resolve;
settling!.resolve = () => { previous(); resolve(); };
@@ -242,7 +244,7 @@ export function createStreamFrameBuffer<Meta>(
cancelScheduled();
}
const pending = pendingChars(releasedAnswer, targetAnswer);
if (settleOptions?.immediate || scheduler.hidden() || pending <= 0) {
if (!settleOptions?.paced || scheduler.hidden() || pending <= 0) {
cancelScheduled();
releasedAnswer = targetAnswer;
releasedThinking = targetThinking;