Files
Jyotisha/frontend/tests/rectification-explicit-entry-20261012.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

849 lines
41 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.
// BUG-1306: an explicit request opens rectification directly. Fictional data only.
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { RectificationOfferCard } from "../src/components/rectification-in-chat-cards.tsx";
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
import {
blocksNewRectification,
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
offerForCase,
parseRectificationOfferRecord,
sourceFromSessionRows,
type RectificationOfferRecord,
} from "../src/lib/rectification-in-chat.ts";
import { CONTRACT_DEGRADED_NOTE, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
import type { Pass4Mode } from "../src/lib/timing-output-guard.ts";
import {
consultationStepBudgetReceipt,
createConsultationRuntimeState,
createConsultationTools,
publicConsultationRuntimeSteps,
} from "../src/mastra/consultation-tools.ts";
const CASE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_CASE = "55555555-5555-4555-8555-555555555555";
const RECT_SESSION = "22222222-2222-4222-8222-222222222222";
const SOURCE_SESSION = "33333333-3333-4333-8333-333333333333";
const RESUME_SESSION = "44444444-4444-4444-8444-444444444444";
const REQUEST_ID = "66666666-6666-4666-8666-666666666666";
const DIRECT_SENTENCES = [
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
] as const;
const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
type OfferDecision =
| { ok: true; creditCost: number; questionEstimate: number }
| { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" };
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 offerTool(options: {
alreadyOffered?: boolean;
decide?: () => Promise<OfferDecision>;
loadResumableSessionId?: () => Promise<string | null>;
/** False only for a step-0 call that has not seen the chart yet. */
chartReady?: boolean;
} = {}) {
const state = createConsultationRuntimeState();
if (options.chartReady !== false) {
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
}
let decisions = 0;
let loads = 0;
const tools = createConsultationTools({
userId: "user-fictional",
sessionId: SOURCE_SESSION,
requestId: REQUEST_ID,
consultationMode: "verified_chart",
serverChart: {} as never,
state,
rectificationOffer: {
decide: async () => {
decisions += 1;
return options.decide ? options.decide() : { ok: true, creditCost: 3, questionEstimate: 8 };
},
alreadyOffered: options.alreadyOffered ?? false,
revisitQuestion: "我和伴侣还能走下去吗",
sourceSessionId: SOURCE_SESSION,
loadResumableSessionId: async () => {
loads += 1;
return options.loadResumableSessionId ? options.loadResumableSessionId() : null;
},
now: () => new Date("2026-10-07T00:00:00.000Z"),
},
}) as Record<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
return { state, decisions: () => decisions, loads: () => loads, tool: tools[RECTIFICATION_OFFER_TOOL_ID]! };
}
function src(relative: string) {
return readFileSync(new URL(relative, import.meta.url), "utf8");
}
function walkSources(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walkSources(full));
else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
test("direct-entry sentences stay within the free-completion length", () => {
// 原值:[13, 13, 14, 11, 11],含「可在历史对话里接着做。」
// 新值:[13, 13, 14, 11]
// 原因:接不回进行中的校正时改为新开,不再留这句没有出口的兜底
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11]);
for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20);
assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。");
assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。");
assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。");
assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持");
assert.equal(blocksNewRectification("candidate_accepted"), false);
assert.equal(blocksNewRectification("collecting_evidence"), true);
assert.equal(blocksNewRectification("draft"), true);
assert.equal(blocksNewRectification("candidate_ready"), true);
assert.equal(blocksNewRectification("needs_rebaseline"), true);
assert.equal(blocksNewRectification("paused"), true);
assert.equal(blocksNewRectification("confirmed"), false);
});
test("an opened offer with no case id binds, and a reload can find that case", () => {
const opened = [{
role: "assistant",
text: RECTIFICATION_DIRECT_NEW_COPY,
responseKind: "smalltalk",
rectificationOffer: offer("opened"),
}];
assert.equal(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: opened }], CASE_ID), null);
const bound = bindOfferToCase(opened, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
assert.ok(bound);
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID);
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).rectificationSessionId, RECT_SESSION);
assert.equal(bindOfferToCase(bound, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }), null);
assert.ok(offerForCase(bound, CASE_ID));
assert.deepEqual(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: bound }], CASE_ID), {
sessionId: SOURCE_SESSION,
question: "我和伴侣还能走下去吗",
returned: null,
});
});
test("binding leaves another case's opened record alone and still binds a later card", () => {
const messages = [
{ role: "assistant", text: "旧卡", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) },
{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: offer("opened") },
];
assert.equal(bindOfferToCase(
[{ role: "assistant", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) }],
{ caseId: CASE_ID, rectificationSessionId: RECT_SESSION },
), null);
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
assert.ok(bound);
assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, OTHER_CASE);
assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID);
assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
});
test("the free reply can carry the opened offer once, without clobbering a case id", () => {
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
const card = offer("opened");
assert.equal(attachOpenedOfferToFreeMessage([], { sentence, offer: card }), "missing");
assert.equal(attachOpenedOfferToFreeMessage(
[{ role: "assistant", text: sentence, responseKind: "consultation" }],
{ sentence, offer: card },
), "missing");
const attached = attachOpenedOfferToFreeMessage(
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
{ sentence, offer: card },
);
assert.ok(Array.isArray(attached));
assert.deepEqual(attached[1]!.rectificationOffer, card);
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
const withCase = attachOpenedOfferToFreeMessage(
[{ role: "assistant", text: sentence, responseKind: "smalltalk", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) }],
{ sentence, offer: card },
);
assert.equal(withCase, "present");
});
test("explicit pass opens once, records an offered auto-open offer, and does not open again", async () => {
const fresh = offerTool();
const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true });
assert.equal(result.offered, true);
assert.equal(result.directEntry, "new");
assert.match(String(result.note), /好,我们开始核对出生时间/);
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
assert.equal(fresh.state.rectificationOffer?.status, "offered");
assert.equal(fresh.state.rectificationOffer?.autoOpen, true);
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
assert.deepEqual(fresh.state.rectificationDirectEntry, {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: SOURCE_SESSION,
});
assert.equal(fresh.loads(), 0);
assert.equal(fresh.decisions(), 1);
const again = await fresh.tool.execute({ explicitRequest: true });
assert.equal(again.offered, false);
assert.match(String(again.note), /已经在打开/);
assert.match(String(again.note), /不要再写解读/);
assert.equal(fresh.decisions(), 1);
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
});
test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => {
const direct = offerTool({ alreadyOffered: true });
const opened = await direct.tool.execute({ explicitRequest: true });
assert.equal(opened.directEntry, "new");
assert.equal(direct.state.rectificationOffer?.status, "offered");
assert.equal(direct.state.rectificationOffer?.autoOpen, true);
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
const card = offerTool({ alreadyOffered: true });
const asked = await card.tool.execute({ userAsked: true });
assert.equal(asked.offered, true);
assert.equal(card.state.rectificationOffer?.status, "offered");
assert.equal(card.state.rectificationDirectEntry, undefined);
const held = offerTool();
await held.tool.execute({});
const second = await held.tool.execute({ explicitRequest: true });
assert.match(String(second.note), /提议卡已经在了/);
assert.equal(held.state.rectificationDirectEntry, undefined);
assert.equal(held.state.rectificationOffer?.status, "offered");
assert.equal(held.decisions(), 1);
});
async function withResumeWarnings(run: () => Promise<void>): Promise<unknown[][]> {
const warnings: unknown[][] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args); };
try {
await run();
} finally {
console.warn = original;
}
return warnings;
}
test("an in-progress case resumes that session, or opens a new one when the session cannot be read", async () => {
const resume = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => RESUME_SESSION,
});
const resumed = await resume.tool.execute({ explicitRequest: true });
assert.equal(resumed.offered, false);
assert.equal(resumed.directEntry, "resume");
assert.equal(resume.state.rectificationOffer, undefined);
assert.deepEqual(resume.state.rectificationDirectEntry, {
kind: "resume",
sentence: RECTIFICATION_DIRECT_RESUME_COPY,
sourceSessionId: SOURCE_SESSION,
resumeSessionId: RESUME_SESSION,
});
assert.equal(resume.loads(), 1);
// 原值:loader 返回 null 时 kind=refused,句子是「可在历史对话里接着做。」
// 新值:kind=new,句子是「好,我们开始核对出生时间。」,并记一条不含原文的日志
// 原因:没有会话号时必须仍能打开一次校正
const missingWarnings = await withResumeWarnings(async () => {
const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
const opened = await missing.tool.execute({ explicitRequest: true });
assert.equal(opened.directEntry, "new");
assert.equal(missing.state.rectificationDirectEntry?.kind, "new");
assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(missing.state.rectificationOffer, undefined);
assert.match(String(opened.note), /好,我们开始核对出生时间/);
assert.doesNotMatch(String(opened.note), /历史对话/);
});
assert.equal(missingWarnings.length, 1);
assert.equal(missingWarnings[0]?.[0], "rectification_resume_lookup_failed");
assert.deepEqual(missingWarnings[0]?.[1], { requestId: REQUEST_ID, errorCategory: "missing_session", hasCaseId: false });
const thrownWarnings = await withResumeWarnings(async () => {
const thrown = offerTool({
decide: async () => ({ ok: false, reason: "case_in_progress" }),
loadResumableSessionId: async () => { throw new Error("db down"); },
});
const fallback = await thrown.tool.execute({ explicitRequest: true });
assert.equal(thrown.state.rectificationDirectEntry?.kind, "new");
assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(fallback.directEntry, "new");
assert.doesNotMatch(JSON.stringify(fallback), /db down/);
});
assert.equal(thrownWarnings.length, 1);
const thrownPayload = JSON.stringify(thrownWarnings[0]);
assert.match(thrownPayload, /lookup_failed/);
assert.match(thrownPayload, new RegExp(REQUEST_ID));
assert.doesNotMatch(thrownPayload, /db down/);
const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
const ordinary = await quiet.tool.execute({});
assert.equal(quiet.loads(), 0);
assert.equal(quiet.state.rectificationDirectEntry, undefined);
// 原值:普通提议的说明匹配 /历史对话/
// 新值:说明这次不出卡,不提历史对话
// 原因:那句兜底已经删掉;没做完时仍不出新卡
assert.match(String(ordinary.note), /不出提议卡/);
assert.doesNotMatch(String(ordinary.note), /历史对话/);
assert.doesNotMatch(String(ordinary.note), /不要提校正/);
});
test("explicit refusals are one sentence, and none of the four notes says to hide rectification", async () => {
const unavailable = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
const disabled = await unavailable.tool.execute({ explicitRequest: true });
assert.equal(unavailable.state.rectificationDirectEntry?.kind, "refused");
assert.equal(unavailable.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY);
assert.equal(unavailable.state.rectificationOffer, undefined);
assert.match(String(disabled.note), /出生时间核对这会儿还做不了/);
assert.match(String(disabled.note), /不写内部原因/);
assert.doesNotMatch(String(disabled.note), /不要提校正/);
const unpriced = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
const priced = await unpriced.tool.execute({ explicitRequest: true });
assert.equal(unpriced.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY);
assert.match(String(priced.note), /出生时间核对这会儿还做不了/);
assert.match(String(priced.note), /不写价格/);
assert.doesNotMatch(String(priced.note), /不要提校正/);
const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const other = await family.tool.execute({ explicitRequest: true });
assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NOT_SELF_COPY);
assert.match(String(other.note), /给家人校正暂时还不支持/);
assert.doesNotMatch(String(other.note), /不要提校正/);
const plainFamily = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) });
const plain = await plainFamily.tool.execute({});
assert.equal(plainFamily.state.rectificationDirectEntry, undefined);
assert.doesNotMatch(String(plain.note), /不要提校正/);
const plainPrice = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) });
assert.doesNotMatch(String((await plainPrice.tool.execute({})).note), /不要提校正/);
const plainOff = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) });
assert.doesNotMatch(String((await plainOff.tool.execute({})).note), /不要提校正/);
assert.equal(plainPrice.state.rectificationDirectEntry, undefined);
assert.equal(plainOff.state.rectificationDirectEntry, undefined);
});
function receipt(state: ReturnType<typeof createConsultationRuntimeState>): AgentExecutionReceipt {
return {
runId: "run-direct",
runtime: "mastra-agentic",
skill: {
name: "jyotish-vedic-astrology",
loaded: state.jyotishSkillBound,
referenceReads: state.skillReferenceReadCount,
methodologySections: state.methodologySectionCount,
},
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
};
}
function visibleAnswer(events: readonly ConsultationAgentPublicEvent[]) {
let text = "";
for (const event of events) {
if (event.type !== "answer.delta") continue;
text = event.replace ? event.text : text + event.text;
}
return text;
}
async function readStream(response: Response) {
const events: ConsultationAgentPublicEvent[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
return events;
}
test("natal and window direct entry or explicit refusal skips the runtime-contract retry", async () => {
const modes: Pass4Mode[] = ["verified_chart", "declared_birth_window"];
const kinds = ["new", "resume", "refused"] as const;
for (const pass4Mode of modes) {
for (const kind of kinds) {
const state = createConsultationRuntimeState();
const sentence = kind === "new"
? RECTIFICATION_DIRECT_NEW_COPY
: kind === "resume"
? RECTIFICATION_DIRECT_RESUME_COPY
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
let contractRetries = 0;
let answerRetries = 0;
let warmupRan = false;
let completedOutput = "";
const response = streamAgentResponse({
runId: "run-direct",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode,
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
...(pass4Mode === "declared_birth_window"
? {
warmup: async (send: (event: ConsultationAgentPublicEvent) => void) => {
warmupRan = true;
send({ type: "activity", phase: "chart-calculation", label: "窗口预热" });
},
}
: {}),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "我先看看盘。" } };
yield { type: "tool-call", payload: { toolCallId: "offer-1", toolName: "offer-birth-time-rectification" } };
state.rectificationDirectEntry = {
kind,
sentence,
sourceSessionId: SOURCE_SESSION,
...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}),
};
if (kind === "new") state.rectificationOffer = offer("offered", { autoOpen: true });
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
retryForAnswer: async () => {
answerRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
onComplete: (output) => { completedOutput = output; },
});
const events = await readStream(response);
assert.equal(contractRetries, 0, `${pass4Mode} ${kind} retried the contract`);
assert.equal(answerRetries, 0, `${pass4Mode} ${kind} retried the answer`);
assert.equal(state.consultationToolCompleted, false);
assert.equal(visibleAnswer(events), sentence);
assert.equal(visibleAnswer(events).includes("我先看看盘"), false);
assert.equal(visibleAnswer(events).includes("然后写一段解读"), false);
assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false);
assert.equal(completedOutput, sentence);
if (pass4Mode === "declared_birth_window") assert.equal(warmupRan, true);
const completed = events.filter((event) => event.type === "run.completed");
assert.equal(completed.length, 1);
const done = completed[0]!;
assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk");
assert.equal(done.type === "run.completed" && done.receipt, undefined);
if (kind === "new") {
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "offered");
assert.equal(done.type === "run.completed" && done.rectificationOffer?.autoOpen, true);
assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined);
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
sourceSessionId: SOURCE_SESSION,
mode: "new",
});
} else if (kind === "resume") {
assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined);
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
sourceSessionId: SOURCE_SESSION,
mode: "resume",
resumeSessionId: RESUME_SESSION,
});
} else {
assert.equal(done.type === "run.completed" && done.rectificationDirectOpen, undefined);
assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined);
}
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), false);
}
}
});
test("a reading that already streamed is replaced by the direct-entry sentence", async () => {
for (const pass4Mode of ["verified_chart", "declared_birth_window"] as const) {
const state = createConsultationRuntimeState();
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-direct",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode,
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
yield { type: "step-finish", payload: { stepResult: { reason: "stop" } } };
state.rectificationDirectEntry = {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: SOURCE_SESSION,
};
state.rectificationOffer = offer("offered", { autoOpen: true });
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 0);
assert.equal(events.some((event) => event.type === "answer.delta" && event.text.includes("事业方向的判断如下")), true);
assert.equal(events.some((event) => event.type === "answer.delta" && event.replace === true && event.text === RECTIFICATION_DIRECT_NEW_COPY), true);
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
}
});
test("an ordinary consultation still retries once when the calculation never finished", async () => {
const state = createConsultationRuntimeState();
let contractRetries = 0;
let answerRetries = 0;
const response = streamAgentResponse({
runId: "run-direct",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "还没排盘。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
return (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})();
},
retryForAnswer: async () => {
answerRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 1);
assert.equal(answerRetries, 0);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false);
assert.equal(visibleAnswer(events).includes("还没排盘"), false);
const done = events.find((event) => event.type === "run.completed");
assert.equal(done?.type === "run.completed" && done.receipt !== undefined, true);
assert.equal(done?.type === "run.completed" && done.responseKind, undefined);
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("step 0 explicit request opens rectification without calculating a chart", async () => {
const fresh = offerTool({ chartReady: false });
const result = await fresh.tool.execute({ explicitRequest: true });
assert.equal(result.directEntry, "new");
assert.equal(fresh.state.consultationToolCallCount, 0);
assert.equal(fresh.state.consultationToolCompleted, false);
assert.equal(fresh.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-explicit",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "我先看看盘,然后写一段解读。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 0);
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(events.some((event) => event.type === "run.completed" && event.responseKind === "smalltalk"), true);
});
test("step 0 text without a tool still retries into a chart answer", async () => {
const state = createConsultationRuntimeState();
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-empty",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "还没排盘就想写。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
return (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 1);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(state.rectificationDirectEntry, undefined);
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("a soft offer before the chart is not recorded, then a chart answer can still offer", async () => {
const fresh = offerTool({ chartReady: false });
const early = await fresh.tool.execute({});
assert.equal(early.offered, false);
assert.match(String(early.note), /先排盘,看过盘再决定要不要提/);
// 记在局部上再断言。直接断言 state 上的字段会把它收成永远是空,排盘后再读就对不上类型。
const offerBeforeChart = fresh.state.rectificationOffer;
assert.equal(offerBeforeChart, undefined);
assert.equal(fresh.state.rectificationDirectEntry, undefined);
assert.equal(fresh.decisions(), 0);
fresh.state.consultationToolCompleted = true;
fresh.state.consultationToolSuccessCount = 1;
const later = await fresh.tool.execute({});
assert.equal(later.offered, true);
const offerAfterChart = fresh.state.rectificationOffer;
assert.equal(offerAfterChart?.status, "offered");
assert.equal(offerAfterChart?.autoOpen, undefined);
assert.equal(fresh.decisions(), 1);
const response = streamAgentResponse({
runId: "run-step0-soft",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})(),
retry: async () => {
throw new Error("chart contract should already be ready");
},
});
const events = await readStream(response);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(visibleAnswer(events).includes(RECTIFICATION_DIRECT_NEW_COPY), false);
});
test("the offer rule is defined once, and direct entry settles with the free completion", () => {
const definitions = walkSources(fileURLToPath(new URL("../src/", import.meta.url))).filter((file) => (
readFileSync(file, "utf8").includes("export const RECTIFICATION_OFFER_RULE")
));
assert.equal(definitions.length, 1);
assert.match(definitions[0]!, /consultation-thinking-plan\.ts$/);
assert.match(RECTIFICATION_OFFER_RULE, /不承诺能收窄出生时间/);
assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/);
assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/);
// 原值:规则以「用户说你推得不准时」开头,并写「按纠正规则认下」。
// 新值:第一句就写 explicitRequest=true,并改称「改读规则」。全文不含「纠正规则」。
// 原因:BUG-1317,「生时纠正」和「纠正解读」撞词,正向句被埋在禁止句中间。
assert.match(RECTIFICATION_OFFER_RULE.split("。")[0] ?? "", /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /按改读规则认下、改读,不提校正/);
assert.doesNotMatch(RECTIFICATION_OFFER_RULE, /纠正规则/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
const tools = src("../src/mastra/consultation-tools.ts");
assert.doesNotMatch(tools, /不承诺能收窄出生时间/);
assert.doesNotMatch(tools, /不要提校正/);
assert.match(tools, /explicitRequest/);
assert.match(tools, /userAsked/);
const route = src("../src/app/api/consult/route.ts");
assert.match(route, /大运、子运只说「在哪一段附近」,不写起止年月日/);
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
assert.doesNotMatch(route, /请你帮我进行生时纠正/);
const direct = route.slice(
route.indexOf("async function completeDirectRectificationTurn"),
route.indexOf("let settlement:"),
);
assert.match(direct, /complete_consultation_free/);
assert.match(direct, /responseKind: "smalltalk"/);
assert.match(direct, /persistOpenedDirectOffer/);
assert.doesNotMatch(direct, /complete_consultation_response/);
assert.doesNotMatch(direct, /consultation_requests/);
const windowBlock = route.slice(
route.indexOf("if (shouldRunDeclaredWindowWorkflow(consultationMode))"),
route.indexOf("if (!prepared.serverChart)"),
);
const natalBlock = route.slice(
route.indexOf("if (!prepared.serverChart)"),
route.indexOf("const history = storedHistory;"),
);
assert.match(windowBlock, /settleAnswer/);
assert.match(natalBlock, /settleAnswer/);
assert.match(windowBlock, /warmup:/);
assert.match(windowBlock, /precomputeWindowConsultation/);
assert.match(windowBlock, /运行合同不完整/);
assert.match(natalBlock, /运行合同不完整/);
// 原值:route 直接 getRectificationEntrySummary 后 from("agentic_rectification_cases").select("session_id")
// 新值:route 调用 loadResumableRectificationSessionId,不再读这张表
// 原因:服务角色对该表没有读权限,直接读必然失败还被吞掉
assert.match(route, /loadResumableRectificationSessionId\(accounting, userId\)/);
assert.doesNotMatch(route, /\.from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(route, /\.select\("session_id"\)/);
const serverFile = src("../src/lib/rectification-in-chat-server.ts");
assert.match(serverFile, /blocksNewRectification\(summary\.latestResumable\?\.status \?\? ""\)/);
const stream = src("../src/lib/stream-agent-response.ts");
assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/);
assert.match(stream, /rectificationDirectRunCompleted\(options\.state\)/);
assert.doesNotMatch(stream, /请你帮我进行生时纠正/);
const hook = src("../src/hooks/use-consultation-run.ts");
// 原值:直达在浏览器里 openRectificationFromChat(sourceSessionId) 新开,或 openRectificationSession(resumeSessionId) 续上。
// 新值:新开和续上都只跳到 /rectification(openRectificationPage),页面自己给「开始」或「继续」。
// 原因:issue #145(产品 2026-10-28 定:入口单独拆出,不从对话开案例)。
assert.equal(hook.match(/if \(rectificationDirectOpen\) openRectificationPage\(\);/g)?.length, 1);
assert.doesNotMatch(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.doesNotMatch(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
const directOpen = hook.slice(hook.indexOf("if (rectificationDirectOpen)"), hook.indexOf("await persistSession(completedSession)"));
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
const page = src("../src/app/(app)/page.tsx");
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationPage,/);
assert.match(page, /navigateInApp\(RECTIFICATION_PAGE_HREF\)/);
assert.doesNotMatch(page, /请你帮我进行生时纠正/);
const server = src("../src/lib/rectification-in-chat-server.ts");
assert.match(server, /return sourceFromSessionRows/);
assert.match(server, /attachOpenedOfferToFreeMessage/);
const persist = server.slice(server.indexOf("export async function persistOpenedDirectOffer"), server.indexOf("export async function bindRectificationSource"));
assert.doesNotMatch(persist, /consultation_requests/);
consultationAgentPublicEventSchema.parse({
type: "run.completed",
responseKind: "smalltalk",
rectificationOffer: offer("opened"),
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
});
consultationAgentPublicEventSchema.parse({
type: "run.completed",
responseKind: "smalltalk",
rectificationOffer: offer("offered", { autoOpen: true }),
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
});
const surface = src("../src/hooks/use-rectification-surface.ts");
const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession"));
assert.match(fromChat, /return null/);
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/);
});
function cardHtml(record: RectificationOfferRecord) {
return renderToString(createElement(RectificationOfferCard, { offer: record, disabled: false, onStart() {} }));
}
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
const pending = offer("offered", { autoOpen: true });
const html = cardHtml(pending);
assert.match(html, /出生时间还不确定。/);
assert.match(html, /href="\/rectification"/);
assert.match(html, /去做生时校正/);
assert.doesNotMatch(html, /会用掉|点;/);
assert.equal(html.includes("开始核对出生时间"), false);
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
const opened = bound![0]!.rectificationOffer as RectificationOfferRecord;
assert.equal(opened.status, "opened");
assert.equal(opened.autoOpen, true);
assert.equal(opened.caseId, CASE_ID);
const openedHtml = cardHtml(opened);
assert.match(openedHtml, /href="\/rectification"/);
assert.equal(openedHtml.includes("出生时间核对已经开始"), false);
});
test("a failed direct open leaves the offer offered and clickable", () => {
const pending = offer("offered", { autoOpen: true });
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const opened = null;
const next = opened ? bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) : null;
assert.equal(next, null);
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const html = cardHtml(pending);
assert.equal(html.includes("出生时间核对已经开始"), false);
assert.match(html, /href="\/rectification"/);
assert.equal(html.includes("aria-disabled"), false);
assert.equal(html.includes("disabled=\"\""), false);
assert.equal(html.includes("disabled=\"disabled\""), false);
});
test("stored offers offered, opened, and returned still parse; returned hides the card", () => {
for (const status of ["offered", "opened", "returned"] as const) {
const parsed = parseRectificationOfferRecord(offer(status));
assert.equal(parsed?.status, status);
assert.equal(parsed?.autoOpen, undefined);
assert.equal("autoOpen" in (parsed ?? {}), false);
}
const flagged = parseRectificationOfferRecord(offer("offered", { autoOpen: true }));
assert.equal(flagged?.status, "offered");
assert.equal(flagged?.autoOpen, true);
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
assert.equal(cardHtml(offer("returned")), "");
assert.match(cardHtml(offer("opened")), /href="\/rectification"/);
assert.match(cardHtml(offer("offered")), /href="\/rectification"/);
});
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
// The record is not opened yet. The name of the attacher is historical.
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
const card = offer("offered", { autoOpen: true });
const attached = attachOpenedOfferToFreeMessage(
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
{ sentence, offer: card },
);
assert.ok(Array.isArray(attached));
assert.deepEqual(attached[1]!.rectificationOffer, card);
assert.equal((attached[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
});