import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { canRegenerateRectificationMessage, rectificationPhaseActivityState, rectificationV4ChatMessages, rectificationPhaseLabel, rectificationProgressLabel, toggleRectificationFeedback, } from "../src/components/rectification-v4-panel.tsx"; import { applyRectificationV4JobUpdate } from "../src/hooks/use-rectification-v4.ts"; import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts"; const id = "00000000-0000-4000-8000-000000000901"; const questionId = "00000000-0000-4000-8000-000000000902"; const now = "2026-07-27T00:00:00.000Z"; function response(overrides: Record = {}): RectificationV4ApiResponse { return { case: { id, userId: "00000000-0000-4000-8000-000000000900", protocol: "rectification-evidence-v4", version: 2, status: "awaiting_answer", phase: "collecting_evidence", calculationSpec: { version: "rectification-calculation-spec-v4", birthDate: "1997-08-08", candidateRange: { start: "04:50", end: "05:10" }, latitude: 36.419, longitude: 114.213, timezoneOffsetHours: 8, ayanamsa: "lahiri", nodeMode: "mean", minuteStep: 1, }, calculationSpecHash: "a".repeat(64), evidenceSetHash: "b".repeat(64), currentQuestion: { id: questionId, domain: "relocation", targetEventId: null, prompt: "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?", recallCost: "low", reason: "根据对话选择下一条高信息量追问。", }, latestSnapshot: null, orchestrationModelId: null, narrationModelId: null, skillVersion: "birth-time-rectification-v5", promptVersion: "rectification-agent-v5-1", algorithmVersion: "rectification-v5-matrix-scoring-1", deploymentMode: "v5_agent", agentMode: "deterministic_fallback", featureSnapshotId: null, latestDiagnosticsId: null, acceptedRange: null, createdAt: now, updatedAt: now, ...overrides, }, job: null, events: [], analysis: [], turns: [{ id: "00000000-0000-4000-8000-000000000903", caseId: id, caseVersion: 1, questionId: "00000000-0000-4000-8000-000000000904", questionDomain: "other", questionTargetEventId: null, question: "请从你最确定的一段人生经历开始说。", answer: "2015年毕业后复读,2016年再次毕业。", modelId: "gpt-5.5", actionId: "00000000-0000-4000-8000-000000000905", createdAt: now, }], } as unknown as RectificationV4ApiResponse; } function analysisTrace(label: string) { return { status: "completed", stages: [{ phase: "extracting_evidence", label, status: "completed", durationMs: 320, }], toolCalls: [{ category: "candidate_engine", label: "候选分钟扫描", outcome: "succeeded", durationMs: 840, }], techniques: ["Vimshottari Dasha", "D24"], reasoningSummary: "现有证据更适合继续收集另一件时间明确的经历。", reasoningSource: "provider_summary", } as const; } test("v4 rectification reuses the ordinary session message list, composer, and model selector", () => { const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8"); const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); assert.match( component, /<>\s*
\s*\{showControls && \(\s*
/); assert.match(component, /分析过程<\/span>/); assert.match( component, /[\s\S]*?/); assert.match(page, /\{!rectificationSurfaceOpen && \(\s*
{ assert.equal(toggleRectificationFeedback(undefined, "up"), "up"); assert.equal(toggleRectificationFeedback("up", "up"), undefined); assert.equal(toggleRectificationFeedback("up", "down"), "down"); const message = rectificationV4ChatMessages(response(), false).at(-1)!; assert.equal(canRegenerateRectificationMessage({ message, currentMessageKey: `rectification-current-${questionId}`, deploymentMode: "v5_agent", busy: false, canAnswer: true, }), true); assert.equal(canRegenerateRectificationMessage({ message, currentMessageKey: message.renderKey, deploymentMode: "v5_shadow", busy: false, canAnswer: true, }), false); assert.equal(canRegenerateRectificationMessage({ message, currentMessageKey: message.renderKey, deploymentMode: "v5_agent", busy: true, canAnswer: true, }), false); assert.equal(canRegenerateRectificationMessage({ message: { ...message, role: "user" }, currentMessageKey: message.renderKey, deploymentMode: "v5_agent", busy: false, canAnswer: true, }), false); }); test("turn history and the context-aware next question render as one chat timeline", () => { const messages = rectificationV4ChatMessages(response(), false); assert.deepEqual(messages.map(({ role, text }) => [role, text]), [ ["assistant", "请从你最确定的一段人生经历开始说。"], ["user", "2015年毕业后复读,2016年再次毕业。"], ["assistant", "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?"], ]); }); test("completed analysis is attached to the next Agent message by source turn", () => { const base = response(); const firstTurn = base.turns[0]!; const secondTurn = { ...firstTurn, id: "00000000-0000-4000-8000-000000000906", caseVersion: 2, questionId: "00000000-0000-4000-8000-000000000907", question: "承接复读后再次毕业,你还记得哪次职业变化的大概时间?", answer: "2020年4月去研究院实习。", actionId: "00000000-0000-4000-8000-000000000908", }; const firstTrace = analysisTrace("整理第一轮经历"); const secondTrace = analysisTrace("整理第二轮经历"); const data = { ...base, turns: [firstTurn, secondTurn], analysis: [ { sourceTurnId: firstTurn.id, trace: firstTrace }, { sourceTurnId: secondTurn.id, trace: secondTrace }, ], } as unknown as RectificationV4ApiResponse; const assistantMessages = rectificationV4ChatMessages(data, false) .filter((message) => message.role === "assistant"); assert.equal(assistantMessages[0]?.analysisTrace, undefined); assert.equal(assistantMessages[1]?.analysisTrace, firstTrace); assert.equal(assistantMessages[2]?.analysisTrace, secondTrace); }); test("legacy and shadow modes do not expose persisted analysis traces", () => { const base = response(); const trace = analysisTrace("不应显示"); for (const deploymentMode of ["v4_legacy", "v5_shadow"] as const) { const data = { ...base, case: { ...base.case, deploymentMode }, analysis: [{ sourceTurnId: base.turns[0]!.id, trace }], } as unknown as RectificationV4ApiResponse; assert.equal( rectificationV4ChatMessages(data, false).some((message) => message.analysisTrace), false, deploymentMode, ); } }); test("processing follows every server job phase returned by polling", () => { const base = response({ currentQuestion: null, status: "processing", phase: "extracting_evidence" }); const job = { id: "00000000-0000-4000-8000-000000000909", caseId: id, status: "processing", phase: "extracting_evidence", expectedCaseVersion: 2, evidenceSetHash: "b".repeat(64), calculationSpecHash: "a".repeat(64), errorCode: null, createdAt: now, updatedAt: now, } as const; let data = { ...base, job } as unknown as RectificationV4ApiResponse; const phases = [ ["extracting_evidence", "正在整理你刚才提到的经历…", "working"], ["planning_question", "正在生成语义问题机会…", "shaping"], ["reasoning", "正在选择下一步动作…", "solving"], ["rendering", "正在生成安全回复…", "composing"], ] as const; for (const [phase, label, activityState] of phases) { const updated = applyRectificationV4JobUpdate(data, { ...job, phase }); assert.ok(updated); data = updated; const message = rectificationV4ChatMessages(data, true).at(-1); assert.equal(message?.role, "assistant"); assert.equal(message?.state, "thinking"); assert.equal(message?.text, rectificationProgressLabel(phase)); assert.equal(message?.text, label); assert.equal(message?.activityState, activityState); } assert.equal(rectificationPhaseLabel("checking_robustness"), "正在检查候选范围的稳定性…"); assert.equal(rectificationProgressLabel("extracting_evidence"), "正在整理你刚才提到的经历…"); assert.equal(rectificationProgressLabel("checking_robustness"), "正在检查候选范围的稳定性…"); assert.equal(rectificationProgressLabel("reasoning"), "正在选择下一步动作…"); assert.equal(rectificationPhaseActivityState("collecting_evidence"), "listening"); assert.equal(rectificationPhaseActivityState("scoring_candidates"), "searching"); assert.doesNotMatch(rectificationProgressLabel("reasoning"), /正在分析:|整理已确认事件|比较候选时间差异|确定下一步验证方向/); }); test("polling ignores an older job response so the visible phase cannot move backward", () => { const base = response({ currentQuestion: null, status: "processing", phase: "rendering" }); const latest = { id: "00000000-0000-4000-8000-000000000909", caseId: id, status: "processing", phase: "rendering", expectedCaseVersion: 2, evidenceSetHash: "b".repeat(64), calculationSpecHash: "a".repeat(64), errorCode: null, createdAt: now, updatedAt: "2026-07-27T00:00:02.000Z", } as const; const data = { ...base, job: latest } as unknown as RectificationV4ApiResponse; const stale = { ...latest, phase: "planning_question" as const, updatedAt: "2026-07-27T00:00:01.000Z" }; assert.equal(applyRectificationV4JobUpdate(data, stale)?.job?.phase, "rendering"); }); test("analysis details render only public labels and preserve the message action icons", () => { const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); assert.match(component, /\{stage\.label\}/); assert.match(component, /\{toolCall\.label\}/); assert.match(component, /trace\.techniques\.join\("、"\)/); assert.match(component, /trace\.reasoningSource === "provider_summary"/); assert.doesNotMatch(component, />\{stage\.phase\}\{toolCall\.category\}\{item\.sourceTurnId\}/); assert.match(component, /