fix(web): persist thinking, title sessions distinctly, and send follow-ups from the answer

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>
This commit is contained in:
Jesse_Chen
2026-08-22 09:10:47 +08:00
co-authored by Cursor
parent 649ba32034
commit 59559d4b24
27 changed files with 741 additions and 179 deletions
@@ -3,6 +3,7 @@ import test from "node:test";
import { agentGenerationSettings, AGENT_MAX_OUTPUT_TOKENS } from "../src/lib/agent-generation-settings.ts";
import {
activityCompletedSteps,
activityCompletedTrail,
activityElapsedLabel,
nextActivityView,
@@ -77,6 +78,10 @@ test("completed-step trail stays short and drops while composing", () => {
label: "正在组织回答…",
}, 20);
assert.equal(composing.completedTrail, undefined);
assert.deepEqual(
activityCompletedSteps("已完成:读取分析方法 · 计算本命盘"),
["读取分析方法", "计算本命盘"],
);
});
test("live rectification labels name the actual public tool", () => {
+27
View File
@@ -58,6 +58,33 @@ test("general no-birth-time replies keep a question-specific session title", ()
assert.equal(resolveSessionTitle("工作变化的重点是什么?", "事业方向与工作变化"), "事业方向与工作变化");
});
test("daily and topic consultations get distinct dated or domain titles", () => {
const at = new Date(2026, 7, 22, 8, 36);
assert.equal(
resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at }),
"8月22日 · 今日节奏",
);
assert.equal(
resolveSessionTitle("深入看今日", undefined, {
entrypoint: "daily_starlanguage",
at,
existingTitles: ["8月22日 · 今日节奏"],
}),
"8月22日 · 今日节奏 08:36",
);
assert.equal(
resolveSessionTitle("请帮我梳理目前的事业方向和下一步重点。", undefined, { theme: "career", at }),
"事业 · 请帮我梳理目前的事业…",
);
assert.equal(
resolveSessionTitle("请用刚才采用的代表性出生时间看盘。", undefined, {
entrypoint: "birth_time_rectification",
at,
}),
"8月22日 · 生时校正",
);
});
test("a stored legacy suggestion block is stripped and never becomes suggested questions", () => {
// Answers written before the follow-up chips were removed still carry this block, and
+10 -1
View File
@@ -50,6 +50,14 @@ test("chat session schema preserves the safe agent execution receipt", () => {
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
});
test("chat session schema keeps sanitized thinking text on assistant messages", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", thinkingText: "先看今日节奏。" }],
});
assert.equal(parsed.messages[0]?.thinkingText, "先看今日节奏。");
});
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, values, "update", async (url, init) => {
@@ -112,6 +120,7 @@ test("session API owns create and update while answer UI keeps sync failures out
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
assert.match(page, /thinkingText: message\.thinkingText/);
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
assert.match(collectionRoute, /export async function POST/);
@@ -122,7 +131,7 @@ test("session API owns create and update while answer UI keeps sync failures out
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string \}> \}>/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string \}> \}>/);
assert.match(contract, /\): z\.ZodType<Output> \{/);
});
+8 -6
View File
@@ -59,19 +59,19 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.doesNotMatch(messageRowSource, /正在核对星盘信息/);
assert.match(messageRowSource, /正在处理…/);
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
assert.match(messageRowSource, /\{showActivity && \(/);
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-activity-status__trail" 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-activity-status__row/);
assert.match(globalStyles, /\.agent-activity-status__trail/);
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/);
@@ -84,12 +84,14 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /createNdjsonParser/);
assert.match(pageSource, /event.type === "thinking.delta"/);
assert.match(pageSource, /activeStreamingThinking/);
assert.match(messageRowSource, /思考过程/);
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\) 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", () => {
@@ -1294,9 +1294,11 @@ test("Chinese thinking stays off the spoken answer and does not bill a thought-o
yield { type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } };
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
}
let completedThinking: string | undefined;
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "ready", receipt: () => receipt(state),
onComplete: (_output, _receipt, thinkingText) => { completedThinking = thinkingText; },
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
@@ -1310,6 +1312,7 @@ test("Chinese thinking stays off the spoken answer and does not bill a thought-o
.map((event) => event.text)
.join("");
assert.equal(answer, "事业方向的判断如下。");
assert.equal(completedThinking, "先看事业宫的结构。");
assert.doesNotMatch(JSON.stringify(events), /proposedKind/);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
@@ -235,9 +235,13 @@ test("an answered conversation offers no suggested follow-up questions", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// Given: the chips above the composer were removed after measured use stayed negligible.
// Given: the chips above the composer stay gone. Grounded continuations sit under
// the latest answer and send immediately instead of filling the composer.
assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/);
assert.doesNotMatch(styles, /composer-suggestions/);
assert.match(source, /ConversationFollowUps/);
assert.match(source, /deriveConsultationFollowUps/);
assert.match(styles, /\.conversation-follow-ups/);
// Then: nothing in the conversation carries or stores a suggestion list any more.
assert.doesNotMatch(source, /suggestions: (?:reply|previewReply|parsed)\.suggestions/);
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { deriveConsultationFollowUps } from "../src/lib/consultation-follow-ups.ts";
test("daily answers surface grounded next questions instead of theme lookup chips", () => {
const followUps = deriveConsultationFollowUps({
question: "深入看今日",
answer: "今天更适合推进已经开过口的事,也要避开临时加塞的承诺。这股节奏会把注意力放在一件主线上。",
theme: "timing",
entrypoint: "daily_starlanguage",
});
assert.deepEqual(followUps, [
"今天最该先推进哪一件",
"这周哪些事最好先放一放",
"这股节奏大概还会持续多久",
]);
});
test("short or empty answers do not invent follow-ups", () => {
assert.deepEqual(deriveConsultationFollowUps({
question: "事业下一步怎么走?",
answer: "可以。",
theme: "career",
}), []);
});
+5 -5
View File
@@ -127,11 +127,11 @@ test("the first default consultation title is persisted with the user question",
sendSource.indexOf("const userSession: ChatSession"),
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
);
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && currentSession\.title === "新对话"[\s\S]*resolveSessionTitle\(question\)/);
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/);
assert.ok(sendSource.indexOf("await persistSession(userSession)") < sendSource.indexOf('fetch("/api/consult"'));
assert.doesNotMatch(sendSource, /persistSession\(completedSession\)/);
assert.match(sendSource, /const completedSession: ChatSession = \{[\s\S]*title: userSession\.title/);
assert.doesNotMatch(sendSource, /resolveSessionTitle\(question, reply\.title\)/);
assert.match(sendSource, /await persistSession\(completedSession\)/);
assert.match(sendSource, /const completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/);
assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/);
});
test("a truncated generation keeps the partial answer and does not wait for a successful run", () => {
@@ -141,6 +141,6 @@ test("a truncated generation keeps the partial answer and does not wait for a su
assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/);
assert.match(stream, /await persistSession\(truncatedSession\)/);
assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/);
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) throw new ConsultationResponseError/);
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/);
});
@@ -24,7 +24,7 @@ test("persists transformed assistant metadata before atomically settling usage",
consultRoute,
/parseAgentReply\([\s\S]*?rawTransformedText,[\s\S]*?createConsultationReplyMetadata\(\{ question: visibleQuestion \}\),[\s\S]*?\)/,
);
assert.match(consultRoute, /role: "assistant" as const,[\s\S]*techniqueTruth,[\s\S]*workflowReceipt/);
assert.match(consultRoute, /role: "assistant" as const,[\s\S]*thinkingText: persistedThinking[\s\S]*techniqueTruth,[\s\S]*workflowReceipt/);
const append = migration.indexOf("set messages = session.messages || jsonb_build_array(p_response_message)");
const store = migration.indexOf("set response_message = p_response_message");
@@ -10,8 +10,8 @@ const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const messageRow = readFileSync(
new URL("../src/components/chat-message-row.tsx", import.meta.url),
const activityStatus = readFileSync(
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
"utf8",
);
const messageActions = readFileSync(
@@ -300,9 +300,9 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
assert.match(chat, /event.type === "thinking.delta"/);
assert.match(messageRow, /className="message-thinking"/);
assert.match(messageRow, /思考过程/);
assert.match(messageRow, /userOpen \?\? !hasAnswer/);
assert.match(activityStatus, /className="message-thinking"/);
assert.match(activityStatus, /思考过程/);
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
assert.match(styles, /\.message-thinking-body/);
});
+7 -1
View File
@@ -116,7 +116,11 @@ test("uses one collapsed history action instead of icon-only session rows", () =
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /MessageSquareText/);
assert.match(appSidebar, /state === "collapsed" && !isMobile/);
assert.match(appSidebar, /sessions\.map/);
assert.match(appSidebar, /favoriteSessions\.map/);
assert.match(appSidebar, /historySessions\.map/);
assert.match(appSidebar, /星盘列表/);
assert.match(appSidebar, /收藏对话/);
assert.match(appSidebar, /历史对话/);
assert.doesNotMatch(appSidebar, /sessions\.map\([^)]*\)\s*=>\s*<[^>]+aria-label=/);
});
@@ -151,6 +155,8 @@ test("keeps app sidebar props as product data and callbacks", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
assert.match(appSidebar, /export type AppSidebarProps/);
assert.match(appSidebar, /onSelectSession: \(sessionId: string\) => void/);
assert.match(appSidebar, /onSelectChart: \(chartId: string\) => void/);
assert.match(appSidebar, /charts: readonly SidebarChart\[\]/);
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
});