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
+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", () => {