TASK-rectification-in-chat-step1-20261006, direct execution.
- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
definition) and calls offer-birth-time-rectification; the server decides
whether a card may show (self, product on, no case in progress,
feature_pricing price). The offer is stored with the answer and travels on
run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
find the source and go back: one divider (time changed or not), earlier
answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
when clicked. Auto-return after the post-adopt check or a terminal case, only
for work done in this view.
- Model history skips the divider and notes answers written with the old time.
Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
290 lines
17 KiB
TypeScript
290 lines
17 KiB
TypeScript
// 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> = {}): 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" });
|
||
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<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;/);
|
||
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, [])));
|
||
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");
|
||
});
|