/** * BUG-1057 (TASK-rectification-grounding-20260927 T3, red line 3): the * turn-decision read-case is the model's only conversation memory (the model * messages carry no history). With 9 candidates it used to exceed 6 KB and * clear `recent_turns` and `relevant_evidence_summary` first. Now candidates * carry time / score / status / cluster_range only, the duplicate * `candidate_summary.candidates` is gone, and over budget the candidate detail * goes before the conversation. * * Fixture: a real local engine response for a public AA chart (9 candidates). */ import assert from "node:assert/strict"; import test from "node:test"; import { CASE_ID, TURN_ID, USER_ID, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; import { AA_EVIDENCE_ROWS, AA_RANGE, buildGoldenLatest, setFocusHandler, stubGoldenFetch, } from "./rectification-grounding-support.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { TURN_DECISION_CANDIDATE_FIELDS, TURN_DECISION_MAX_BYTES, enforceTurnDecisionBudget, turnDecisionByteLength, } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; const SIX_TURNS = [ ["user", "2000 年拿了一个很重要的表演奖,那一年整个人的事业一下子起来了,很多人开始找我合作。"], ["assistant", "记下了:2000 年获奖。"], ["user", "2013 年做了一次预防性的大手术,前后休养了大半年,那年身体状态很差。"], ["assistant", "记下了:2013 年做手术。"], ["user", "2014 年 8 月结婚,是在法国办的婚礼,家里人都去了,算是那几年最大的一件事。"], ["assistant", "记下了:2014 年 8 月结婚。"], ].map(([role, text], index) => ({ id: `77777777-7777-4777-8777-77777777777${index}`, role, text, status: "completed", created_at: `2026-08-12T10:0${index}:00.000Z`, completed_at: `2026-08-12T10:0${index}:05.000Z`, })); async function readCaseFor(turns: unknown[]) { const { latest, compute } = await buildGoldenLatest(); const restore = stubGoldenFetch(); try { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ evidence: AA_EVIDENCE_ROWS, latestResult: latest, turns: turns as never, candidateRange: AA_RANGE as never, }), get_agentic_rectification_case_compute: () => compute, set_agentic_rectification_conversation_focus: setFocusHandler, }, { fallback: () => null }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, userMessage: "2014 年结婚", accounting: accounting.client as never, }) as unknown as Record> }>; const readCase = await tools["rectification-read-case"].execute({ caseId: CASE_ID }); return { readCase, candidateCount: latest.candidates.length }; } finally { restore(); } } test("red line 3: 9 candidates and six turns keep recent_turns and relevant_evidence_summary", async () => { const { readCase, candidateCount } = await readCaseFor(SIX_TURNS); assert.equal(candidateCount, 9); assert.ok(turnDecisionByteLength(readCase) <= TURN_DECISION_MAX_BYTES); assert.equal(readCase.truncated, undefined); const turns = readCase.recent_turns as Array<{ role: string; text: string }>; assert.equal(turns.length, 6); assert.match(turns[4].text, /2014 年 8 月结婚/); const evidence = readCase.relevant_evidence_summary as unknown[]; assert.equal(evidence.length, 3); const inference = readCase.inference as { candidates: Array> }; assert.equal(inference.candidates.length, 9); for (const candidate of inference.candidates) { assert.deepEqual(Object.keys(candidate), [...TURN_DECISION_CANDIDATE_FIELDS]); assert.match(String(candidate.time), /^\d{2}:\d{2}$/); } const summary = readCase.candidate_summary as Record; assert.equal("candidates" in summary, false); const text = JSON.stringify(readCase); assert.doesNotMatch(text, /cluster_intervals|window_offset_minutes|posterior_score|candidate_date/); console.log(JSON.stringify({ scope: "BUG-1057 read-case size", bytes: turnDecisionByteLength(readCase) })); }); test("red line 3: over budget, candidate detail goes before recent turns and evidence", () => { const clock = (index: number) => `${String(4 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`; const candidates = Array.from({ length: 200 }, (_, index) => ({ time: clock(index), score: index === 7 ? 0.5 : 0.01, status: index % 3 === 0 ? "eliminated" : "active", cluster_range: [clock(index), clock(index)], })); const turns = Array.from({ length: 6 }, (_, index) => ({ role: index % 2 ? "assistant" : "user", text: `第 ${index} 句话`.repeat(10) })); const evidence = Array.from({ length: 6 }, (_, index) => ({ domain: "career", summary: `事件 ${index}`.repeat(8) })); const payload = { projection: "turn_decision", inference: { credible_range: ["05:00", "05:39"], candidates }, recent_turns: turns, relevant_evidence_summary: evidence, }; assert.ok(turnDecisionByteLength(payload) > TURN_DECISION_MAX_BYTES); const trimmed = enforceTurnDecisionBudget(payload); assert.ok(turnDecisionByteLength(trimmed) <= TURN_DECISION_MAX_BYTES); assert.deepEqual(trimmed.recent_turns, turns); assert.deepEqual(trimmed.relevant_evidence_summary, evidence); assert.equal(trimmed.truncated, undefined); const kept = (trimmed.inference as { candidates: Array>; candidates_omitted?: number }); assert.ok(kept.candidates.every((candidate) => !("cluster_range" in candidate))); assert.ok(kept.candidates.length < 200); assert.equal(kept.candidates[0]?.time, "04:07"); assert.ok(kept.candidates.every((candidate) => candidate.status === "active")); assert.equal(kept.candidates_omitted, 200 - kept.candidates.length); }); test("red line 3: first budget step only drops cluster ranges; every candidate and turn stays", () => { const clock = (index: number) => `${String(4 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`; const candidates = Array.from({ length: 90 }, (_, index) => ({ time: clock(index), score: 0.01, status: "active", cluster_range: [clock(index), clock(index)], })); const turns = [{ role: "user", text: "2014 年 8 月结婚。" }]; const payload = { inference: { candidates }, recent_turns: turns, relevant_evidence_summary: [] }; assert.ok(turnDecisionByteLength(payload) > TURN_DECISION_MAX_BYTES); const trimmed = enforceTurnDecisionBudget(payload); const kept = trimmed.inference as { candidates: Array>; candidates_omitted?: number }; assert.equal(kept.candidates.length, 90); assert.equal(kept.candidates_omitted, undefined); assert.ok(kept.candidates.every((candidate) => !("cluster_range" in candidate))); assert.deepEqual(trimmed.recent_turns, turns); });