Files
Jyotisha/frontend/tests/chat-stream-layout.test.ts
T
Jesse_ChenandClaude Opus 5.5 ad801a5db7
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 2m12s
Home and the chat chart no longer re-render on every streamed frame (issue #123, BUG-1472)
Home subscribed to the whole streamingReply object, so each animation frame of
an answer re-ran the page (header, picker, composer, overlays) and the desktop
natal panel under it. ChatStreamingTranscript now subscribes for its session
and feeds ChatTranscript; Home reads one derived boolean for the stop label.
ChatChartSplit is memoized. 100 frames: Home <=2 commits (was >=100), panel 0.

Two source-contract tests now pin the new component instead of the old names.
The onboarding typewriter still ticks at Home level (out of this ticket).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:13:55 +08:00

225 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatMessageViews } from "../src/lib/chat-message-view.ts";
import { homeSurface as pageSource } from "./home-surface.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const streamingTranscriptSource = readFileSync(new URL("../src/components/chat-streaming-transcript.tsx", import.meta.url), "utf8");
const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
const previousMessages = [
{ role: "user", text: "问题" },
] as const;
test("keeps the assistant render identity stable when streaming settles", () => {
// Given: one assistant answer exists first as a transient stream.
const streaming = chatMessageViews(previousMessages, true, "完整答案");
// When: the same answer becomes part of the persisted transcript.
const settled = chatMessageViews([
...previousMessages,
{ role: "assistant", text: "完整答案" },
], false, "");
// Then: React receives the same key and can reuse the existing message shell.
assert.equal(streaming.at(-1)?.renderKey, settled.at(-1)?.renderKey);
assert.equal(streaming.at(-1)?.state, "streaming");
assert.equal(settled.at(-1)?.state, "settled");
});
test("does not duplicate a completed assistant answer while loading state settles", () => {
// Given: the final answer has entered the transcript while request cleanup lags.
const completedMessages = [
...previousMessages,
{ role: "assistant", text: "完整答案" },
] as const;
// When: the render view is derived with the old loading flag still true.
const views = chatMessageViews(completedMessages, true, "完整答案");
// Then: only the persisted answer is rendered.
assert.equal(views.length, completedMessages.length);
assert.equal(views.at(-1)?.state, "settled");
});
test("shows honest agent activity states before and during streamed text", () => {
const activity = { phase: "chart-calculation", label: "正在计算本命盘…" } as const;
const view = chatMessageViews(previousMessages, true, "", activity).at(-1);
assert.deepEqual(view?.activity, activity);
const thinkingView = chatMessageViews(previousMessages, true, "", activity, "先看事业宫。").at(-1);
assert.equal(thinkingView?.thinkingText, "先看事业宫。");
assert.equal(thinkingView?.state, "thinking");
const sectionView = chatMessageViews(previousMessages, true, "", activity, undefined, [{
id: "foundation",
title: "先整理本盘的统一参数",
heading: "统一参数与原始结构",
steps: [{ id: "raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}]).at(-1);
assert.equal(sectionView?.thinkingSections?.[0]?.heading, "统一参数与原始结构");
assert.match(messageRowSource, /"loading-method": "searching"/);
assert.match(messageRowSource, /"chart-calculation": "solving"/);
assert.match(messageRowSource, /"evidence-validation": "working"/);
assert.match(messageRowSource, /"answer-composition": "composing"/);
assert.match(messageRowSource, /message\.activity\?\.label/);
assert.doesNotMatch(messageRowSource, /正在核对星盘信息/);
assert.match(messageRowSource, /正在处理…/);
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
assert.match(messageRowSource, /showThinkingPanel &&/);
assert.match(messageRowSource, /showSpokenAnswer/);
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{smalltalk \? undefined : message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(messageRowSource, /className="message-stage-and-answer"/);
// 旧:vargaSentence 在气泡正文,折叠思考时才藏
// 新:正文不再渲染方法句;展开的活动时间线保留
// 原因:BUG-606 决策 3(a)
assert.doesNotMatch(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
assert.match(messageRowSource, /<ConsultationRunTimeline[\s\S]*vargaSentence=\{vargaSentence\}/);
assert.match(globalStyles, /\.message-stage-and-answer/);
// 原值: gap: var(--space-2)
// 新值: gap: var(--consult-think-answer-gap)
// 原因: BUG-964 思考条与正文间距由同一 token 决定(12px),不再各写各的
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--consult-think-answer-gap\)/);
assert.match(globalStyles, /\.consultation-thinking-report \{[\s\S]*gap: var\(--consult-think-answer-gap\)/);
assert.match(globalStyles, /--consult-think-answer-gap:\s*var\(--space-3\)/);
assert.match(
globalStyles,
/\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \.message-markdown > \*:first-child \{[\s\S]*margin-top:\s*0/,
);
assert.match(globalStyles, /\.message-markdown ul\.markdown-list \{[\s\S]*list-style-type:\s*disc/);
assert.match(globalStyles, /\.message-markdown ol\.markdown-list \{[\s\S]*list-style-type:\s*decimal/);
assert.match(globalStyles, /\.markdown-list[^{]*\{[^}]*list-style/);
assert.match(
globalStyles,
/\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \.message-markdown \.markdown-list \{[\s\S]*gap:\s*var\(--space-2\)/,
);
assert.match(globalStyles, /\.markdown-list ::marker \{[\s\S]*color:\s*var\(--color-ink-secondary\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
// Former lock: `<ThinkingOrb aria-hidden="true" state={state} size={20}` — the canvas orb was a
// second live-marker vocabulary next to the timeline's InlineSpinner (BUG-476). One marker now.
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
assert.match(activitySource, /className="agent-activity-status__text"/);
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
assert.match(activitySource, /className="agent-thinking-timeline"/);
assert.match(activitySource, /role="status"/);
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
assert.match(globalStyles, /\.agent-thinking-step/);
assert.match(globalStyles, /\.agent-thinking-timeline/);
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
assert.match(pageSource, /nextActivityView/);
assert.match(pageSource, /chartCalculationProgressLabel|CONSULTATION_CHART_CALCULATION_LABEL/);
assert.match(pageSource, /activityCompletedTrail\(\[CONSULTATION_DONE_SKILL_LABEL\]\)/);
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成/);
assert.doesNotMatch(activitySource, /state === "completed"|: "completed"/);
assert.doesNotMatch(messageRowSource, /: "completed"/);
assert.doesNotMatch(globalStyles, /\.thinking\b/);
assert.match(pageSource, /application\/x-ndjson/);
assert.match(pageSource, /createNdjsonParser/);
assert.match(pageSource, /event.type === "thinking.section"/);
assert.match(pageSource, /event.type === "thinking.delta"/);
// 原值:page.tsx 里有 `activeStreamingSections`
// 新值:流式的思考分节由 ChatStreamingTranscript 自己订阅后传给 ChatTranscript
// 原因:issue #123(BUG-1472)Home 不再订阅流式回复,避免每帧整页重画
assert.match(streamingTranscriptSource, /streamingSections=\{reply\?\.thinkingSections\}/);
assert.match(pageSource, /streamedThinking/);
assert.match(messageRowSource, /showLiveActivity/);
// Former locks on `ConsultationThinkingReport`, `hasTrace`, `activityTrace={message.activityTrace}`,
// `function TraceStep`, `.agent-thinking-step.is-think:has(...)`, and the report/tree sources:
// those pinned the second and third thinking-render paths (sections report, trace panel) that
// no caller could reach once every reply carries a timeline (BUG-476). One path remains.
assert.doesNotMatch(messageRowSource, /ConsultationThinkingReport|showReport|hasTrace|activityTrace/);
assert.match(messageRowSource, /thinkingText=\{message\.thinkingText\}/);
assert.match(messageRowSource, /showLiveActivity \|\| \(consultTimeline === undefined && Boolean\(message\.thinkingText/);
assert.doesNotMatch(activitySource, /activityTrace|thinking-orbs/);
assert.match(globalStyles, /\.markdown-list/);
assert.match(activitySource, />思考</);
assert.match(pageSource, /event\.type === "run\.failed"/);
assert.match(pageSource, /event\.code === "answer_truncated"/);
assert.match(pageSource, /throw new ConsultationResponseError/);
assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
assert.match(pageSource, /agentExecutionReceipt = event\.receipt/);
assert.match(pageSource, /const failedSession: ChatSession/);
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
assert.match(pageSource, /reduceConsultationTimeline/);
assert.match(pageSource, /timeline: timelineState\.rows/);
// 原值:page.tsx 里有 `activeStreamingTimeline`;新值与原因同上(issue #123)
assert.match(streamingTranscriptSource, /streamingTimeline=\{reply\?\.timeline\}/);
assert.match(messageRowSource, /ConsultationRunTimeline/);
assert.match(messageRowSource, /message\.timeline/);
assert.match(timelineSource, /BookOpen/);
assert.match(timelineSource, /Layers/);
assert.match(timelineSource, /ListTodo/);
assert.match(timelineSource, /PenLine/);
assert.match(timelineSource, /LoaderCircle/);
assert.match(timelineSource, /data-kind=\{row\.kind\}/);
assert.doesNotMatch(timelineSource, /thinking-orbs|Globe|Cloud|favicon/i);
assert.match(globalStyles, /\.consultation-run-timeline/);
assert.match(reducerSource, /kind: "method"/);
assert.match(reducerSource, /kind: "calculate"/);
assert.match(globalStyles, /\.consultation-report-analysis/);
assert.match(globalStyles, /\.consultation-thinking-report/);
// Former locks on `.consultation-thinking-report .message-thinking`, `consultation-step-tree__*`
// and the tree source: the step-tree report path is deleted (BUG-476), and with it its CSS.
assert.doesNotMatch(globalStyles, /consultation-step-tree/);
});
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
// The follow-up chip row used to appear and disappear here, changing composer height
// mid-stream; the jump-to-latest overlay is the only remaining sibling and it is
// absolutely positioned, so it cannot reflow the composer.
const composerWrap = pageSource.slice(
pageSource.indexOf('composer-wrap ${starterHomeVisible'),
pageSource.indexOf("<ChatComposer"));
assert.doesNotMatch(composerWrap, /composer-suggestions/);
// Former lock: the inline `pointer-events-none absolute inset-x-0 bottom-full` wrapper. The
// control is now the shared JumpToLatestButton, positioned by `.jump-to-latest` (BUG-478).
assert.match(composerWrap, /<JumpToLatestButton/);
assert.match(globalStyles, /\.jump-to-latest \{[^}]*position: absolute[^}]*bottom: 100%/);
});
test("docks the composer inside the chat panel instead of floating over content", () => {
assert.match(pageSource, /<div className=\{`composer-wrap \$\{starterHomeVisible \? "composer-wrap-starter" : ""\}`\}>/);
assert.match(globalStyles, /\.chat-panel[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/);
assert.match(globalStyles, /\.conversation[^}]*padding-bottom:\s*var\(--composer-reserve\)/);
assert.match(globalStyles, /\.composer-wrap[^}]*position:\s*sticky/);
assert.match(globalStyles, /\.composer-wrap[^}]*bottom:\s*0/);
assert.doesNotMatch(globalStyles, /\.composer-wrap[^}]*position:\s*fixed/);
});
test("ordinary consultation replies reuse the shared Agent action bar", () => {
const actionsSource = readFileSync(new URL("../src/components/chat-message-actions.tsx", import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const rectificationChat = rectificationChatSurface;
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(actionsSource, new RegExp(`aria-label="${label}"`));
}
assert.match(transcriptSource, /<ChatMessageActions/);
assert.match(pageSource, /toggleChatMessageFeedback/);
assert.match(pageSource, /function regenerateLatestAnswer\(renderKey: string\)/);
assert.match(pageSource, /messages: session\.messages\.slice\(0, -1\)/);
assert.match(pageSource, /restoreOnFailure: session/);
assert.doesNotMatch(rectificationChat, /appendActivityTraceThinking/);
assert.doesNotMatch(rectificationChat, /event\.type === "thinking\.delta"/);
assert.match(rectificationChat, /startActivityTraceStep/);
assert.match(rectificationChat, /completeActivityTraceStep/);
// 原值: 校正面容器源码含 `<ChatMessageActions`。
// 新值: 动作条随行组件抽出,容器仍 import 行组件。
// 原因: BUG-725。
const rectificationEntry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(rectificationEntry, /<ChatMessageActions/);
assert.match(rectificationChat, /<RectificationMessageEntry/);
assert.match(globalStyles, /\.message-actions \{/);
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
});