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.
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO,
|
|
CONSULTATION_HISTORY_MESSAGE_CHARS,
|
|
clipConsultationHistoryText,
|
|
consultationHistoryCheckpointChars,
|
|
consultationHistoryFromStoredMessages,
|
|
consultationHistoryWindow,
|
|
consultationUserTurnContent,
|
|
droppedRoundsMarker,
|
|
historyBudgetChars,
|
|
isContextOverflowError,
|
|
omissionMarker,
|
|
SESSION_CONTEXT_SUMMARY_HEADING,
|
|
} from "../src/lib/consultation-session-history.ts";
|
|
|
|
function numberedMessages(count: number, text: (index: number) => string) {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
role: index % 2 === 0 ? "user" : "assistant",
|
|
text: text(index),
|
|
requestId: `req-${index}`,
|
|
}));
|
|
}
|
|
|
|
test("20 unspecialized messages at 128k drop whole oldest turns to fit the budget", () => {
|
|
const body = "x".repeat(3_000);
|
|
const history = consultationHistoryWindow(numberedMessages(20, () => body), null, {
|
|
contextWindow: 128_000,
|
|
});
|
|
const budget = historyBudgetChars(128_000);
|
|
assert.equal(budget, 40_000);
|
|
assert.ok(history.droppedCount > 0);
|
|
assert.equal(history.tail.length, 20 - history.droppedCount);
|
|
assert.equal(history.tail[0]?.text, body);
|
|
assert.ok(history.tail.reduce((sum, message) => sum + message.text.length, 0) <= budget);
|
|
assert.equal(history.summaryText, null);
|
|
});
|
|
|
|
test("a summary throughMessageIndex keeps only later turns in the tail", () => {
|
|
const messages = numberedMessages(20, (index) => `keep-${index}`);
|
|
const history = consultationHistoryWindow(messages, {
|
|
version: 1,
|
|
text: "先前结论",
|
|
throughRequestId: "req-14",
|
|
throughMessageIndex: 14,
|
|
messageCount: 15,
|
|
updatedAt: "2026-09-06T00:00:00.000Z",
|
|
}, { contextWindow: 128_000 });
|
|
|
|
assert.deepEqual(history.tail.map((message) => message.text), [
|
|
"keep-15",
|
|
"keep-16",
|
|
"keep-17",
|
|
"keep-18",
|
|
"keep-19",
|
|
]);
|
|
assert.equal(history.summaryText, "先前结论");
|
|
assert.equal(history.droppedCount, 0);
|
|
});
|
|
|
|
test("a 20_000 character message is clipped to 12_000 with an omission count", () => {
|
|
const text = "y".repeat(20_000);
|
|
const clipped = clipConsultationHistoryText(text);
|
|
assert.equal(clipped.startsWith("y".repeat(CONSULTATION_HISTORY_MESSAGE_CHARS)), true);
|
|
assert.equal(clipped.includes(omissionMarker(8_000)), true);
|
|
assert.equal(clipped.length, CONSULTATION_HISTORY_MESSAGE_CHARS + omissionMarker(8_000).length);
|
|
|
|
const history = consultationHistoryWindow([
|
|
{ role: "assistant", text },
|
|
], null, { contextWindow: 128_000 });
|
|
assert.equal(history.tail[0]?.text, clipped);
|
|
});
|
|
|
|
test("historyBudgetChars uses 64k, 32k, and null-as-128k windows", () => {
|
|
assert.equal(historyBudgetChars(64_000), 6_000);
|
|
assert.equal(historyBudgetChars(32_000), 4_000);
|
|
assert.equal(historyBudgetChars(null), historyBudgetChars(128_000));
|
|
assert.equal(historyBudgetChars(undefined), 40_000);
|
|
});
|
|
|
|
test("checkpoint threshold stays below the history budget for legal context windows", () => {
|
|
assert.equal(CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO, 0.4);
|
|
const windows = [200_000, 128_000, 64_000, 32_000, null] as const;
|
|
for (const window of windows) {
|
|
const budget = historyBudgetChars(window);
|
|
const threshold = consultationHistoryCheckpointChars(window);
|
|
assert.ok(threshold < budget, `window=${window}`);
|
|
}
|
|
// 128k keeps the previous 16,000-character checkpoint.
|
|
assert.equal(consultationHistoryCheckpointChars(128_000), 16_000);
|
|
assert.equal(consultationHistoryCheckpointChars(null), 16_000);
|
|
assert.equal(consultationHistoryCheckpointChars(64_000), 2_400);
|
|
assert.equal(consultationHistoryCheckpointChars(32_000), 1_600);
|
|
});
|
|
|
|
test("dropped whole turns leave an omission marker in the user-turn summary slot", () => {
|
|
const body = "x".repeat(3_000);
|
|
const history = consultationHistoryWindow(numberedMessages(20, () => body), null, {
|
|
contextWindow: 128_000,
|
|
});
|
|
assert.ok(history.droppedCount > 0);
|
|
const withoutSummary = consultationUserTurnContent({
|
|
currentTime: "当前时间:2026-09-16 12:00(中国)",
|
|
instruction: "先加载 Jyotish Skill",
|
|
summaryText: history.summaryText,
|
|
droppedCount: history.droppedCount,
|
|
question: "刚才你说的那个时间",
|
|
});
|
|
assert.match(
|
|
withoutSummary,
|
|
new RegExp(`更早的 ${history.droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要`),
|
|
);
|
|
assert.equal(withoutSummary.includes(SESSION_CONTEXT_SUMMARY_HEADING), false);
|
|
|
|
const withSummary = consultationUserTurnContent({
|
|
currentTime: "当前时间:2026-09-16 12:00(中国)",
|
|
instruction: "先加载 Jyotish Skill",
|
|
summaryText: "先前结论",
|
|
droppedCount: history.droppedCount,
|
|
question: "刚才你说的那个时间",
|
|
});
|
|
assert.match(withSummary, new RegExp(`更早的 ${history.droppedCount} 轮问答已并入上面的会话摘要`));
|
|
assert.ok(withSummary.indexOf(SESSION_CONTEXT_SUMMARY_HEADING) < withSummary.indexOf("刚才你说的那个时间"));
|
|
|
|
const kept = consultationUserTurnContent({
|
|
currentTime: "当前时间:2026-09-16 12:00(中国)",
|
|
instruction: "先加载 Jyotish Skill",
|
|
summaryText: "先前结论",
|
|
droppedCount: 0,
|
|
question: "刚才你说的那个时间",
|
|
});
|
|
assert.equal(kept.includes("更早的"), false);
|
|
assert.equal(droppedRoundsMarker(0, true), "");
|
|
});
|
|
|
|
test("stored consultation history can skip the in-flight request id", () => {
|
|
const history = consultationHistoryFromStoredMessages([
|
|
{ role: "user", text: "old", requestId: "keep" },
|
|
{ role: "user", text: "current", requestId: "skip" },
|
|
], { excludeRequestId: "skip" });
|
|
|
|
assert.deepEqual(history, [{ role: "user", text: "old" }]);
|
|
});
|
|
|
|
test("the summary block sits after the time line and before the question", () => {
|
|
const content = consultationUserTurnContent({
|
|
currentTime: "当前时间:2026-09-06 14:00(中国)",
|
|
name: "测",
|
|
instruction: "先加载 Jyotish Skill",
|
|
summaryText: "上次应期在 2027 年",
|
|
question: "你前面说的应期是哪年",
|
|
});
|
|
const timeAt = content.indexOf("当前时间:");
|
|
const summaryAt = content.indexOf(SESSION_CONTEXT_SUMMARY_HEADING);
|
|
const questionAt = content.indexOf("你前面说的应期是哪年");
|
|
assert.ok(timeAt >= 0 && summaryAt > timeAt && questionAt > summaryAt);
|
|
});
|
|
|
|
test("context overflow detection covers provider codes and related 400s", () => {
|
|
assert.equal(isContextOverflowError({ code: "context_length_exceeded" }), true);
|
|
assert.equal(isContextOverflowError(new Error("maximum context length exceeded")), true);
|
|
assert.equal(isContextOverflowError({ message: "prompt is too long" }), true);
|
|
assert.equal(isContextOverflowError({ message: "input is too long" }), true);
|
|
assert.equal(isContextOverflowError({ message: "too many tokens in the prompt" }), true);
|
|
assert.equal(isContextOverflowError({ status: 400, message: "max_tokens for this context" }), true);
|
|
assert.equal(isContextOverflowError({ status: 400, message: "invalid json" }), false);
|
|
assert.equal(isContextOverflowError(new Error("rate limit")), false);
|
|
});
|