Files
Jyotisha/frontend/tests/rectification-post-adopt-card-20261001.test.ts
T
Jesse_ChenandClaude Opus 5.5 28f4df0e07
Independent Staging Quality Gate / validate (push) Failing after 13m1s
Independent Staging Quality Gate / publish (push) Skipped
fix(rectification): adopt/gate turns use uuid request ids; post-adopt checks carry the state probe id (BUG-1149, BUG-1150)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 19:38:10 +08:00

229 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { expectedAnswerSchemaFor, persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import { adoptTurnRequestId, exhaustionGateRequestId, inferenceForPersistedAnswer, writeAdoptFollowupTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { composeChoiceNarration } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { alreadyDelivered, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { deliveryTurnNarration, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts";
import { fakeAccounting } from "./rectification-v9-test-support.ts";
// Real device 2026-10-01 (BUG-1146): after 采用 the first post-adopt check persisted, but the
// case GET rebuilt no card, so the stem showed without buttons and refresh did not help. Fixtures
// that left activeFocus.questionId unset never reached the question-id comparison.
const EVIDENCE = [{
status: "confirmed", domain: "career", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null,
}, {
status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null,
eventKind: "education_start", summary: "fictional enrolment",
}];
const DATED_CHECK: DiscriminatingEventProbe = {
year: 2018, year_label: "2018 年前后", domain: "career", event_family: "入职、升职或职责明显加重",
source: "dasha_activation", tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false,
user_meaning: "年份锁定 2018 年前后。请写成一句自然语言,问是否入职或职责加重。", role: "reverse_verify",
semantic_key: "career.2018.dasha_activation", candidate_split_hash: "career.2018.dasha_activation",
candidate_ids: ["05:00", "05:04"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:04"] },
{ answer_class: "no", supports: ["05:04"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
} as never;
const QUALITY_CHECK: DiscriminatingEventProbe = {
year: 2016, year_label: "2016 年 9 月", domain: "education", event_family: "学业或考试发挥失常、压力特别大",
source: "known_event_quality", role: "distinguish", choice_kind: "event_quality",
tracks: ["vimshottari"], tracks_agree: true, unique_minute_claim: false,
user_meaning: "2016 年 9 月那次升学,结果更接近如愿、将就调剂、发挥失常还是说不清。只问那次经历的实际体验,不得改时间范围。",
semantic_key: "education.2016.09.known_event_quality", candidate_split_hash: "education.2016.09.known_event_quality",
target_evidence_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
candidate_ids: ["05:00", "05:04"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:04"] },
{ answer_class: "no", supports: ["05:04"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
} as never;
for (const [label, probe] of [["dated", DATED_CHECK], ["quality", QUALITY_CHECK]] as const) {
test(`post-adopt ${label} check: the card persisted after 采用 is rebuilt on GET`, async () => {
const input = {
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative" as const,
selectionAllowed: true, proposeAllowed: true, eventProbes: [probe],
};
const plan = buildMethodFollowupPlan(input as never);
const followup = plan.next_followup;
assert.ok(followup, `the ${label} probe yields a post-adopt check`);
assert.equal(followup.intent, "reverse_verify");
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: "abababab-abab-4bab-8bab-abababababab", case_id: "x", question_id: args.p_question_id, intent: args.p_intent,
target_evidence_id: null, target_domain: args.p_target_domain, target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema, status: "active",
asked_at: "2026-10-01T00:00:00.000Z", resolved_at: null, idempotent: false,
}),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client, userId: "11111111-1111-4111-8111-111111111111",
caseId: "22222222-2222-4222-8222-222222222222", activeFocus: null, decisionReceipt: null, followup,
});
assert.equal(persisted.status, "created");
assert.ok(persisted.focus?.questionId, "a real focus always carries a question id");
const card = projectRectificationChoiceCard({ ...input, activeFocus: persisted.focus } as never);
assert.ok(card, `refresh rebuilds the ${label} post-adopt card (question id ${persisted.focus!.questionId})`);
assert.equal(card!.options.length, 4);
});
}
const USER = "11111111-1111-4111-8111-111111111111";
const CASE = "22222222-2222-4222-8222-222222222222";
const RESULT = "33333333-3333-4333-8333-333333333333";
test("采用 writes its own turn, hangs the post-adopt check on it and quiets the read_only run", async () => {
resetDeliveryTurnGuardForTests();
const accounting = fakeAccounting({
append_agentic_rectification_turn: (_fn, args) => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false, args }),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: "abababab-abab-4bab-8bab-abababababab", case_id: CASE, question_id: args.p_question_id, intent: args.p_intent,
target_evidence_id: null, target_domain: args.p_target_domain, target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema, status: "active", asked_turn_id: args.p_asked_turn_id,
asked_at: "2026-10-01T00:00:00.000Z", resolved_at: null, idempotent: true,
}),
});
const focus = {
id: "abababab-abab-4bab-8bab-abababababab", questionId: "probe:career.2018.dasha_activation", intent: "reverse_verify",
targetDomain: "career", targetKind: "event", targetEvidenceId: null, expectedAnswerSchema: { semantic_key: "career.2018.dasha_activation" },
status: "active", askedTurnId: null,
};
const out = await writeAdoptFollowupTurn({
accounting: accounting.client, userId: USER, caseId: CASE, requestId: "req-1", resultId: RESULT,
hostNarration: "2018 年前后,有没有入职或职责明显加重?", focus: focus as never, savedTime: "05:00:00",
});
assert.deepEqual(out, { turnId: "44444444-4444-4444-8444-444444444444" });
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_request_id, adoptTurnRequestId("req-1"));
assert.equal(turn?.args.p_user_message, null);
assert.equal(turn?.args.p_assistant_message, "已采用 05:00。再核对一件过去的事。2018 年前后,有没有入职或职责明显加重?");
const link = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.equal(link?.args.p_asked_turn_id, "44444444-4444-4444-8444-444444444444");
assert.equal(alreadyDelivered({ caseId: CASE, resultId: RESULT, hasUserMessage: false }), true);
resetDeliveryTurnGuardForTests();
});
test("采用 with nothing to say writes no turn", async () => {
const accounting = fakeAccounting({});
const out = await writeAdoptFollowupTurn({
accounting: accounting.client, userId: USER, caseId: CASE, requestId: "req-2", resultId: RESULT,
hostNarration: " ", focus: null,
});
assert.equal(out, null);
assert.equal(accounting.calls.length, 0);
});
// BUG-1147: in the segment (盘型) product the bubble neither reads a minute leaderboard nor
// repeats the boundary line the card already prints.
test("segment product: choice ack has no minute leaderboard", () => {
const base = {
optionId: "A" as const, scoring: true, appliedInference: true, answerClass: "yes" as const,
credibleBefore: ["04:52", "05:05"] as const, credibleAfter: ["04:52", "05:05"] as const,
deltasByCluster: [
{ range: ["04:52", "04:53"], delta: 0.4 }, { range: ["05:00", "05:05"], delta: -0.4 },
] as never,
};
const minute = composeChoiceNarration(base as never);
const segment = composeChoiceNarration({ ...base, segmentProduct: true } as never);
assert.equal(segment, "已记录,范围没变。");
assert.doesNotMatch(segment, /领先|落后/);
assert.match(minute, /领先/, "the minute product still reads the movement");
});
test("segment product: delivery bubble leaves the boundary line to the card", () => {
const summary: SegmentSummary = {
targets: ["D1", "D9"], window_minutes: 21, scan_complete: true, no_rectification_needed: false, adoption_minute: null,
charts: [
{ chart: "D1", sign: 3, tier: "certain", alternatives: [], tied: false },
{ chart: "D9", sign: 7, tier: "credible", alternatives: [], tied: false },
],
} as never;
const segment = deliveryTurnNarration({ credibleRange: ["04:52", "05:05"], segmentSummary: summary });
assert.ok(!segment.includes(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.match(segment, /D9 上升:天蝎(较可信)/);
const minute = deliveryTurnNarration({ credibleRange: ["04:52", "05:05"] });
assert.ok(minute.endsWith(REPRESENTATIVE_MINUTE_DISCLAIMER));
});
test("client: an adopt turn from the accept POST replaces the live row; no read_only run", async () => {
const { chatRunRecorder, offerableCandidateResult, jsonResponse, runRectificationCandidateAccept, withFakeFetch } = await import("./rectification-chat-run-fixtures.ts");
const recorder = chatRunRecorder([{ role: "assistant", text: "已记录,范围没变。", renderKey: "persisted-prev-0", state: "settled", turnId: "prev" } as never]);
recorder.candidateResult.set(offerableCandidateResult());
const adoptTurn = { id: "adopt-turn", role: "assistant", text: "已按 04:53 排盘。再核对一件事。", status: "completed" };
await withFakeFetch(() => jsonResponse({
ok: true, status: "accepted", saved_time: "04:53:00", result_id: "result-1", idempotent: false, adopt_turn_id: "adopt-turn",
}), async () => {
await runRectificationCandidateAccept(recorder.acceptDeps({
loadCaseSnapshot: async (merge) => { merge?.([{ id: "prev", role: "assistant", text: "已记录,范围没变。", status: "completed" }, adoptTurn]); return undefined; },
}), "88888888-8888-4888-8888-888888888881");
});
assert.equal(recorder.error.get(), "");
assert.equal(recorder.sends.length, 0, "no read_only run after the server wrote the adopt turn");
const transcript = recorder.messages.get();
assert.deepEqual(transcript.map((message) => message.turnId), ["prev", "adopt-turn"]);
assert.ok(transcript.every((message) => message.state === "settled"), "the live row is gone");
});
test("client: no adopt turn (idempotent accept) keeps the read_only continuation", async () => {
const { chatRunRecorder, offerableCandidateResult, jsonResponse, runRectificationCandidateAccept, withFakeFetch } = await import("./rectification-chat-run-fixtures.ts");
const recorder = chatRunRecorder();
recorder.candidateResult.set(offerableCandidateResult());
await withFakeFetch(() => jsonResponse({ ok: true, status: "accepted", saved_time: "04:53:00", result_id: "result-1", idempotent: true }),
async () => { await runRectificationCandidateAccept(recorder.acceptDeps(), "88888888-8888-4888-8888-888888888881"); });
assert.equal(recorder.error.get(), "");
assert.equal(recorder.sends.length, 1);
assert.equal(recorder.sends[0]!.action, "read_only");
});
// BUG-1149: real PostgreSQL types p_request_id as uuid; 「<id>:adopt」/「<id>:gate」 raised 22P02,
// the accept route swallowed it, and the gate narration never persisted.
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
test("deterministic turn request ids are uuids and stable per seed", () => {
const adopt = adoptTurnRequestId("66666666-6666-4666-8666-666666666666");
assert.match(adopt, UUID);
assert.equal(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666"));
assert.notEqual(adopt, adoptTurnRequestId("77777777-7777-4777-8777-777777777777"));
const gate = exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE);
assert.match(gate, UUID);
assert.equal(gate, exhaustionGateRequestId("55555555-5555-4555-8555-555555555555", CASE));
assert.match(exhaustionGateRequestId(null, CASE), UUID);
assert.notEqual(adopt, adoptTurnRequestId("66666666-6666-4666-8666-666666666666:x"));
});
// BUG-1150: post-adopt checks were stamped 「probe:<key>」 while the state holds the split-hashed
// 「probe:<key>:<split>」; the transition RPC rejected every answer as stale_probe.
test("a scoring post-adopt check carries the state's probe id", () => {
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE as never, accepted: true, sessionOutcome: "adopt_representative",
selectionAllowed: true, proposeAllowed: true, eventProbes: [DATED_CHECK],
} as never);
const followup = plan.next_followup!;
assert.equal(followup.intent, "reverse_verify");
const stateId = "probe:career.2018.dasha_activation:career.2018.dasha_activation";
const inference = {
algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "04:58-05:06:05:00,05:04", revision: 6,
phase: "discrimination", result_status: "discriminating", range_start: "04:58", range_end: "05:06",
candidates: ["05:00", "05:04"].map((time, index) => ({ id: time, time, cluster_range: ["04:58", "05:06"], prior_score: 10,
posterior_score: 10, probability: 0.5, status: "active", rank: index + 1, strong_conflict_count: 0 })),
events: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "05:00", credible_range: ["04:58", "05:06"],
probes: [{ ...DATED_CHECK, id: stateId, question: "2018", information_gain: 0.5, year: 2018 }],
};
const receipt = { inference_state: inference };
assert.ok(inferenceForPersistedAnswer(receipt)?.probes.some((probe) => probe.id === stateId), "fixture state parses");
const schema = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, receipt, followup);
assert.equal(schema?.probe_id, stateId);
const bare = expectedAnswerSchemaFor(followup.choice_frame!, followup.choice_frame!.question_id, null, followup);
assert.equal(bare?.probe_id, "probe:career.2018.dasha_activation", "no state: the named key, as before");
});