- 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>
849 lines
41 KiB
TypeScript
849 lines
41 KiB
TypeScript
// 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");
|
||
});
|