59559d4b24
Thinking disappeared on failure and never reached session storage. Keep the sanitized chain on disk and on errors, and regroup the sidebar around reports, charts, favorites, and dated history titles. Co-authored-by: Cursor <cursoragent@cursor.com>
136 lines
7.5 KiB
TypeScript
136 lines
7.5 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);
|
|
const thinkingView = chatMessageViews(previousMessages, true, "", activity, "先看事业宫。").at(-1);
|
|
assert.equal(thinkingView?.thinkingText, "先看事业宫。");
|
|
assert.equal(thinkingView?.state, "thinking");
|
|
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, /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-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.delta"/);
|
|
assert.match(pageSource, /activeStreamingThinking/);
|
|
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, /thinkingText: thinking.trim\(\) \|\| undefined/);
|
|
});
|
|
|
|
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 \{/);
|
|
});
|