4e247c112e
Enumerate evidence kinds so education cannot be proposed as a kind, and stream Chinese thinking on a separate channel that collapses when the reply arrives. Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
7.4 KiB
TypeScript
134 lines
7.4 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, /\{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 === "thinking.delta"/);
|
|
assert.match(pageSource, /activeStreamingThinking/);
|
|
assert.match(messageRowSource, /思考过程/);
|
|
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 \{/);
|
|
});
|