52f3c04b3b
Measured use of the three chips above the composer was negligible. They were also not what they appeared to be: the server looked up a fixed triplet by session theme and passed it as metadata that overrode anything the model produced, so the same ten hardcoded sets served every user regardless of question or chart. That is a plausible reason nobody pressed them. Both copies of the per-theme table are gone, reply metadata narrows to the session title, and the two parse entry points collapse into one now that they return the same shape. The write schema still tolerates a suggestions field so a client on the previous bundle does not lose its message mid-deploy, and stored answers containing the legacy hidden block are still stripped rather than shown raw. Co-authored-by: Cursor <cursoragent@cursor.com>
96 lines
5.1 KiB
TypeScript
96 lines
5.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 && <ChatMessageContent text=\{message\.text\}/);
|
|
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
|
assert.match(activitySource, /className="agent-activity-status__text"/);
|
|
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
|
|
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
|
|
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
|
|
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成|completed/);
|
|
assert.doesNotMatch(messageRowSource, /: "completed"/);
|
|
assert.doesNotMatch(globalStyles, /\.thinking\b/);
|
|
assert.match(pageSource, /application\/x-ndjson/);
|
|
assert.match(pageSource, /createNdjsonParser/);
|
|
assert.match(pageSource, /if \(event\.type === "run\.failed"\) throw new ConsultationResponseError/);
|
|
assert.match(pageSource, /if \(!runCompleted\) 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/);
|
|
});
|