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
@@ -753,6 +753,7 @@ or user IDs.
|
||||
| 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
|
||||
| 盘位骨架(仅星盘页) | D1 / 非 D1 分盘 / 西洋盘尚未返回 | `VedicChartSvg skeleton` / `WesternWheelSvg skeleton`;统一 `.chart-page-skeleton` | 2.4s ease-in-out,opacity 0.35 ↔ 0.6 | 停止动画,opacity 0.5 |
|
||||
| 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
|
||||
| 流式正文的放字节奏 | 普通对话与校正面的回答正文(`stream-frame-buffer`) | 每帧最多 4 字(≈ 240 字/秒),积压超过 600 字才按 1/12 追赶;流结束后剩余文字按同一节奏在 1.5 s 内写完,最后一帧才算 settled;停止 / 失败 / 断线 / 页面隐藏一帧全放(2026-09-28,BUG-1075) | 逐帧 | 不涉及 CSS 动画,无需降级 |
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export const ChatMessageRow = memo(function ChatMessageRow({
|
||||
<div className="consultation-thinking-report">
|
||||
<ConsultationRunTimeline
|
||||
rows={consultTimeline}
|
||||
live={showActivity && message.state !== "settled"}
|
||||
live={showActivity && message.state !== "settled" && !message.settling}
|
||||
vargaSentence={vargaSentence}
|
||||
/>
|
||||
{hasAnswer ? (
|
||||
|
||||
@@ -489,7 +489,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
if (pendingConsultation.current?.requestId !== requestId) return;
|
||||
pendingConsultation.current = null;
|
||||
if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null;
|
||||
setStreamingReply(null);
|
||||
// A reply still being written out at the typing pace keeps the display
|
||||
// (BUG-1075); its last paced frame clears it. Everything else is done now.
|
||||
setStreamingReply((current) => (current?.settling ? current : null));
|
||||
setPendingSessionId(null);
|
||||
setPendingRequestId(null);
|
||||
setConsultationPhase(null);
|
||||
@@ -766,6 +768,11 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
let streamedThinking = "";
|
||||
let timelineState = emptyConsultationTimeline();
|
||||
let currentActivity: AgentActivityView | undefined;
|
||||
// True once the stream has ended and the buffer is writing out what is
|
||||
// left at the typing pace (BUG-1075). From then on the display belongs to
|
||||
// this request only while the streaming reply still carries its id: a new
|
||||
// send replaces the reply, and the old buffer's frames fall through.
|
||||
let settleRequested = false;
|
||||
// Every stream event lands in `frames`; it commits at most once per animation
|
||||
// frame and releases text at a steady pace. Nothing below calls
|
||||
// setStreamingReply directly while the response body is being read.
|
||||
@@ -775,7 +782,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
const partialReply = parseAgentReply(frame.answer).text;
|
||||
latestPartialReply = partialReply;
|
||||
thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply);
|
||||
setStreamingReply({
|
||||
const next: StreamingReply = {
|
||||
sessionId,
|
||||
text: partialReply,
|
||||
responseKind,
|
||||
@@ -783,12 +790,33 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
|
||||
timeline: timelineState.rows,
|
||||
activity: currentActivity,
|
||||
});
|
||||
...(settleRequested ? { settling: requestId } : {}),
|
||||
};
|
||||
if (settleRequested) {
|
||||
setStreamingReply((current) => (
|
||||
current?.settling !== requestId ? current : frame.settled ? null : next
|
||||
));
|
||||
} else {
|
||||
setStreamingReply(next);
|
||||
}
|
||||
if (partialReply && pendingConsultation.current?.requestId === requestId) {
|
||||
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
|
||||
}
|
||||
},
|
||||
});
|
||||
// The stream has ended: write out the rest at the typing pace unless the
|
||||
// run was stopped or failed, in which case what arrived shows at once.
|
||||
const settleFrames = (immediate: boolean) => {
|
||||
if (immediate) {
|
||||
void frames.settle({ immediate: true });
|
||||
return;
|
||||
}
|
||||
settleRequested = true;
|
||||
setStreamingReply((current) => (
|
||||
current && current.sessionId === sessionId ? { ...current, settling: requestId } : current
|
||||
));
|
||||
void frames.settle();
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/consult", {
|
||||
method: "POST",
|
||||
@@ -949,7 +977,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
parser.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
parser.finish(decoder.decode());
|
||||
frames.settle();
|
||||
settleFrames(controller.signal.aborted || Boolean(truncatedFailure));
|
||||
if (truncatedFailure) {
|
||||
const reply = parseAgentReply(answer);
|
||||
if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message);
|
||||
@@ -992,7 +1020,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
}
|
||||
answer += decoder.decode();
|
||||
frames.setAnswer(answer);
|
||||
frames.settle();
|
||||
settleFrames(controller.signal.aborted);
|
||||
}
|
||||
if (controller.signal.aborted) return Boolean(latestPartialReply);
|
||||
const reply = parseAgentReply(answer);
|
||||
@@ -1043,7 +1071,8 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
} catch (caught) {
|
||||
// Whatever arrived before the failure is what gets kept, not just the
|
||||
// part the pacing had released so far.
|
||||
frames.settle();
|
||||
settleRequested = false;
|
||||
void frames.settle({ immediate: true });
|
||||
const cancelled = controller.signal.aborted;
|
||||
const ownsInterface = pendingConsultation.current?.requestId === requestId;
|
||||
const partialReply = latestPartialReply;
|
||||
|
||||
@@ -67,6 +67,12 @@ export type ChatMessage = {
|
||||
export type ChatMessageView = ChatMessage & {
|
||||
readonly renderKey: string;
|
||||
readonly state: "settled" | "streaming" | "thinking";
|
||||
/**
|
||||
* The run has ended and the reply is stored in full; the text shown is the
|
||||
* part the client has written out so far (BUG-1075). The step timeline is
|
||||
* already settled, so it is not live while this is true.
|
||||
*/
|
||||
readonly settling?: boolean;
|
||||
readonly activity?: AgentActivityView;
|
||||
readonly activityTrace?: readonly AgentActivityTraceItem[];
|
||||
readonly timeline?: readonly ConsultationTimelineRow[];
|
||||
@@ -145,9 +151,29 @@ export function latestAssistantView(
|
||||
if (streaming) return { view: streaming, views: [...settled, streaming] };
|
||||
const last = settled.at(-1);
|
||||
if (!last || last.role !== "assistant") return undefined;
|
||||
const writing = settlingChatMessageView(last, loading, streamingText);
|
||||
if (writing) return { view: writing, views: [...settled.slice(0, -1), writing] };
|
||||
return { view: last, views: settled };
|
||||
}
|
||||
|
||||
/**
|
||||
* After the run has ended the stored reply is complete, but the client may
|
||||
* still be writing it out at the typing pace (BUG-1075): while the released
|
||||
* text is a strict prefix of the stored reply, the trailing row shows the
|
||||
* released part under the same render key, without a live timeline. Anything
|
||||
* else (no text, text already complete, text that is not a prefix) renders
|
||||
* the stored reply as is.
|
||||
*/
|
||||
export function settlingChatMessageView(
|
||||
last: ChatMessageView,
|
||||
loading: boolean,
|
||||
streamingText: string,
|
||||
): ChatMessageView | undefined {
|
||||
if (loading || !streamingText || last.role !== "assistant") return undefined;
|
||||
if (last.text === streamingText || !last.text.startsWith(streamingText)) return undefined;
|
||||
return { ...last, text: streamingText, state: "streaming", settling: true };
|
||||
}
|
||||
|
||||
export function chatMessageViews(
|
||||
messages: readonly ChatMessage[],
|
||||
loading: boolean,
|
||||
@@ -169,5 +195,8 @@ export function chatMessageViews(
|
||||
timeline,
|
||||
responseKind,
|
||||
);
|
||||
return streaming ? [...settled, streaming] : settled;
|
||||
if (streaming) return [...settled, streaming];
|
||||
const last = settled.at(-1);
|
||||
const writing = last ? settlingChatMessageView(last, loading, streamingText) : undefined;
|
||||
return writing ? [...settled.slice(0, -1), writing] : settled;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ export type StreamingReply = {
|
||||
responseKind?: "smalltalk";
|
||||
sessionId: string;
|
||||
text: string;
|
||||
/** Set once the run has ended and the reply is being written out at the typing pace (BUG-1075): the request that owns the display. */
|
||||
settling?: string;
|
||||
activity?: AgentActivityView;
|
||||
thinkingText?: string;
|
||||
thinkingSections?: PublicThinkingSection[];
|
||||
|
||||
@@ -7,39 +7,62 @@
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Pure release arithmetic lives in exported functions so the policy is
|
||||
* testable without a DOM; scheduling is injectable for the same reason.
|
||||
*/
|
||||
|
||||
export const STREAM_RELEASE_MIN_CHARS = 2;
|
||||
/**
|
||||
* Typing pace: at most this many characters per frame while the backlog is
|
||||
* ordinary (BUG-1075). Four a frame is about 240 characters a second at 60 fps,
|
||||
* above what a model produces on average, so the backlog does not grow; a
|
||||
* server-held lump (the 160-character answer release, a whole short follow-up)
|
||||
* reads as writing instead of appearing at once.
|
||||
*/
|
||||
export const STREAM_RELEASE_MAX_CHARS = 4;
|
||||
export const STREAM_RELEASE_CATCHUP_DIVISOR = 12;
|
||||
/** A backlog above this (reconnect, slow tab) catches up in about twelve frames instead of typing it out. */
|
||||
export const STREAM_RELEASE_CATCHUP_CHARS = 600;
|
||||
/** A paced settle writes out what is left within this many frames (1.5 s at 60 fps). */
|
||||
export const STREAM_SETTLE_MAX_FRAMES = 90;
|
||||
export const STREAM_HIDDEN_FLUSH_MS = 250;
|
||||
|
||||
/**
|
||||
* Characters to reveal on one frame. `backlogChars` is how much was waiting
|
||||
* when the newest text arrived: dividing that by twelve clears any burst in
|
||||
* about twelve frames, while the two-character floor keeps a slow model from
|
||||
* reading as stalled. Callers without a backlog figure pass the pending count.
|
||||
* when the newest text arrived. An ordinary backlog is typed out at up to
|
||||
* STREAM_RELEASE_MAX_CHARS a frame, with a two-character floor so a slow model
|
||||
* never reads as stalled; a backlog above STREAM_RELEASE_CATCHUP_CHARS is
|
||||
* divided by twelve so any burst clears in about twelve frames. Callers
|
||||
* without a backlog figure pass the pending count.
|
||||
*/
|
||||
export function streamReleaseCount(pendingChars: number, backlogChars = pendingChars): number {
|
||||
if (pendingChars <= 0) return 0;
|
||||
return Math.min(
|
||||
pendingChars,
|
||||
Math.max(STREAM_RELEASE_MIN_CHARS, Math.ceil(backlogChars / STREAM_RELEASE_CATCHUP_DIVISOR)),
|
||||
);
|
||||
const catchUp = Math.ceil(backlogChars / STREAM_RELEASE_CATCHUP_DIVISOR);
|
||||
const perFrame = backlogChars > STREAM_RELEASE_CATCHUP_CHARS
|
||||
? catchUp
|
||||
: Math.min(STREAM_RELEASE_MAX_CHARS, catchUp);
|
||||
return Math.min(pendingChars, Math.max(STREAM_RELEASE_MIN_CHARS, perFrame));
|
||||
}
|
||||
|
||||
/** Characters per frame that write out `pendingChars` within the settle budget, never slower than the typing pace. */
|
||||
export function settleReleaseCount(pendingChars: number): number {
|
||||
if (pendingChars <= 0) return 0;
|
||||
return Math.max(STREAM_RELEASE_MAX_CHARS, Math.ceil(pendingChars / STREAM_SETTLE_MAX_FRAMES));
|
||||
}
|
||||
|
||||
/** Advance a released prefix toward its target by one frame's worth of text. */
|
||||
export function advanceStreamRelease(released: string, target: string, backlogChars?: number): string {
|
||||
export function advanceStreamRelease(released: string, target: string, backlogChars?: number, perFrame?: number): string {
|
||||
if (!target.startsWith(released)) {
|
||||
// The target was replaced rather than extended: restart from its head.
|
||||
return target.slice(0, streamReleaseCount(target.length, backlogChars ?? target.length));
|
||||
return target.slice(0, perFrame ?? streamReleaseCount(target.length, backlogChars ?? target.length));
|
||||
}
|
||||
const pending = target.length - released.length;
|
||||
if (pending <= 0) return target;
|
||||
return target.slice(0, released.length + streamReleaseCount(pending, backlogChars ?? pending));
|
||||
return target.slice(0, released.length + (perFrame ?? streamReleaseCount(pending, backlogChars ?? pending)));
|
||||
}
|
||||
|
||||
export type StreamFrameSnapshot<Meta> = Readonly<{
|
||||
@@ -70,8 +93,15 @@ export type StreamFrameBuffer<Meta> = Readonly<{
|
||||
setMeta: (next: Meta | ((current: Meta) => Meta)) => void;
|
||||
/** Publish meta-only changes (timeline rows, activity) on the next frame. */
|
||||
touch: () => void;
|
||||
/** Release everything received and flush synchronously. */
|
||||
settle: () => 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
|
||||
* at once.
|
||||
*/
|
||||
settle: (options?: Readonly<{ immediate?: boolean }>) => Promise<void>;
|
||||
/** Drop everything, including scheduled work, without flushing. */
|
||||
reset: (meta?: Meta) => void;
|
||||
dispose: () => void;
|
||||
@@ -107,6 +137,10 @@ export function createStreamFrameBuffer<Meta>(
|
||||
let disposed = false;
|
||||
let frameHandle: number | null = null;
|
||||
let timeoutHandle: number | null = null;
|
||||
// A paced settle in flight: its per-frame count, and who to tell when the
|
||||
// last frame has flushed. dispose() during it defers until that frame.
|
||||
let settling: { perFrame: number; resolve: () => void } | null = null;
|
||||
let disposeWhenSettled = false;
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (frameHandle !== null) {
|
||||
@@ -128,6 +162,16 @@ export function createStreamFrameBuffer<Meta>(
|
||||
});
|
||||
};
|
||||
|
||||
const finishSettle = () => {
|
||||
const done = settling;
|
||||
settling = null;
|
||||
if (disposeWhenSettled) {
|
||||
disposeWhenSettled = false;
|
||||
disposed = true;
|
||||
}
|
||||
done?.resolve();
|
||||
};
|
||||
|
||||
const step = () => {
|
||||
frameHandle = null;
|
||||
timeoutHandle = null;
|
||||
@@ -135,6 +179,9 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (scheduler.hidden()) {
|
||||
releasedAnswer = targetAnswer;
|
||||
releasedThinking = targetThinking;
|
||||
} else if (settling) {
|
||||
releasedAnswer = advanceStreamRelease(releasedAnswer, targetAnswer, answerBacklog, settling.perFrame);
|
||||
releasedThinking = targetThinking;
|
||||
} else {
|
||||
releasedAnswer = advanceStreamRelease(releasedAnswer, targetAnswer, answerBacklog);
|
||||
releasedThinking = advanceStreamRelease(releasedThinking, targetThinking, thinkingBacklog);
|
||||
@@ -143,7 +190,11 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (releasedThinking === targetThinking) thinkingBacklog = 0;
|
||||
const caughtUp = releasedAnswer === targetAnswer && releasedThinking === targetThinking;
|
||||
emit(caughtUp);
|
||||
if (!caughtUp) schedule();
|
||||
if (!caughtUp) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
if (settling) finishSettle();
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
@@ -177,17 +228,38 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (disposed) return;
|
||||
schedule();
|
||||
},
|
||||
settle() {
|
||||
if (disposed) return;
|
||||
cancelScheduled();
|
||||
releasedAnswer = targetAnswer;
|
||||
releasedThinking = targetThinking;
|
||||
answerBacklog = 0;
|
||||
thinkingBacklog = 0;
|
||||
emit(true);
|
||||
settle(settleOptions) {
|
||||
if (disposed) return Promise.resolve();
|
||||
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) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const previous = settling!.resolve;
|
||||
settling!.resolve = () => { previous(); resolve(); };
|
||||
});
|
||||
}
|
||||
cancelScheduled();
|
||||
}
|
||||
const pending = pendingChars(releasedAnswer, targetAnswer);
|
||||
if (settleOptions?.immediate || scheduler.hidden() || pending <= 0) {
|
||||
cancelScheduled();
|
||||
releasedAnswer = targetAnswer;
|
||||
releasedThinking = targetThinking;
|
||||
answerBacklog = 0;
|
||||
thinkingBacklog = 0;
|
||||
emit(true);
|
||||
if (settling) finishSettle();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
settling = { perFrame: settleReleaseCount(pending), resolve };
|
||||
schedule();
|
||||
});
|
||||
},
|
||||
reset(nextMeta) {
|
||||
cancelScheduled();
|
||||
if (settling) finishSettle();
|
||||
targetAnswer = "";
|
||||
targetThinking = "";
|
||||
releasedAnswer = "";
|
||||
@@ -197,6 +269,11 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (nextMeta !== undefined) meta = nextMeta;
|
||||
},
|
||||
dispose() {
|
||||
if (settling) {
|
||||
// Let the paced settle write out its last frames; it disposes itself.
|
||||
disposeWhenSettled = true;
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
cancelScheduled();
|
||||
},
|
||||
|
||||
@@ -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