import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { consultationHistoryCheckpointChars, } from "../src/lib/consultation-session-history.ts"; import { buildSummaryPrompt, checkpointSessionContextSummary, generateSessionContextSummary, messagesForSummaryInput, resolveInheritedContextSummary, 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 the derived threshold", () => { // Former value: hard-coded CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000. // 128k still checkpoints at 16_000 (0.4 × 40_000 budget). assert.equal(consultationHistoryCheckpointChars(128_000), 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); assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_400) }], null, { contextWindow: 64_000 }), false); assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_401) }], null, { contextWindow: 64_000 }), 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("inherited context summary copies owned text and skips foreign or empty sources", async () => { const source = { version: 1 as const, text: "已问过的问题\n事业时机", throughRequestId: "a1", throughMessageIndex: 3, messageCount: 4, updatedAt: "2026-09-15T00:00:00.000Z", }; const copied = await resolveInheritedContextSummary({ continuedFromSessionId: "11111111-1111-4111-8111-111111111111", loadOwnedSummary: async () => source, }); assert.deepEqual(copied, source); const foreign = await resolveInheritedContextSummary({ continuedFromSessionId: "22222222-2222-4222-8222-222222222222", loadOwnedSummary: async () => null, }); assert.equal(foreign, null); const empty = await resolveInheritedContextSummary({ continuedFromSessionId: "11111111-1111-4111-8111-111111111111", loadOwnedSummary: async () => ({ version: 1, text: " " }), }); assert.equal(empty, null); const skipped = await resolveInheritedContextSummary({ loadOwnedSummary: async () => { throw new Error("should not load"); }, }); assert.equal(skipped, null); }); 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"); });