685 lines
33 KiB
TypeScript
685 lines
33 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 {
|
|
RECTIFICATION_DIRECT_NEW_COPY,
|
|
RECTIFICATION_DIRECT_NOT_SELF_COPY,
|
|
RECTIFICATION_DIRECT_RESUME_COPY,
|
|
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
|
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
|
RECTIFICATION_OFFER_OPENED_COPY,
|
|
RECTIFICATION_OFFER_START_LABEL,
|
|
RECTIFICATION_OFFER_TOOL_ID,
|
|
attachOpenedOfferToFreeMessage,
|
|
bindOfferToCase,
|
|
offerForCase,
|
|
parseRectificationOfferRecord,
|
|
rectificationOfferCardCopy,
|
|
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,
|
|
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_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>;
|
|
} = {}) {
|
|
const state = createConsultationRuntimeState();
|
|
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", () => {
|
|
assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11, 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(RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, "可在历史对话里接着做。");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("an in-progress case resumes that session, or says to continue in history", 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);
|
|
|
|
const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) });
|
|
const refused = await missing.tool.execute({ explicitRequest: true });
|
|
assert.equal(refused.directEntry, undefined);
|
|
assert.equal(missing.state.rectificationDirectEntry?.kind, "refused");
|
|
assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
|
|
assert.equal(missing.state.rectificationOffer, undefined);
|
|
assert.match(String(refused.note), /历史对话/);
|
|
assert.doesNotMatch(String(refused.note), /不要提校正/);
|
|
|
|
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, "refused");
|
|
assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY);
|
|
assert.match(String(fallback.note), /历史对话/);
|
|
|
|
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), /不要提校正/);
|
|
});
|
|
|
|
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("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, /只写那一句过渡,不写解读/);
|
|
assert.match(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, /运行合同不完整/);
|
|
assert.match(route, /getRectificationEntrySummary\(accounting, userId\)/);
|
|
assert.match(route, /\.from\("agentic_rectification_cases"\)/);
|
|
assert.match(route, /\.select\("session_id"\)/);
|
|
|
|
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");
|
|
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
|
|
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
|
|
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
|
|
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
|
|
assert.match(directOpen, /if \(opened\) return/);
|
|
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, openRectificationFromChat,/);
|
|
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", () => {
|
|
const pending = offer("offered", { autoOpen: true });
|
|
const html = cardHtml(pending);
|
|
assert.match(html, /大约 8 题,会用掉 3 点/);
|
|
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
|
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
|
assert.equal(html.includes("继续核对出生时间"), false);
|
|
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
|
|
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, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
|
assert.match(openedHtml, /继续核对出生时间/);
|
|
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), 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(RECTIFICATION_OFFER_OPENED_COPY), false);
|
|
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
|
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")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
|
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
|
});
|
|
|
|
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");
|
|
});
|