Files
Jyotisha/frontend/tests/rectification-in-chat-20261007.test.ts
T
jesseandClaude Opus 5.5 dfd3208979
Independent Staging Quality Gate / validate (push) Failing after 13m25s
Independent Staging Quality Gate / publish (push) Skipped
feat: standalone rectification entry at /rectification (issue #145)
- Sidebar 「生时校正」 under 星盘, behind a new account `rectification` flag.
- /rectification page: status card from entry-summary (not started / in
  progress with window and confirmed event count / adopted time), a short
  data check, then the home rectification surface (?rectify=1, or
  ?rectify=new to start over; ?c=<id> to continue or view). No price on
  the page.
- entry-summary gains session id, candidate range, confirmed evidence count
  and adopted times (add-only migration 20261028170000).
- The offer card under a consultation answer becomes one line and a link
  to /rectification; an explicit request in chat also goes there. No case
  is opened from a conversation any more.
- Starter home shows a rectification prompt for self charts with an
  estimated time; onboarding checklist gains 做一次生时校正 for them.
- Chart-page link points to /rectification. Shell registers its router so
  home hooks can navigate client-side without useRouter in Home().
- Docs: VOICE, DESIGN, CHANGELOG, device checklist.

Self profile only; other people are issue #160.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:50:55 +08:00

369 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TASK-rectification-in-chat-step1-20261006 (BUG-1247~1251): rectification
// starts from an offer card under a consultation answer and comes back to that
// consultation. Fictional data only.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import test from "node:test";
import {
RECTIFICATION_OFFER_RULE,
followUpAnswerShapeInstruction,
natalAnswerShapeBody,
} from "../src/lib/consultation-thinking-plan.ts";
import {
BIRTH_TIME_CHANGED_HISTORY_NOTE,
RECTIFICATION_OFFER_TOOL_ID,
blocksNewRectification,
appendRectificationReturn,
bindOfferToCase,
decideRectificationOfferWith,
lastBirthTimeChangeIndex,
revisitQuestionForOffer,
sessionHasRectificationOffer,
sourceFromSessionRows,
type RectificationOfferRecord,
} from "../src/lib/rectification-in-chat.ts";
import { consultationHistoryWindow } from "../src/lib/consultation-session-history.ts";
import { consultationAgentPublicEventSchema } from "../src/lib/consultation-agent-events.ts";
import { createConsultationRuntimeState, createConsultationTools } from "../src/mastra/consultation-tools.ts";
import {
ChatTranscript,
type ChatTranscriptActions,
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
const CASE_ID = "11111111-1111-4111-8111-111111111111";
const RECT_SESSION = "22222222-2222-4222-8222-222222222222";
const SOURCE_SESSION = "33333333-3333-4333-8333-333333333333";
function offer(status: RectificationOfferRecord["status"] = "offered", extra: Partial<RectificationOfferRecord> = {}): RectificationOfferRecord {
return {
status,
creditCost: 3,
questionEstimate: 8,
sourceQuestion: "我和伴侣还能走下去吗",
offeredAt: "2026-10-07T00:00:00.000Z",
...extra,
};
}
function conversation(): Record<string, unknown>[] {
return [
{ role: "user", text: "我和伴侣还能走下去吗" },
{ role: "assistant", text: "虚构的第一段回答。" },
{ role: "user", text: "你说我2019年换工作,其实那年什么都没发生" },
{ role: "assistant", text: "虚构的第二段回答。", rectificationOffer: offer() },
];
}
test("binding moves the latest offered card to opened, once per case (C4, BUG-1249)", () => {
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
assert.ok(bound);
assert.deepEqual(bound![3]!.rectificationOffer, offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }));
assert.equal(bindOfferToCase(bound, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }), null, "idempotent");
assert.equal(bindOfferToCase([{ role: "assistant", text: "没有提议" }], { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }), null);
assert.equal(sessionHasRectificationOffer(conversation()), true);
assert.equal(sessionHasRectificationOffer([{ role: "assistant", text: "x" }]), false);
});
test("coming back appends one divider and marks the offer returned (C5/C6, BUG-1250)", () => {
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const adopted = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: true, clock: "08:42", returnedAt: "2026-10-07T01:00:00.000Z" });
assert.ok(adopted);
const divider = adopted!.messages.at(-1)!;
assert.equal(divider.role, "assistant");
assert.equal(divider.text, "出生时间改为 08:42(生时校正)");
assert.deepEqual(adopted!.record, {
caseId: CASE_ID,
adopted: true,
clock: "08:42",
sourceQuestion: "我和伴侣还能走下去吗",
returnedAt: "2026-10-07T01:00:00.000Z",
});
assert.equal((adopted!.messages[3]!.rectificationOffer as RectificationOfferRecord).status, "returned");
assert.equal(appendRectificationReturn(adopted!.messages, { caseId: CASE_ID, adopted: true, clock: "08:42", returnedAt: "x" }), null, "idempotent");
const unchanged = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: false, clock: null, returnedAt: "2026-10-07T01:00:00.000Z" });
assert.equal(unchanged!.messages.at(-1)!.text, "出生时间没有改");
assert.equal(unchanged!.record.adopted, false);
assert.equal(appendRectificationReturn(conversation(), { caseId: CASE_ID, adopted: true, clock: "08:42", returnedAt: "x" }), null, "no bound offer, no divider");
assert.equal(lastBirthTimeChangeIndex(adopted!.messages), adopted!.messages.length - 1);
assert.equal(lastBirthTimeChangeIndex(unchanged!.messages), -1);
});
test("the source of a case is found among the user's consultations; history cases have none (BUG-1249)", () => {
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const rows = [{ id: "other", messages: [{ role: "user", text: "x" }] }, { id: SOURCE_SESSION, messages: bound }];
assert.deepEqual(sourceFromSessionRows(rows, CASE_ID), { sessionId: SOURCE_SESSION, question: "我和伴侣还能走下去吗", returned: null });
assert.equal(sourceFromSessionRows(rows, "44444444-4444-4444-8444-444444444444"), null);
});
test("the question to look at again is the one before the dispute (C4)", () => {
assert.equal(revisitQuestionForOffer(conversation(), "现在这句"), "你说我2019年换工作,其实那年什么都没发生");
assert.equal(revisitQuestionForOffer([], "我的事业接下来怎么走"), "我的事业接下来怎么走");
});
test("the server decides whether a card may be shown: self, product on, no case in progress, real price (C3, BUG-1248)", async () => {
const yes = async () => true;
const no = async () => false;
const price = async () => 3;
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "other", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: false, reason: "not_self" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" });
// 原值:只要摘要说有可续做校正,就判 case_in_progress
// 新值:已采用(candidate_accepted)不再挡住入口;collecting_evidence 仍挡住
// 原因:已采用是终点。入口改看 blocksNewRectification,不改共享状态集合
assert.deepEqual(await decideRectificationOfferWith({
subjectRole: "self",
productEnabled: yes,
hasResumableCase: async () => blocksNewRectification("candidate_accepted"),
creditCost: price,
}), { ok: true, creditCost: 3, questionEstimate: 8 });
assert.deepEqual(await decideRectificationOfferWith({
subjectRole: "self",
productEnabled: yes,
hasResumableCase: async () => blocksNewRectification("collecting_evidence"),
creditCost: price,
}), { ok: false, reason: "case_in_progress" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" });
assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 });
});
function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) {
const state = createConsultationRuntimeState();
// 原值:建出来还没排盘也能直接提议。
// 新值:这些用例表示已经看过盘,所以先标计算完成。
// 原因:BUG-1317,还没排盘的顺口提议不算已经提议过。
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
const tools = createConsultationTools({
userId: "user-fictional",
sessionId: SOURCE_SESSION,
requestId: "55555555-5555-4555-8555-555555555555",
consultationMode: "verified_chart",
serverChart: {} as never,
state,
rectificationOffer: {
decide: options.decide ?? (async () => ({ ok: true, creditCost: 3, questionEstimate: 8 })),
alreadyOffered: options.alreadyOffered ?? false,
revisitQuestion: "我和伴侣还能走下去吗",
sourceSessionId: SOURCE_SESSION,
loadResumableSessionId: async () => null,
now: () => new Date("2026-10-07T00:00:00.000Z"),
},
}) as Record<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
return { state, tool: tools[RECTIFICATION_OFFER_TOOL_ID]! };
}
test("the offer tool records one offer per turn and never re-offers unasked in the same session (C2/C3)", async () => {
const fresh = offerTool();
const result = await fresh.tool.execute({});
assert.equal(result.offered, true);
assert.deepEqual(fresh.state.rectificationOffer, offer());
assert.equal((await fresh.tool.execute({})).offered, true, "a second call in the same turn keeps the one card");
const declined = offerTool({ alreadyOffered: true });
const again = await declined.tool.execute({});
assert.equal(again.offered, false);
assert.equal(again.reason, "already_offered_in_session");
assert.equal(declined.state.rectificationOffer, undefined);
assert.equal((await declined.tool.execute({ userAsked: true })).offered, true, "the user asking again opens it");
const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const refused = await family.tool.execute({});
assert.equal(refused.offered, false);
assert.match(String(refused.note), /给家人校正暂时还不支持/);
assert.equal(family.state.rectificationOffer, undefined);
});
test("the completed event carries the offer, so the card appears without a reload (BUG-1248)", () => {
const event = consultationAgentPublicEventSchema.parse({
type: "run.completed",
receipt: {
runId: "run-1",
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
steps: [],
workflow: { route: "marriage", status: "ready", preciseTiming: "allowed", missingLayers: [] },
},
rectificationOffer: offer(),
});
assert.equal(event.type === "run.completed" && event.rectificationOffer?.creditCost, 3);
});
test("history: earlier answers carry the old-time note after a change, the divider is not a turn (C7, BUG-1251)", () => {
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const returned = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: true, clock: "08:42", returnedAt: "x" })!.messages;
const window = consultationHistoryWindow([...returned, { role: "user", text: "我和伴侣还能走下去吗" }], null);
assert.equal(window.tail.some((turn) => turn.text.includes("出生时间改为")), false);
const answers = window.tail.filter((turn) => turn.role === "assistant");
assert.ok(answers.length === 2 && answers.every((turn) => turn.text.startsWith(BIRTH_TIME_CHANGED_HISTORY_NOTE)));
assert.equal(window.tail.filter((turn) => turn.role === "user").every((turn) => !turn.text.startsWith("(")), true);
const unchanged = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: false, clock: null, returnedAt: "x" })!.messages;
const plain = consultationHistoryWindow(unchanged, null);
assert.equal(plain.tail.some((turn) => turn.text.includes(BIRTH_TIME_CHANGED_HISTORY_NOTE)), false);
const summary = { version: 1 as const, text: "虚构摘要", throughRequestId: "r", throughMessageIndex: 1, messageCount: 2, updatedAt: "x" };
assert.ok(consultationHistoryWindow(returned, summary).summaryText?.startsWith(BIRTH_TIME_CHANGED_HISTORY_NOTE));
});
test("the C2 rule is defined once and reaches first turns, follow-ups and declared-window turns (BUG-1248)", () => {
assert.ok(natalAnswerShapeBody().includes(RECTIFICATION_OFFER_RULE));
assert.ok(followUpAnswerShapeInstruction().includes(RECTIFICATION_OFFER_RULE));
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
// 原值:/按纠正规则认下、改读,不提校正/
// 新值:/按改读规则认下、改读,不提校正/
// 原因:BUG-1317,避免「生时纠正」被读成「用户在纠正解读」。
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按改读规则认下、改读,不提校正/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
});
function transcriptProps(messages: ChatMessage[], calls: string[]): ChatTranscriptProps {
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
actionsRef.current = {
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp(question) { calls.push(`follow:${question}`); },
onStartRectification(sessionId) { calls.push(`start:${sessionId}`); },
};
return {
messages,
loading: false,
streamingText: "",
sessionId: SOURCE_SESSION,
sessionType: "consultation",
theme: "marriage",
messageFeedback: {},
copiedMessageKey: null,
cancellationPending: false,
productEntrypointsDisabled: false,
actionsRef,
};
}
test("the transcript shows the offer card, the divider, the old-time caption and the revisit button (P2, BUG-1248/1250)", () => {
const offered = conversation() as ChatMessage[];
const card = renderToString(createElement(ChatTranscript, transcriptProps(offered, [])));
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
assert.match(card, /rectification-offer-card/);
assert.match(card, /出生时间还不确定。/);
assert.match(card, /href="\/rectification"/);
assert.doesNotMatch(card, /会用掉/);
assert.doesNotMatch(card, /按校正前的时间/);
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const returned = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: true, clock: "08:42", returnedAt: "x" })!.messages as ChatMessage[];
const after = renderToString(createElement(ChatTranscript, transcriptProps(returned, [])));
assert.doesNotMatch(after, /rectification-offer-card/, "a returned offer has no card");
assert.match(after, /出生时间改为 08:42(生时校正)/);
assert.match(after, /按新时间重新看:「我和伴侣还能走下去吗」/);
assert.equal(after.match(/message-before-change/g)?.length, 2, "both earlier answers carry the caption");
const notAdopted = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: false, clock: null, returnedAt: "x" })!.messages as ChatMessage[];
const plain = renderToString(createElement(ChatTranscript, transcriptProps(notAdopted, [])));
assert.match(plain, /出生时间没有改/);
assert.doesNotMatch(plain, /按新时间重新看|message-before-change/);
});
test("nothing is sent until the revisit button is clicked; the offer opens the case for this consultation (C6)", () => {
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const cards = readFileSync(new URL("../src/components/rectification-in-chat-cards.tsx", import.meta.url), "utf8");
assert.match(transcript, /onRevisit=\{\(question\) => actionsRef\.current\.onFollowUp\(question\)\}/);
assert.match(transcript, /onStart=\{\(\) => actionsRef\.current\.onStartRectification\(sessionId\)\}/);
assert.match(cards, /onClick=\{\(\) => onRevisit\(record\.sourceQuestion\)\}/);
assert.doesNotMatch(cards, /useEffect/);
const surface = readFileSync(new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), "utf8");
assert.match(surface, /openRectificationCase\("homepage", null, sourceSessionId\)/);
assert.doesNotMatch(surface.slice(surface.indexOf("async function returnToRectificationSource")), /\/api\/consult/);
});
test("auto-return only follows work done in this view; history cases stay on screen (C5, BUG-621)", () => {
const hook = readFileSync(new URL("../src/hooks/use-rectification-return.ts", import.meta.url), "utf8");
assert.match(hook, /if \(input\.busy\) actedInView\.current = true;/);
assert.match(hook, /!actedInView\.current \|\| fired\.current/);
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /\{canReturnToSource && \(/);
assert.match(chat, /ready: questionGap === "verified_idle" \|\| isTerminalRectificationStatus\(caseStatus \?\? ""\)/);
const surface = readFileSync(new URL("../src/hooks/use-rectification-surface.ts", import.meta.url), "utf8");
assert.match(surface, /canReturnToSource: Boolean\(rectificationSource && rectificationSource\.caseId === rectificationCaseId\)/);
});
test("the open route binds the offer only for a consultation source; the source route reads and returns (C4/C5)", () => {
const open = readFileSync(new URL("../src/app/api/rectification/cases/open/route.ts", import.meta.url), "utf8");
assert.match(open, /if \(parsed\.intent === "homepage" && parsed\.sourceSessionId\) \{\s*await bindRectificationSource\(/);
const source = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/source/route.ts", import.meta.url), "utf8");
assert.match(source, /export async function GET/);
assert.match(source, /export async function POST/);
const server = readFileSync(new URL("../src/lib/rectification-in-chat-server.ts", import.meta.url), "utf8");
assert.match(server, /\.eq\("session_type", "consultation"\)/);
assert.match(server, /const accepted = dossier\.case\.acceptedTime\?\.slice\(0, 5\) \?\? null;/);
assert.doesNotMatch(server, /createAdminSupabaseClient/);
});
test("an auto-open offer shows the ordinary card until the case is bound, and a reload keeps autoOpen", async () => {
const pending = offer("offered", { autoOpen: true });
const waiting = [
{ role: "user" as const, text: "请帮我核对出生时间" },
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
];
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
assert.match(html, /href="\/rectification"/);
assert.doesNotMatch(html, /会用掉/);
assert.doesNotMatch(html, /出生时间核对已经开始/);
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
assert.match(openedHtml, /href="\/rectification"/);
assert.doesNotMatch(openedHtml, /出生时间核对已经开始/);
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const failed = waiting;
assert.equal((failed[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
const reloaded = readSessions([{
id: SOURCE_SESSION,
title: "虚构",
theme: "marriage",
messages: [{ role: "assistant", text: "还没打开", rectificationOffer: pending }],
}], null);
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.status, "offered");
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, true);
const old = readSessions([{
id: SOURCE_SESSION,
title: "虚构",
theme: "marriage",
messages: [
{ role: "assistant", text: "a", rectificationOffer: offer("offered") },
{ role: "assistant", text: "b", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
{ role: "assistant", text: "c", rectificationOffer: offer("returned", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
],
}], null);
assert.deepEqual(old.sessions[0]!.messages.map((message) => message.rectificationOffer?.status), ["offered", "opened", "returned"]);
assert.equal(old.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, undefined);
const returnedHtml = renderToString(createElement(ChatTranscript, transcriptProps(old.sessions[0]!.messages.slice(2) as ChatMessage[], [])));
assert.doesNotMatch(returnedHtml, /rectification-offer-card/);
});
test("stored sessions keep the offer and the divider when the browser reads them back (BUG-1248/1250)", async () => {
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const returned = appendRectificationReturn(bound, { caseId: CASE_ID, adopted: true, clock: "08:42", returnedAt: "x" })!.messages;
const { sessions } = readSessions([{ id: SOURCE_SESSION, title: "虚构", theme: "marriage", messages: [...returned, { role: "user", text: "x", rectificationOffer: offer() }] }], null);
const messages = sessions[0]!.messages;
assert.equal(messages[3]!.rectificationOffer?.status, "returned");
assert.equal(messages[4]!.rectificationReturn?.clock, "08:42");
assert.equal(messages[5]!.rectificationOffer, undefined, "only assistant messages carry an offer");
});