feat(consult): type out streamed text at a capped pace and write the tail out on settle instead of one frame (T3, BUG-1075)

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:14:46 +08:00
co-authored by Claude Fable 5.1
parent 86d99115c6
commit 3f8b817261
8 changed files with 346 additions and 32 deletions
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatMessageRow } from "../src/components/chat-message-row.tsx";
import {
chatMessageViews,
latestAssistantView,
settledChatMessageViews,
settlingChatMessageView,
} from "../src/lib/chat-message-view.ts";
const source = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const stored = [
{ role: "user" as const, text: "我妈是不是不太在意我" },
{
role: "assistant" as const,
text: "不是。4 宫主水星逆行落 10 宫,她把心力投在你的前途上——不是不在意,是不会用你想要的方式在意。",
techniqueTruth: "unknown",
},
];
// T3 / BUG-1075: after the run has ended the stored reply is complete, but the
// client keeps writing it out at the typing pace. The trailing row shows the
// released prefix under the same render key, and its timeline is not live.
test("a settled reply still being written out renders the released prefix, same render key, timeline not live", () => {
const prefix = "不是。4 宫主水星逆行落 10 宫,";
const latest = latestAssistantView(stored, false, prefix);
assert.ok(latest);
assert.equal(latest.view.text, prefix);
assert.equal(latest.view.state, "streaming");
assert.equal(latest.view.settling, true);
assert.equal(latest.view.renderKey, settledChatMessageViews(stored)[1]!.renderKey);
assert.equal(latest.views.length, 2);
assert.equal(latest.views[1]!.text, prefix);
const views = chatMessageViews(stored, false, prefix);
assert.equal(views.at(-1)!.text, prefix);
assert.equal(views.at(-1)!.settling, true);
const html = renderToStaticMarkup(<ChatMessageRow message={latest.view} />);
assert.match(html, /4 宫主水星逆行落 10 宫,/);
assert.doesNotMatch(html, /她把心力投在你的前途上/);
// The step timeline is the settled one: no live row, no spinner, no 正在分析.
assert.doesNotMatch(html, /inline-spinner|正在分析|收到,正在看你的问题/);
});
test("the write-out override applies only to a strict prefix of the stored reply while nothing is loading", () => {
const last = settledChatMessageViews(stored)[1]!;
assert.equal(settlingChatMessageView(last, true, "不是。"), undefined, "still loading: the streaming view owns the row");
assert.equal(settlingChatMessageView(last, false, ""), undefined, "nothing released: stored reply as is");
assert.equal(settlingChatMessageView(last, false, last.text), undefined, "fully written out: stored reply as is");
assert.equal(settlingChatMessageView(last, false, "另一条回答"), undefined, "not a prefix (a replaced reply): stored reply as is");
assert.equal(latestAssistantView(stored, false, "另一条回答")?.view.text, last.text);
const user = settledChatMessageViews([{ role: "user", text: "问" }])[0]!;
assert.equal(settlingChatMessageView(user, false, "问"), undefined);
});
test("the consultation hook paces the write-out only for a finished run; stop, failure and truncation release at once", () => {
const hook = source("../src/hooks/use-consultation-run.ts");
// The stream ended normally: paced unless the user stopped it or the server cut it.
assert.match(hook, /settleFrames\(controller\.signal\.aborted \|\| Boolean\(truncatedFailure\)\);/);
assert.match(hook, /settleFrames\(controller\.signal\.aborted\);/);
// Failure path keeps what arrived, at once.
assert.match(hook, /settleRequested = false;\s*void frames\.settle\(\{ immediate: true \}\);/);
// The paced write-out owns the display through the streaming reply's `settling` id;
// the interface completes without waiting for it and without clearing it.
assert.match(hook, /setStreamingReply\(\(current\) => \(current\?\.settling \? current : null\)\);/);
assert.match(hook, /current\?\.settling !== requestId \? current : frame\.settled \? null : next/);
assert.match(hook, /\{ \.\.\.current, settling: requestId \}/);
// No second animation system: the write-out is the same frame buffer the live stream uses.
assert.equal((hook.match(/createStreamFrameBuffer</g) ?? []).length, 1);
});
+102 -2
View File
@@ -3,8 +3,11 @@ import test from "node:test";
import {
STREAM_HIDDEN_FLUSH_MS,
STREAM_RELEASE_CATCHUP_CHARS,
STREAM_RELEASE_CATCHUP_DIVISOR,
STREAM_RELEASE_MAX_CHARS,
STREAM_RELEASE_MIN_CHARS,
STREAM_SETTLE_MAX_FRAMES,
advanceStreamRelease,
createStreamFrameBuffer,
streamReleaseCount,
@@ -83,7 +86,7 @@ test("a replaced target that no longer extends the released prefix jumps instead
assert.equal(advanceStreamRelease("abc", "abd"), "ab");
});
test("many events collapse into one flush per frame and settle releases everything synchronously", () => {
test("many events collapse into one flush per frame and a paced settle writes the rest out within the budget", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<string[]>[] = [];
const buffer = createStreamFrameBuffer<string[]>({
@@ -112,11 +115,108 @@ test("many events collapse into one flush per frame and settle releases everythi
assert.equal(flushes.at(-1)!.meta.length, 200);
assert.ok(flushes.at(-1)!.answer.length < 200, "pacing is still behind the network");
buffer.settle();
// 原值: buffer.settle() 同步一帧放完剩余文字(settled: true 立即到)
// 新值: 默认 settle 按打字节奏在 STREAM_SETTLE_MAX_FRAMES 内写完,最后一帧才 settled: true;immediate 才一帧放完
// 原因: TASK-consult-first-frame-and-pacing-20260928 D3 / BUG-1075:流尾一帧全放让短回答「一下全出来」
const pendingAtSettle = answer.length - flushes.at(-1)!.answer.length;
let resolved = false;
void buffer.settle().then(() => { resolved = true; });
assert.equal(flushes.at(-1)!.settled, false, "a paced settle does not flush synchronously");
let settleFrames = 0;
while (fake.scheduledFrames > 0 && settleFrames < 200) {
fake.tick();
settleFrames += 1;
}
assert.ok(settleFrames >= Math.ceil(pendingAtSettle / STREAM_RELEASE_MAX_CHARS) - 1, `wrote out ${pendingAtSettle} in ${settleFrames} frames`);
assert.ok(settleFrames <= STREAM_SETTLE_MAX_FRAMES, `took ${settleFrames} frames`);
assert.equal(flushes.at(-1)!.answer, answer);
assert.equal(flushes.at(-1)!.settled, true);
assert.equal(flushes.at(-2)!.settled, false, "only the last paced frame is settled");
assert.equal(fake.scheduledFrames, 0);
assert.equal(buffer.released().answer, answer);
return Promise.resolve().then(() => assert.equal(resolved, true));
});
test("typing pace: an ordinary backlog is capped at four characters a frame, a large one still catches up", () => {
// The server releases the natal opener as one 160-character lump (ANSWER_RELEASE_CHARS).
assert.equal(streamReleaseCount(160), STREAM_RELEASE_MAX_CHARS);
assert.equal(streamReleaseCount(STREAM_RELEASE_CATCHUP_CHARS), STREAM_RELEASE_MAX_CHARS);
assert.equal(streamReleaseCount(STREAM_RELEASE_CATCHUP_CHARS + 1), Math.ceil((STREAM_RELEASE_CATCHUP_CHARS + 1) / STREAM_RELEASE_CATCHUP_DIVISOR));
let released = "";
const lump = "字".repeat(160);
let frames = 0;
while (released !== lump && frames < 1_000) {
released = advanceStreamRelease(released, lump, lump.length);
frames += 1;
}
assert.equal(frames, 160 / STREAM_RELEASE_MAX_CHARS, "a 160-character lump is typed out over 40 frames, not 12");
});
test("a paced settle writes 300 leftover characters within the budget; immediate, stop-style settle is one flush", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
const text = "字".repeat(300);
buffer.setAnswer(text);
fake.tick();
const shownBefore = flushes.at(-1)!.answer.length;
assert.ok(shownBefore < 300);
void buffer.settle();
let frames = 0;
while (fake.scheduledFrames > 0 && frames < 500) {
fake.tick();
frames += 1;
}
assert.ok(frames <= STREAM_SETTLE_MAX_FRAMES, `took ${frames} frames`);
assert.ok(frames > 1, "not one frame");
assert.equal(flushes.at(-1)!.answer, text);
assert.equal(flushes.at(-1)!.settled, true);
for (let index = 1; index < flushes.length; index += 1) {
assert.ok(flushes[index]!.answer.length >= flushes[index - 1]!.answer.length, "monotonic");
}
const immediate = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
immediate.setAnswer(text);
const before = flushes.length;
void immediate.settle({ immediate: true });
assert.equal(flushes.length, before + 1);
assert.equal(flushes.at(-1)!.answer, text);
assert.equal(flushes.at(-1)!.settled, true);
assert.equal(fake.scheduledFrames, 0);
});
test("dispose during a paced settle lets it finish, then silences the buffer", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
const text = "字".repeat(40);
buffer.setAnswer(text);
fake.tick();
void buffer.settle();
buffer.dispose();
let frames = 0;
while (fake.scheduledFrames > 0 && frames < 100) {
fake.tick();
frames += 1;
}
assert.equal(flushes.at(-1)!.answer, text);
assert.equal(flushes.at(-1)!.settled, true);
buffer.setAnswer("不再发布");
buffer.touch();
assert.equal(fake.tick(), 0);
});
test("thinking text is paced separately from the answer and meta-only touches still flush", () => {