import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { CONSULTATION_HISTORY_TAIL_MAX_CHARS, } from "../src/lib/consultation-session-history.ts"; import { buildSummaryPrompt, checkpointSessionContextSummary, generateSessionContextSummary, messagesForSummaryInput, sanitizeSessionContextSummary, shouldCheckpoint, writeSessionContextSummary, } from "../src/lib/session-context-summary.ts"; function overBudgetConversation() { return [ { role: "user" as const, text: `问${"x".repeat(8_000)}`, requestId: "u1" }, { role: "assistant" as const, text: `答${"y".repeat(8_000)}`, requestId: "a1" }, { role: "user" as const, text: "追问应期", requestId: "u2" }, { role: "assistant" as const, text: "2027 年", requestId: "a2" }, ]; } test("checkpoint triggers only when the tail exceeds 16_000 characters", () => { assert.equal(CONSULTATION_HISTORY_TAIL_MAX_CHARS, 16_000); assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(15_999) }], null), false); assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(16_001) }], null), true); }); test("checkpoint prompt omits the last question-answer pair", () => { const messages = overBudgetConversation(); const input = messagesForSummaryInput(messages, null); assert.equal(input.length, 2); assert.equal(input[0]?.requestId, "u1"); assert.equal(input[1]?.requestId, "a1"); assert.equal(input.some((turn) => turn.text === "追问应期"), false); assert.equal(input.some((turn) => turn.text === "2027 年"), false); const prompt = buildSummaryPrompt(null, messages); assert.equal(prompt.includes("追问应期"), false); assert.equal(prompt.includes("2027 年"), false); assert.equal(prompt.includes("上一份摘要:无"), true); }); test("summary sanitizer strips ISO dates, clock times, and emails", () => { const cleaned = sanitizeSessionContextSummary( "已问过的问题\n事业时机,邮箱 user@example.com,日期 1990-01-01 08:30\n已给出的结论\nblocked 项无", ); assert.ok(cleaned); assert.equal(cleaned.includes("1990-01-01"), false); assert.equal(cleaned.includes("08:30"), false); assert.equal(cleaned.includes("user@example.com"), false); assert.equal(cleaned.includes("事业时机"), true); }); test("generateSessionContextSummary times out to null and keeps the timer ref'd", async () => { const source = readFileSync(new URL("../src/lib/session-context-summary.ts", import.meta.url), "utf8"); assert.doesNotMatch(source, /AbortSignal\.timeout\(/); assert.doesNotMatch(source, /\.unref\(/); assert.match(source, /clearTimeout/); const pending = new Set(); let created = 0; const realSetTimeout = globalThis.setTimeout; const realClearTimeout = globalThis.clearTimeout; globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => { created += 1; const id = realSetTimeout(handler, delay, ...args); pending.add(id); return id; }) as typeof setTimeout; globalThis.clearTimeout = ((id?: ReturnType) => { pending.delete(id); realClearTimeout(id); }) as typeof clearTimeout; try { const text = await generateSessionContextSummary({ previous: null, messages: overBudgetConversation(), timeoutMs: 20, generateText: () => new Promise(() => {}), }); assert.equal(text, null); assert.ok(created >= 1); assert.equal(pending.size, 0); } finally { globalThis.setTimeout = realSetTimeout; globalThis.clearTimeout = realClearTimeout; } }); test("writeSessionContextSummary abandons when updatedAt does not match", async () => { const result = await writeSessionContextSummary({ seenUpdatedAt: "2026-09-06T00:00:00.000Z", summary: { version: 1, text: "新摘要", throughRequestId: "a1", throughMessageIndex: 1, messageCount: 2, updatedAt: "2026-09-06T01:00:00.000Z", }, update: async () => false, }); assert.equal(result, "abandoned"); }); test("checkpoint writes a new summary when the tail is over budget", async () => { const result = await checkpointSessionContextSummary({ messages: overBudgetConversation(), summary: null, generateText: async () => "已问过的问题\n事业\n已给出的结论\n应期 blocked\n用户补充的事实\n无\n未决与待追问\n继续", update: async () => true, now: () => new Date("2026-09-06T02:00:00.000Z"), }); assert.equal(result, "written"); });