BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot. BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000). BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id.
161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
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<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("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");
|
||
});
|