Files
Jyotisha/frontend/tests/rectification-post-adopt-card-20261001.test.ts
T
Jesse_ChenandClaude Opus 5.5 282455cc83
Independent Staging Quality Gate / validate (push) Successful in 13m59s
Independent Staging Quality Gate / publish (push) Successful in 3m49s
fix(rectification): post-adopt check gets its own message and keeps its buttons; segment bubble copy (BUG-1146, BUG-1147)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 16:59:32 +08:00

187 lines
12 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 { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { 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, "req-1:adopt");
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");
});