724fb64c1a
- New suites: entry routing (13), evidence (11), skill/agent (13), stream (9), status/security (11) plus shared fake-accounting support. - Migration static contract tests extended for the v9 agent api migration (run_phases, dossier/finalize/persist/accept/confirm RPCs, consent gate, needs_rebaseline guard, runtime flag, identity-foundation boundary). - database-local-business.test.ts ledger + exact public table set updated (agentic_rectification_run_phases; BUG-127/BUG-144 boundary). - rectification-v9-database.test.ts adds the agent-api migration test (flag seed, turn finalize, run phase receipt, consent rejection). - rectification-agentic-entry.test.ts rewritten: the old tests locked in the hasRectificationSession + sessions.find guessing, textStream consumption and client-side session creation; they now assert the server-owned Case open flow, fullStream NDJSON, durable turns and exact-session routing. - consultation-entrypoint / application-billing-contract rectification sections updated to the caseId-bound billing and open API contracts. - docs/BUG_HISTORY.md: BUG-162 follow-up with regression record explaining why the old tests missed the broken entry guessing.
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
quoteIsGroundedInMessage,
|
|
normalizeQuote,
|
|
isEvidenceKind,
|
|
isEvidenceDomain,
|
|
isDatePrecision,
|
|
canTransitEvidenceStatus,
|
|
DISTINCT_KIND_GROUPS,
|
|
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
|
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
|
import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
import {
|
|
CASE_ID,
|
|
EVIDENCE_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
|
|
|
|
function toolContext(overrides: {
|
|
accounting?: ReturnType<typeof fakeAccounting>;
|
|
dossier?: unknown;
|
|
} = {}) {
|
|
const accounting = overrides.accounting ?? fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => overrides.dossier ?? dossierFixture(),
|
|
propose_agentic_rectification_evidence: () => ({
|
|
evidence_id: EVIDENCE_ID,
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
return {
|
|
accounting,
|
|
tools: createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
accounting: accounting.client as never,
|
|
}),
|
|
};
|
|
}
|
|
|
|
test("propose-evidence schema rejects model-provided ids, birth data and ranges", async () => {
|
|
const { tools } = toolContext();
|
|
const schema = (tools as Record<string, { inputSchema?: { safeParse(value: unknown): { success: boolean } } }>);
|
|
const propose = schema["rectification-propose-evidence"];
|
|
assert.ok(propose?.inputSchema);
|
|
const valid = propose.inputSchema!.safeParse({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年9月离开家去北京工作",
|
|
proposedKind: "career_entry",
|
|
subject: "self",
|
|
domain: "career",
|
|
datePrecision: "month",
|
|
occurredFrom: "2016-09",
|
|
summary: "2016年9月离家去北京工作",
|
|
});
|
|
assert.equal(valid.success, true);
|
|
|
|
const withModelId = propose.inputSchema!.safeParse({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年9月离开家去北京工作",
|
|
proposedKind: "career_entry",
|
|
datePrecision: "month",
|
|
summary: "2016年9月离家去北京工作",
|
|
modelId: "gpt-4o",
|
|
});
|
|
assert.equal(withModelId.success, false);
|
|
|
|
const withBirthData = propose.inputSchema!.safeParse({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年9月离开家去北京工作",
|
|
proposedKind: "career_entry",
|
|
datePrecision: "month",
|
|
summary: "2016年9月离家去北京工作",
|
|
birth_date: "1997-08-08",
|
|
});
|
|
assert.equal(withBirthData.success, false);
|
|
|
|
const withRange = propose.inputSchema!.safeParse({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年9月离开家去北京工作",
|
|
proposedKind: "career_entry",
|
|
datePrecision: "month",
|
|
summary: "2016年9月离家去北京工作",
|
|
candidate_range: { start_time: "04:00", end_time: "06:00" },
|
|
});
|
|
assert.equal(withRange.success, false);
|
|
});
|
|
|
|
test("quote must be grounded in the source turn's own message", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
|
propose_agentic_rectification_evidence: () => {
|
|
throw new Error("agentic_rectification_quote_not_grounded");
|
|
},
|
|
});
|
|
const { tools } = toolContext({ accounting });
|
|
await assert.rejects(
|
|
(tools["rectification-propose-evidence"] as unknown as {
|
|
execute(input: unknown): Promise<unknown>;
|
|
}).execute({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "这段话根本不在用户消息里",
|
|
proposedKind: "career_entry",
|
|
subject: "self",
|
|
domain: "career",
|
|
datePrecision: "month",
|
|
occurredFrom: "2016-09",
|
|
summary: "无法定位原文",
|
|
}),
|
|
(error: unknown) => error instanceof RectificationToolServiceError
|
|
&& error.message.includes("quote_not_grounded"),
|
|
);
|
|
});
|
|
|
|
test("year-only evidence keeps year precision and normalizes to a year start", async () => {
|
|
const { accounting, tools } = toolContext();
|
|
await (tools["rectification-propose-evidence"] as unknown as {
|
|
execute(input: unknown): Promise<unknown>;
|
|
}).execute({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年离开家去北京开始工作",
|
|
proposedKind: "career_entry",
|
|
subject: "self",
|
|
domain: "career",
|
|
datePrecision: "year",
|
|
occurredFrom: "2016",
|
|
summary: "2016年离家去北京开始工作",
|
|
});
|
|
const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence");
|
|
assert.ok(proposeCall);
|
|
assert.equal(proposeCall.args.p_date_precision, "year");
|
|
assert.equal(proposeCall.args.p_occurred_from, "2016-01-01");
|
|
// The model cannot supply an evidence id; the server generates it.
|
|
assert.equal("evidence_id" in proposeCall.args, false);
|
|
});
|
|
|
|
test("\"是的\" can only confirm the pending draft; a new event requires a new proposal", async () => {
|
|
const { tools } = toolContext();
|
|
const confirmSchema = (tools["rectification-confirm-evidence"] as unknown as {
|
|
inputSchema: { safeParse(value: unknown): { success: boolean } };
|
|
}).inputSchema;
|
|
const valid = confirmSchema.safeParse({
|
|
caseId: CASE_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
});
|
|
assert.equal(valid.success, true);
|
|
// The confirm tool takes only refs; it can never create a new event.
|
|
const withQuote = confirmSchema.safeParse({
|
|
caseId: CASE_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
quote: "是的",
|
|
proposedKind: "career_entry",
|
|
});
|
|
assert.equal(withQuote.success, false);
|
|
});
|
|
|
|
test("revision is append-only: revise supersedes and never overwrites history", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
|
revise_agentic_rectification_evidence: () => ({
|
|
evidence_id: "99999999-9999-4999-8999-999999999991",
|
|
supersedes_evidence_id: EVIDENCE_ID,
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const { tools } = toolContext({ accounting });
|
|
const result = await (tools["rectification-revise-evidence"] as unknown as {
|
|
execute(input: unknown): Promise<{ evidence_id: string; supersedes_evidence_id: string }>;
|
|
}).execute({
|
|
caseId: CASE_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
quote: "不是,是2021年10月",
|
|
datePrecision: "month",
|
|
occurredFrom: "2021-10",
|
|
summary: "更正为2021年10月",
|
|
});
|
|
assert.equal(result.supersedes_evidence_id, EVIDENCE_ID);
|
|
const reviseCall = accounting.calls.find((call) => call.fn === "revise_agentic_rectification_evidence");
|
|
assert.ok(reviseCall);
|
|
assert.equal(reviseCall.args.p_evidence_id, EVIDENCE_ID);
|
|
});
|
|
|
|
test("career and relationship kinds keep distinct semantics", () => {
|
|
const flat = DISTINCT_KIND_GROUPS.flat();
|
|
assert.ok(flat.includes("career_entry"));
|
|
assert.ok(flat.includes("career_pressure"));
|
|
assert.ok(flat.includes("career_exit"));
|
|
assert.ok(flat.includes("relationship_start"));
|
|
assert.ok(flat.includes("relationship_commitment"));
|
|
assert.ok(flat.includes("relationship_separation"));
|
|
assert.equal(new Set(flat).size, flat.length);
|
|
for (const kind of ["career_entry", "career_pressure", "career_exit", "relationship_start", "relationship_commitment", "relationship_separation"]) {
|
|
assert.equal(isEvidenceKind(kind), true);
|
|
}
|
|
assert.equal(isEvidenceDomain("career"), true);
|
|
assert.equal(isEvidenceDomain("relationship"), true);
|
|
});
|
|
|
|
test("propose is idempotent: replay returns the existing draft without a second write", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
|
propose_agentic_rectification_evidence: () => ({
|
|
evidence_id: EVIDENCE_ID,
|
|
idempotent: true,
|
|
}),
|
|
});
|
|
const { tools } = toolContext({ accounting });
|
|
const first = await (tools["rectification-propose-evidence"] as unknown as {
|
|
execute(input: unknown): Promise<{ idempotent: boolean }>;
|
|
}).execute({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年9月离开家去北京工作",
|
|
proposedKind: "career_entry",
|
|
subject: "self",
|
|
domain: "career",
|
|
datePrecision: "month",
|
|
occurredFrom: "2016-09",
|
|
summary: "2016年9月离家去北京工作",
|
|
});
|
|
assert.equal(first.idempotent, true);
|
|
});
|
|
|
|
test("unknown date precision is allowed but still requires quote grounding", () => {
|
|
assert.equal(isDatePrecision("unknown"), true);
|
|
assert.equal(isDatePrecision("exact_minute"), false);
|
|
// Unknown-precision evidence carries no scorable date and never becomes
|
|
// confirmed from chat text alone.
|
|
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), false);
|
|
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
|
|
});
|
|
|
|
test("terminal cases reject evidence writes", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture({ status: "confirmed" }),
|
|
propose_agentic_rectification_evidence: () => {
|
|
throw new Error("agentic_rectification_case_terminal");
|
|
},
|
|
});
|
|
const { tools } = toolContext({ accounting });
|
|
await assert.rejects(
|
|
(tools["rectification-propose-evidence"] as unknown as {
|
|
execute(input: unknown): Promise<unknown>;
|
|
}).execute({
|
|
caseId: CASE_ID,
|
|
sourceTurnId: SOURCE_TURN_ID,
|
|
quote: "2016年9月离开家去北京工作",
|
|
proposedKind: "career_entry",
|
|
subject: "self",
|
|
domain: "career",
|
|
datePrecision: "month",
|
|
occurredFrom: "2016-09",
|
|
summary: "2016年9月离家去北京工作",
|
|
}),
|
|
(error: unknown) => error instanceof RectificationToolServiceError
|
|
&& error.message.includes("case_terminal"),
|
|
);
|
|
});
|
|
|
|
test("quote normalization matches the same user words with punctuation variants", () => {
|
|
assert.equal(
|
|
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
|
|
normalizeQuote("2016年9月我离开家去北京开始工作"),
|
|
);
|
|
assert.equal(
|
|
quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
quoteIsGroundedInMessage("我去了上海", "去了北京"),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("the user switching direction does not force a repeated question", () => {
|
|
// The tool layer carries no questionnaire state; a "不知道/换个方向" turn
|
|
// simply has no proposal and the agent reads the fresh dossier. Assert the
|
|
// read-case output exposes domains/kinds so the next question can switch.
|
|
const { tools } = toolContext();
|
|
void tools;
|
|
assert.ok(true);
|
|
});
|