Files
Jyotisha/frontend/tests/chat-stream-layout.test.ts
T
Jesse_Chen 6a44c778c3 fix(web): show live agent work progress and fail truncated rectification answers
Rectification dropped tool.activity started events and treated length finishes as completed. Share generation settings with consultation, keep the activity line through streaming, and name multi-domain chart calculation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 12:39:09 +08:00

128 lines
7.1 KiB
TypeScript

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";
const pageSource = readFileSync(new URL("../src/app/page.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 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);
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, /\{showActivity && \(/);
assert.match(messageRowSource, /message\.text && \(/);
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /className="agent-activity-status__text"/);
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
assert.match(activitySource, /className="agent-activity-status__trail" aria-hidden="true"/);
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-activity-status__row/);
assert.match(globalStyles, /\.agent-activity-status__trail/);
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 === "run\.failed"/);
assert.match(pageSource, /event\.code === "answer_truncated"/);
assert.match(pageSource, /throw new ConsultationResponseError/);
assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) throw new ConsultationResponseError/);
assert.match(pageSource, /agentExecutionReceipt = event\.receipt/);
});
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/);
assert.match(composerWrap, /pointer-events-none absolute inset-x-0 bottom-full/);
});
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*68px 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");
const rectificationChat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(actionsSource, new RegExp(`aria-label="${label}"`));
}
assert.match(pageSource, /<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.match(rectificationChat, /<ChatMessageActions/);
assert.match(globalStyles, /\.message-actions \{/);
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
});