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:
co-authored by
Claude Fable 5.1
parent
86d99115c6
commit
3f8b817261
@@ -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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user