bf8ad0d1ff
Session history was silently clipped to the first 4000 characters of the last 12 messages, so follow-ups could not see timing or audit tables. Keep an append-only tail plus a checkpoint summary, retry overflow in the same request, and expose cache hit rate in admin usage. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
4.5 KiB
TypeScript
121 lines
4.5 KiB
TypeScript
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<unknown>();
|
|
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<typeof setTimeout>) => {
|
|
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");
|
|
});
|