// 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, 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 { return { status, creditCost: 3, questionEstimate: 8, sourceQuestion: "我和伴侣还能走下去吗", offeredAt: "2026-10-07T00:00:00.000Z", ...extra, }; } function conversation(): Record[] { 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" }); 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(); 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: "我和伴侣还能走下去吗", now: () => new Date("2026-10-07T00:00:00.000Z"), }, }) as Record Promise> }>; 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;/); assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/); assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/); }); function transcriptProps(messages: ChatMessage[], calls: string[]): ChatTranscriptProps { const actionsRef = createRef() 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, []))); assert.match(card, /rectification-offer-card/); assert.match(card, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/); assert.match(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("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"); });