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; 「:adopt」/「: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:」 while the state holds the split-hashed // 「probe::」; 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"); });