fix(rectification): stop re-parsing settled messages on every stream frame
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createElement, createRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
|
||||
import {
|
||||
RectificationMessageEntry,
|
||||
UnsplitRectificationMessageList,
|
||||
type RectificationMessageActions,
|
||||
type RectificationMessageEntryProps,
|
||||
type RenderMessage,
|
||||
} from "../src/components/rectification-message-entry.tsx";
|
||||
import {
|
||||
disableHomeStreamingRenderProbe,
|
||||
enableHomeStreamingRenderProbe,
|
||||
homeStreamingRenderProbeSnapshot,
|
||||
resetHomeStreamingRenderProbe,
|
||||
} from "../src/lib/home-streaming-render-probe.ts";
|
||||
|
||||
const chatSource = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const entrySource = readFileSync(
|
||||
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const rowSource = readFileSync(
|
||||
new URL("../src/components/chat-message-row.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
|
||||
return {
|
||||
role,
|
||||
text,
|
||||
renderKey: key,
|
||||
state: "settled",
|
||||
};
|
||||
}
|
||||
|
||||
function liveAssistant(text: string): RenderMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
text,
|
||||
renderKey: "live-assistant",
|
||||
state: "streaming",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
|
||||
const actionsRef = createRef<RectificationMessageActions>() as RectificationMessageEntryProps["actionsRef"];
|
||||
actionsRef.current = {
|
||||
submitChoice() {},
|
||||
submitStop() {},
|
||||
copyMessage() {},
|
||||
regenerateMessage() {},
|
||||
onFeedback() {},
|
||||
};
|
||||
return actionsRef;
|
||||
}
|
||||
|
||||
function sharedProps(): Omit<
|
||||
RectificationMessageEntryProps,
|
||||
"message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
|
||||
> {
|
||||
return {
|
||||
busy: true,
|
||||
readonly: false,
|
||||
currentQuestionFocusId: null,
|
||||
interactive: false,
|
||||
liveChoiceCard: null,
|
||||
choiceNonce: 0,
|
||||
savedTime: null,
|
||||
actionsRef: emptyActions(),
|
||||
};
|
||||
}
|
||||
|
||||
function entryProps(
|
||||
message: RenderMessage,
|
||||
extras: Partial<Pick<RectificationMessageEntryProps, "regenerating" | "canRegenerate" | "copied" | "feedback">> = {},
|
||||
): RectificationMessageEntryProps {
|
||||
return {
|
||||
...sharedProps(),
|
||||
message,
|
||||
regenerating: extras.regenerating ?? false,
|
||||
canRegenerate: extras.canRegenerate ?? false,
|
||||
copied: extras.copied ?? false,
|
||||
feedback: extras.feedback,
|
||||
};
|
||||
}
|
||||
|
||||
test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
|
||||
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
|
||||
assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
|
||||
assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
|
||||
assert.match(entrySource, /rectificationTimelineRows\s*\(/);
|
||||
assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
|
||||
assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
|
||||
});
|
||||
|
||||
test("the extracted row is memoised and takes no function props except actionsRef", () => {
|
||||
assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
|
||||
const propsBlock = entrySource.slice(
|
||||
entrySource.indexOf("export type RectificationMessageEntryProps"),
|
||||
entrySource.indexOf("export function choiceCardFromQuestion"),
|
||||
);
|
||||
assert.match(propsBlock, /actionsRef: MutableRefObject<RectificationMessageActions>/);
|
||||
assert.doesNotMatch(propsBlock, /=>/);
|
||||
assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
|
||||
assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
|
||||
});
|
||||
|
||||
test("the split architecture renders settled rectification rows once while streaming tokens", () => {
|
||||
const history: RenderMessage[] = [
|
||||
settled("user", "第一件经历", "u1"),
|
||||
settled("assistant", "记下了第一件。", "a1"),
|
||||
settled("user", "第二件经历", "u2"),
|
||||
settled("assistant", "记下了第二件。", "a2"),
|
||||
];
|
||||
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
|
||||
const shared = sharedProps();
|
||||
const feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>> = {};
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
for (const message of history) {
|
||||
renderToString(createElement(RectificationMessageEntry, entryProps(message)));
|
||||
}
|
||||
for (const streamingText of tokens) {
|
||||
renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
|
||||
}
|
||||
const split = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
for (const streamingText of tokens) {
|
||||
renderToString(createElement(UnsplitRectificationMessageList, {
|
||||
...shared,
|
||||
messages: [...history, liveAssistant(streamingText)],
|
||||
latestRegeneratableKey: "a2",
|
||||
copiedMessageKey: null,
|
||||
feedbackByKey,
|
||||
regeneratingMessageKey: null,
|
||||
}));
|
||||
}
|
||||
const unsplit = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
assert.equal(split.settledRowRenders, history.length);
|
||||
assert.ok(split.streamingRowRenders <= tokens.length);
|
||||
assert.ok(split.streamingRowRenders >= 1);
|
||||
assert.equal(unsplit.unsplitListRenders, tokens.length);
|
||||
// Unsplit counts every row on every frame, including the live one.
|
||||
assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
|
||||
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
|
||||
});
|
||||
Reference in New Issue
Block a user