import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { chatSessionCreateSchema } from "../src/lib/chat-session-write-contract.ts"; import { chatProfileDisplayName, chatSessionSubjectLocked, sessionMatchesSubject, } from "../src/lib/chat-profile-picker-model.ts"; import { SYNASTRY_CHAT_INSTRUCTIONS } from "../src/mastra/synastry-report-chat.ts"; import { SYNASTRY_FIXED_REPLY, auditSynastryAnswer, settleSynastryDrafts, } from "../src/lib/synastry-chat-fact-check.ts"; import { SYNASTRY_CHAT_CHAR_BUDGET, boundSynastryRead, synastryChatContextChars, synastrySectionMaterial, } from "../src/lib/synastry-report-material.ts"; import { beginSynastrySectionChat } from "../src/lib/synastry-report-chat-client.ts"; import { authoritativeSynastrySession, synastrySectionQuestion } from "../src/lib/synastry-report-session.ts"; import { runSynastryReportTurn, synastryMissingCoverage } from "../src/lib/synastry-report-turn.ts"; import { buildTimelineSegments, type TimingPeriod } from "../src/lib/synastry-full-timeline.ts"; import { isListedSidebarSession } from "../src/lib/session-list-filter.ts"; import { synastryReportClosed } from "../src/lib/rectification-session-composer-guard.ts"; import type { ChatSession } from "../src/lib/home-types.ts"; const reportId = "83000000-0000-4000-8000-000000000083"; const otherReportId = "83000000-0000-4000-8000-000000000183"; const sessionId = "83000000-0000-4000-8000-000000000283"; function celebrityNote(): string { const golden = JSON.parse(readFileSync(new URL("../../tests/golden/upstream_sync2/relationship_einstein.json", import.meta.url), "utf8")) as unknown; const found = findNote(golden); if (!found) throw new Error("celebrity note missing"); return found; } function findNote(value: unknown): string | null { if (!value || typeof value !== "object") return null; if (Array.isArray(value)) { for (const item of value) { const found = findNote(item); if (found) return found; } return null; } const row = value as Record; if (typeof row.note === "string" && row.note.includes("Venus在10宫")) return row.note; for (const item of Object.values(row)) { const found = findNote(item); if (found) return found; } return null; } function celebrityPayload() { const note = celebrityNote(); const dasha = "土星大运 1903-01 至 1922-01"; return { names: { a: "Albert", b: "Mileva" }, zh: { sections: [{ id: "climate", title: "关系基调", text: note, reasons: [{ basis: `${note};${dasha}`, plain: "感情与事业阶段叠在一起。" }], }], }, dossier: {}, }; } function session(change: Partial): ChatSession { return { id: sessionId, title: "Albert × Mileva · 合盘追问", theme: "marriage", modelId: "m", messages: [], updatedAt: 1, sessionType: "synastry_report", rectificationCaseId: null, chartProfileId: "83000000-0000-4000-8000-000000000383", chartProfileName: "Albert × Mileva", chartProfileRole: "other", synastryReportId: reportId, synastrySection: "climate", synastryPartnerSubjectId: "self", pinned: false, archivedAt: null, messagesHydrated: true, ...change, }; } test("synastry create requires an owned section and refuses context inheritance fields", () => { const accepted = chatSessionCreateSchema.safeParse({ id: sessionId, title: "Albert × Mileva · 合盘追问", theme: "marriage", model_id: "m", messages: [], session_type: "synastry_report", rectification_case_id: null, synastry_report_id: reportId, synastry_section: "climate", }); assert.equal(accepted.success, true); assert.equal(chatSessionCreateSchema.safeParse({ id: sessionId, title: "Albert × Mileva · 合盘追问", theme: "marriage", model_id: "m", messages: [], session_type: "synastry_report", rectification_case_id: null, synastry_section: "practices", }).success, false); assert.equal(chatSessionCreateSchema.safeParse({ id: sessionId, title: "新对话", theme: "general", model_id: "m", messages: [], session_type: "consultation", rectification_case_id: null, synastry_report_id: reportId, synastry_section: "climate", }).success, false); }); test("session binding reads only the caller's ready synastry report", async () => { const bound = await authoritativeSynastrySession({ reportId, section: "timeline", loadOwnedReport: async () => ({ report_type: "synastry_full", status: "ready", subject_low: "self", subject_high: "83000000-0000-4000-8000-000000000383", report_document: { names: { a: "Albert", b: "Mileva" } }, }), }); assert.equal(bound.chart_profile_name, "Albert × Mileva"); assert.equal(bound.synastry_partner_subject_id, "83000000-0000-4000-8000-000000000383"); assert.equal(bound.rectification_case_id, null); await assert.rejects( authoritativeSynastrySession({ reportId: otherReportId, section: "climate", loadOwnedReport: async () => null }), /synastry_report_not_owned/, ); await assert.rejects( authoritativeSynastrySession({ reportId, section: "practices", loadOwnedReport: async () => ({ report_type: "synastry_full", status: "ready" }) }), /synastry_section_invalid/, ); }); test("opening a section chat only creates a session and prefills the question", async () => { const calls: string[] = []; const store = new Map(); const previous = globalThis.sessionStorage; Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, value); }, removeItem: (key: string) => { store.delete(key); }, }, }); try { const opened = await beginSynastrySectionChat({ reportId, sectionId: "climate", language: "zh", names: { a: "Albert", b: "Mileva" }, fetcher: async (input, init) => { const url = String(input); calls.push(`${init?.method ?? "GET"} ${url}`); if (url.endsWith("/api/models")) { return new Response(JSON.stringify({ defaultModelId: "model-a" }), { status: 200 }); } const body = JSON.parse(String(init?.body)) as { session_type?: string }; assert.equal(body.session_type, "synastry_report"); return new Response("{}", { status: 201 }); }, open: (href) => { calls.push(href); }, }); assert.equal(calls.some((call) => call.includes("/api/consult")), false); assert.match(opened.question, /合盘报告里「关系基调」/); assert.match(calls.at(-1) ?? "", /\/\?c=/); assert.equal(synastrySectionQuestion("climate", "en").includes("Relationship Climate"), true); } finally { Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: previous }); } }); test("celebrity material accepts a stored placement and rejects an invented house or a shifted dasha", async () => { const payload = celebrityPayload(); const material = synastrySectionMaterial({ payload, sectionId: "climate" }); assert.equal(auditSynastryAnswer(celebrityNote(), material).ok, true); assert.equal(auditSynastryAnswer("金星在 7 宫。", material).ok, false); assert.equal(auditSynastryAnswer("土星大运 1904-01 至 1922-01。", material).ok, false); assert.equal(synastryChatContextChars(SYNASTRY_CHAT_INSTRUCTIONS, material) <= SYNASTRY_CHAT_CHAR_BUDGET, true); const wrong = await runSynastryReportTurn({ question: "依据是什么?", sectionId: "climate", history: [], readMaterial: () => material, generate: async () => ({ text: "金星在 7 宫。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }), }); assert.equal(wrong.outcome, "fixed"); assert.equal(wrong.text, SYNASTRY_FIXED_REPLY); const shifted = settleSynastryDrafts({ toolCalled: true, material, first: "土星大运 1904-01 至 1922-01。", second: "土星大运 1904-01 至 1923-01。", }); assert.equal(shifted.outcome, "fixed"); assert.equal(shifted.text, SYNASTRY_FIXED_REPLY); const outside = await runSynastryReportTurn({ question: "他们哪一年领证?", sectionId: "climate", history: [], readMaterial: () => material, generate: async () => ({ text: "这份合盘没算到,建议回到各自的盘单独问。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }), }); assert.equal(outside.outcome, "pass"); assert.equal(synastryMissingCoverage(outside.text), true); const missing = await runSynastryReportTurn({ question: "依据是什么?", sectionId: "climate", history: [], readMaterial: () => material, generate: async () => ({ text: "金星在 10 宫。", toolCalls: 0, inputTokens: 1, outputTokens: 1 }), }); assert.equal(missing.outcome, "tool_missing"); assert.equal(missing.text, ""); }); test("the read tool stays on the bound report and the new files do not recast", () => { const read = boundSynastryRead({ boundReportId: reportId, requestedReportId: otherReportId, sectionId: "climate", payload: celebrityPayload(), }); assert.equal(read.reportId, reportId); assert.notEqual(read.reportId, otherReportId); for (const path of [ "../src/mastra/synastry-report-chat.ts", "../src/lib/synastry-report-consult.ts", "../src/lib/synastry-report-turn.ts", "../src/lib/synastry-report-material.ts", ]) { const source = readFileSync(new URL(path, import.meta.url), "utf8"); assert.equal(source.includes("run-jyotish-consultation"), false, path); } }); test("sidebar keeps an empty synastry chat for either person and locks the pair", () => { const row = session({}); assert.equal(isListedSidebarSession(row), true); assert.equal(sessionMatchesSubject(row, "self"), true); assert.equal(sessionMatchesSubject(row, row.chartProfileId ?? ""), true); assert.equal(sessionMatchesSubject(row, otherReportId), false); assert.equal(chatSessionSubjectLocked(row), true); assert.equal(chatProfileDisplayName({ session: row, status: "loading", library: [] }), "Albert × Mileva"); assert.equal(synastryReportClosed(session({ synastryReportId: null })), true); assert.equal(synastryReportClosed(row), false); }); function timelineBasis(language: "zh" | "en"): string { const periodsA: TimingPeriod[] = [ { level: "md", lord: "Saturn", lordCn: "土星", start: "1903-01-01", end: "1922-01-01" }, { level: "ad", lord: "Venus", lordCn: "金星", start: "1905-03-01", end: "1907-11-01" }, ]; const periodsB: TimingPeriod[] = [ { level: "md", lord: "Jupiter", lordCn: "木星", start: "1900-01-01", end: "1916-01-01" }, { level: "ad", lord: "Moon", lordCn: "月亮", start: "1905-01-01", end: "1906-06-01" }, ]; return buildTimelineSegments({ nameA: "Albert", nameB: "Mileva", periodsA, periodsB, from: "1905-06-01", language, }).map((segment) => segment.basis).join("\n"); } function shiftFirstYear(sentence: string): string { return sentence.replace(/(\d{4})/, (year) => String(Number(year) + 1)); } test("timeline basis lines reject a shifted antardasha or English mahadasha", () => { const zh = timelineBasis("zh"); const minor = zh.match(/金星小运 \d{4}-\d{2} 至 \d{4}-\d{2}/u)?.[0]; assert.ok(minor, zh); assert.equal(auditSynastryAnswer(minor, { basis: zh }).ok, true); assert.equal(auditSynastryAnswer(shiftFirstYear(minor), { basis: zh }).ok, false); const en = timelineBasis("en"); const major = en.match(/Saturn mahadasha \d{4}-\d{2} to \d{4}-\d{2}/u)?.[0]; assert.ok(major, en); assert.equal(auditSynastryAnswer(major, { basis: en }).ok, true); assert.equal(auditSynastryAnswer(shiftFirstYear(major), { basis: en }).ok, false); const antardasha = en.match(/Venus antardasha \d{4}-\d{2} to \d{4}-\d{2}/u)?.[0]; assert.ok(antardasha, en); assert.equal(auditSynastryAnswer(shiftFirstYear(antardasha), { basis: en }).ok, false); const shifted = settleSynastryDrafts({ toolCalled: true, material: { basis: zh }, first: shiftFirstYear(minor), second: shiftFirstYear(minor).replace(/至 (\d{4})/u, (_match, year: string) => `至 ${Number(year) + 1}`), }); assert.equal(shifted.outcome, "fixed"); assert.equal(shifted.text, SYNASTRY_FIXED_REPLY); });