Files
Jyotisha/frontend/tests/rectification-post-adopt-card-20261001.test.ts
T
Jesse_ChenandClaude Opus 5.5 279650a684
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 8m58s
fix(rectification): delivery gate turn skips text the last reply already said (BUG-1153)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 22:34:35 +08:00

255 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, narrationAlreadyInLastAssistant, persistExhaustionGateTurn, 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 { CASE_DOSSIER_RPC, dossierFixture, 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");
});
// BUG-1153: once the gate turn could persist (BUG-1149), the delivery text the answered choice
// already said appeared a second time as its own message.
const DELIVERY = "D1 上升:巨蟹(确定);D9 上升:天蝎(较可信)。分钟范围 04:48–05:07。用了 5 道选择题。";
test("delivery gate turn is not written when the last reply already says it", async () => {
const turns = (text: string) => [
{ id: "t-user", role: "user", text: "A", status: "completed", created_at: "2026-10-01T00:00:00Z" },
{ id: "t-ack", role: "assistant", text, status: "completed", created_at: "2026-10-01T00:00:01Z" },
];
for (const [label, last, writes] of [
["already said", `已记录,范围收到 04:48–05:07。\n\n${DELIVERY}`, 0],
["not said", "已记录,范围收到 04:48–05:07。", 1],
] as const) {
resetDeliveryTurnGuardForTests();
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture({ turns: turns(last) }),
append_agentic_rectification_turn: () => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false }),
});
await persistExhaustionGateTurn({ accounting: accounting.client, userId: USER, caseId: CASE, hostNarration: DELIVERY, resultId: RESULT });
assert.equal(accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, writes, label);
assert.equal(alreadyDelivered({ caseId: CASE, resultId: RESULT, hasUserMessage: false }), true, `${label}: still marked delivered`);
}
resetDeliveryTurnGuardForTests();
assert.equal(narrationAlreadyInLastAssistant(DELIVERY, { turns: [{ role: "assistant", text: DELIVERY.replace(/;/g, "; ") }] }), true, "whitespace does not defeat the check");
assert.equal(narrationAlreadyInLastAssistant("短", { turns: [{ role: "assistant", text: "短" }] }), false, "too short to compare");
});