mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
* fix(ai): keep large chat history searches responsive * fix(ai): reconcile pending search matches with current sessions
832 lines
35 KiB
TypeScript
832 lines
35 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
collectSessionSearchFields,
|
|
type SessionHistorySearchMessage,
|
|
} from "../../domain/sessionHistorySearch.ts";
|
|
import {
|
|
PINYIN_CACHE_MAX_CHARS,
|
|
getPinyinCacheStatsForTests,
|
|
resetPinyinCacheForTests,
|
|
} from "../../lib/searchMatcher.ts";
|
|
import { createSessionHistorySearch } from "./sessionHistorySearch.ts";
|
|
|
|
// One instance for the whole file: it keeps the pre-factory semantics of every
|
|
// test sharing (and clearing) a single cache.
|
|
const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch();
|
|
|
|
// Fixture messages stay loosely typed: persisted sessions carry far richer
|
|
// shapes (role, ids, payload fields, …) than the minimal searchable shapes,
|
|
// and extra properties must remain structurally harmless.
|
|
function createSession(
|
|
id: string,
|
|
title: string,
|
|
messages: Array<Record<string, unknown>>,
|
|
) {
|
|
return {
|
|
id,
|
|
title,
|
|
messages: messages.map((message, index) => ({
|
|
id: `${id}-m${index}`,
|
|
role: "user",
|
|
content: "",
|
|
timestamp: index,
|
|
...message,
|
|
})) as SessionHistorySearchMessage[],
|
|
};
|
|
}
|
|
|
|
test("filterSessionHistory matches titles and keeps order when query is blank", () => {
|
|
const sessions = [
|
|
createSession("a", "nginx restart", []),
|
|
createSession("b", "disk cleanup", []),
|
|
];
|
|
|
|
for (const query of ["", " \t\n"]) {
|
|
assert.deepEqual(filterSessionHistory(sessions, query), sessions);
|
|
}
|
|
});
|
|
|
|
test("filterSessionHistory handles special characters through the shared matcher", () => {
|
|
const sessions = [
|
|
createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]),
|
|
createSession("b", "Deploy", [{ content: "ship it" }]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []);
|
|
});
|
|
|
|
test("filterSessionHistory matches message content of user and assistant turns", () => {
|
|
const sessions = [
|
|
createSession("a", "Debugging", [
|
|
{ role: "user", content: "why is sshd down on prod-3?" },
|
|
{ role: "assistant", content: "let me check the journal" },
|
|
]),
|
|
createSession("b", "Deploy notes", [
|
|
{ role: "user", content: "ship release 1.2" },
|
|
]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
|
});
|
|
|
|
test("filterSessionHistory ignores hidden system messages", () => {
|
|
const sessions = [
|
|
createSession("a", "Ops", [
|
|
{ role: "system", content: "shared hidden prompt with marker text" },
|
|
{ role: "user", content: "all clear" },
|
|
]),
|
|
createSession("b", "Deploy", [
|
|
{ role: "system", content: "shared hidden prompt with marker text" },
|
|
{ role: "user", content: "ship it" },
|
|
]),
|
|
];
|
|
|
|
// A query found only inside system messages must not surface the sessions:
|
|
// the reopened conversation hides those messages, so a match would display
|
|
// no matching text.
|
|
assert.deepEqual(filterSessionHistory(sessions, "marker text"), []);
|
|
});
|
|
|
|
test("filterSessionHistory searches thinking and tool call/result content", () => {
|
|
const sessions = [
|
|
createSession("a", "Untitled", [
|
|
{ role: "assistant", thinking: "the certificate expired" },
|
|
]),
|
|
createSession("b", "Ops", [
|
|
{
|
|
role: "assistant",
|
|
toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }],
|
|
toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }],
|
|
},
|
|
]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]);
|
|
});
|
|
|
|
test("filterSessionHistory matches text stored only in tool call arguments", () => {
|
|
const sessions = [
|
|
createSession("a", "Ops", [
|
|
{
|
|
role: "assistant",
|
|
toolCalls: [{
|
|
name: "shell",
|
|
id: "t1",
|
|
arguments: { command: "systemctl restart nginx" },
|
|
}],
|
|
},
|
|
]),
|
|
createSession("b", "Deploy", [
|
|
{
|
|
role: "assistant",
|
|
toolCalls: [{ name: "file", id: "t2", arguments: {} }],
|
|
},
|
|
]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
|
|
|
const fields = collectSessionSearchFields(sessions[0]);
|
|
assert.ok(fields.some((field) => field.includes("systemctl restart nginx")));
|
|
});
|
|
|
|
test("filterSessionHistory matches persisted agent activities", () => {
|
|
const sessions = [
|
|
createSession("a", "Research", [
|
|
{
|
|
role: "assistant",
|
|
agentActivities: [
|
|
{ id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" },
|
|
],
|
|
},
|
|
]),
|
|
createSession("b", "Refactor", [
|
|
{
|
|
role: "assistant",
|
|
agentActivities: [
|
|
{
|
|
id: "2",
|
|
type: "file_change",
|
|
status: "completed",
|
|
changes: [
|
|
{ path: "src/sessionStore.ts", kind: "update" },
|
|
{ path: "src/sessionStore.test.ts", kind: "add" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
createSession("c", "Plan", [
|
|
{
|
|
role: "assistant",
|
|
agentActivities: [
|
|
{
|
|
id: "3",
|
|
type: "plan_update",
|
|
status: "completed",
|
|
items: [
|
|
{ text: "extract search collector", completed: true },
|
|
{ text: "wire into drawer", completed: false },
|
|
],
|
|
},
|
|
{ id: "4", type: "warning", status: "completed", message: "rate limit hit" },
|
|
],
|
|
},
|
|
]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
|
|
|
const fields = collectSessionSearchFields(sessions[2]);
|
|
assert.ok(fields.some((field) => field.includes("extract search collector")));
|
|
assert.ok(fields.some((field) => field.includes("rate limit hit")));
|
|
});
|
|
|
|
test("filterSessionHistory matches persisted error messages and attachment labels", () => {
|
|
const sessions = [
|
|
createSession("a", "Untitled", [
|
|
{
|
|
role: "assistant",
|
|
content: "",
|
|
errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true },
|
|
},
|
|
]),
|
|
createSession("b", "Deploy", [
|
|
{
|
|
role: "user",
|
|
content: "please summarize",
|
|
attachments: [
|
|
{ base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" },
|
|
{ base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" },
|
|
],
|
|
},
|
|
]),
|
|
createSession("c", "Legacy", [
|
|
{
|
|
role: "user",
|
|
content: "see attached",
|
|
images: [
|
|
{ base64Data: "", mediaType: "image/png", filename: "screenshot.png" },
|
|
],
|
|
},
|
|
]),
|
|
createSession("d", "Payloads", [
|
|
{
|
|
role: "user",
|
|
content: "",
|
|
attachments: [
|
|
{ base64Data: "AAAA", mediaType: "image/png", terminalSelection: true },
|
|
],
|
|
},
|
|
]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]);
|
|
// Attachment base64 payloads and unlabeled attachments are not indexed.
|
|
assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []);
|
|
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
|
|
|
const fields = collectSessionSearchFields(sessions[1]);
|
|
assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml")));
|
|
assert.ok(fields.some((field) => field.includes("Postgres runbook")));
|
|
assert.ok(!fields.some((field) => field === "AAAA"));
|
|
});
|
|
|
|
test("budget exhaustion inside a message keeps the newest tool calls searchable", () => {
|
|
// One assistant message bearing enough tool calls (each argument capped at
|
|
// 2,000 chars) to exhaust the remaining session budget: iteration must be
|
|
// newest-first so the latest calls (appended at the array end) stay
|
|
// searchable instead of the oldest ones winning the budget.
|
|
const session = createSession("a", "Ops", [
|
|
{
|
|
role: "assistant",
|
|
content: "y".repeat(20_000),
|
|
toolCalls: Array.from({ length: 25 }, (_, i) => ({
|
|
name: `call-${i}`,
|
|
id: `t${i}`,
|
|
arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` },
|
|
})),
|
|
},
|
|
]);
|
|
|
|
const fields = collectSessionSearchFields(session);
|
|
// The newest call (index 24, appended last) stays searchable along with the
|
|
// rest of the recent burst; the oldest call is the one dropped.
|
|
assert.ok(fields.some((field) => field.includes("marker-args-24")));
|
|
assert.ok(fields.some((field) => field.includes("marker-args-19")));
|
|
assert.ok(!fields.some((field) => field.includes("marker-args-0")));
|
|
assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]);
|
|
// Retained call fields are output in chronological order after the
|
|
// newest-first collection is reversed back (call-10 before call-19).
|
|
const call10 = fields.findIndex((field) => field.includes("call-10"));
|
|
const call19 = fields.findIndex((field) => field.includes("call-19"));
|
|
assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19);
|
|
});
|
|
|
|
test("later attachment labels stay searchable when the label list overflows the cap", () => {
|
|
// The joined labels of these attachments exceed the 2,000-character field
|
|
// cap; feeding labels individually keeps the later (newest) ones findable.
|
|
const session = createSession("a", "Deploy", [
|
|
{
|
|
role: "user",
|
|
content: "please summarize",
|
|
attachments: [
|
|
{ base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` },
|
|
{ base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const fields = collectSessionSearchFields(session);
|
|
assert.ok(fields.some((field) => field.includes("latest-run.log")));
|
|
assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]);
|
|
});
|
|
|
|
test("collectSessionSearchFields skips empty content and caps very long fields", () => {
|
|
const session = createSession("a", " ", [
|
|
{ role: "user", content: " " },
|
|
{ role: "assistant", content: "x".repeat(25_000) },
|
|
]);
|
|
|
|
const fields = collectSessionSearchFields(session);
|
|
assert.equal(fields.length, 1);
|
|
assert.equal(fields[0].length, 20_000);
|
|
});
|
|
|
|
test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => {
|
|
const sessions = [
|
|
createSession("a", "重启生产服务器", []),
|
|
createSession("b", "backup", []),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
|
|
});
|
|
|
|
test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => {
|
|
// An empty persisted title is displayed as the localized "Untitled" label;
|
|
// searching for that label must find the session instead of hiding it.
|
|
const sessions = [
|
|
createSession("a", "", [{ content: "nginx notes" }]),
|
|
createSession("b", "nginx restart", []),
|
|
];
|
|
|
|
// Without the fallback the blank title contributes nothing.
|
|
assert.deepEqual(filterSessionHistory(sessions, "untitled"), []);
|
|
assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]);
|
|
// Titled sessions are unaffected by the fallback label.
|
|
assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]);
|
|
// Localized (CJK) fallback labels match through title pinyin too.
|
|
assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]);
|
|
|
|
const fields = collectSessionSearchFields(sessions[0], "Untitled");
|
|
assert.deepEqual(fields, ["Untitled", "nginx notes"]);
|
|
});
|
|
|
|
test("filterSessionHistory lets tokens span title pinyin and message text", () => {
|
|
const sessions = [
|
|
createSession("a", "重启服务器", [
|
|
{ role: "user", content: "restarted nginx without errors" },
|
|
]),
|
|
];
|
|
|
|
const combined = filterSessionHistory(sessions, "chongqi nginx");
|
|
assert.deepEqual(combined, [sessions[0]]);
|
|
// Message-only pinyin still stays out of the expensive fallback.
|
|
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
|
|
assert.deepEqual(
|
|
filterSessionHistory(sessions, "failing nginx"),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("search field trimming stays inside the field and remaining session limits", () => {
|
|
const fieldLimited = createSession("a", "Ops", [
|
|
{ content: " ".repeat(20_000) + "outside-field" },
|
|
{ content: " recent-match " + " ".repeat(1_000_000) },
|
|
]);
|
|
assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]);
|
|
assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]);
|
|
assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []);
|
|
|
|
// Title + three full fields leave 3,997 characters for the oldest message.
|
|
// Trimming must not reach past that window to discover more content.
|
|
const sessionLimited = createSession("b", "Ops", [
|
|
{ content: " ".repeat(3_997) + "outside-session" },
|
|
...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })),
|
|
]);
|
|
assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003);
|
|
assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []);
|
|
});
|
|
|
|
test("filterSessionHistory keeps the pinyin fallback on titles only", () => {
|
|
const sessions = [
|
|
createSession("a", "Ops", [
|
|
{ role: "user", content: "重启生产服务器" },
|
|
]),
|
|
];
|
|
|
|
// Pinyin transliteration of long message content is skipped; literal CJK
|
|
// queries still match message text.
|
|
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []);
|
|
});
|
|
|
|
test("collectSessionSearchFields bounds the total searchable text per session", () => {
|
|
const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({
|
|
role: "user" as const,
|
|
content: `chunk-${i}-${"y".repeat(19_000)}`,
|
|
})));
|
|
|
|
const fields = collectSessionSearchFields(session);
|
|
const total = fields.reduce((sum, field) => sum + field.length, 0);
|
|
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
|
|
// Title is indexed first so it always lands in the bounded window.
|
|
assert.equal(fields[0], "big session");
|
|
});
|
|
|
|
test("collectSessionSearchFields keeps recent messages searchable under the cap", () => {
|
|
// Four 20,000-char messages exceed the 64 KB cap; the budget must be
|
|
// allocated newest-first so the most recent message remains searchable.
|
|
const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({
|
|
role: "user" as const,
|
|
content: `${"x".repeat(19_900)} marker-${i}`,
|
|
})));
|
|
|
|
const fields = collectSessionSearchFields(session);
|
|
assert.equal(fields[0], "big session");
|
|
// Oldest messages are dropped, the newest is retained.
|
|
assert.ok(!fields.some((field) => field.includes("marker-0")));
|
|
assert.ok(fields.some((field) => field.includes("marker-3")));
|
|
// Output order stays chronological (marker-1 before marker-2).
|
|
const marker1 = fields.findIndex((field) => field.includes("marker-1"));
|
|
const marker2 = fields.findIndex((field) => field.includes("marker-2"));
|
|
assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2);
|
|
});
|
|
|
|
test("collectSessionSearchFields truncates a field to the remaining session budget", () => {
|
|
// Four 19,000-char messages plus the title overflow the 64 KB cap; the last
|
|
// collected (oldest) message must be truncated to exactly the remaining
|
|
// budget instead of being discarded wholesale.
|
|
const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({
|
|
role: "user" as const,
|
|
content: "a".repeat(19_000),
|
|
})));
|
|
|
|
const fields = collectSessionSearchFields(session);
|
|
const total = fields.reduce((sum, field) => sum + field.length, 0);
|
|
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
|
|
assert.equal(total, 64_000);
|
|
// The oldest message fills the leftover budget with its freshest-available
|
|
// head instead of being dropped entirely.
|
|
assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000)));
|
|
assert.ok(fields.some((field) => field.startsWith("aaa")));
|
|
});
|
|
|
|
test("huge tool call arguments are serialized within the field cap without full materialization", () => {
|
|
const blob = "x".repeat(5_000_000);
|
|
const sessions = [
|
|
createSession("a", "Ops", [
|
|
{
|
|
role: "assistant",
|
|
toolCalls: [{
|
|
// Retained calls can carry uncapped payloads (e.g. `sftp_write_file`
|
|
// content); serialization must stop at the field cap instead of
|
|
// stringifying the whole argument object on every keystroke.
|
|
name: "sftp_write_file",
|
|
id: "t1",
|
|
arguments: { path: "/tmp/report.md", content: blob },
|
|
}],
|
|
},
|
|
]),
|
|
];
|
|
|
|
const fields = collectSessionSearchFields(sessions[0]);
|
|
const serialized = fields.find((field) => field.includes("/tmp/report.md"));
|
|
assert.ok(serialized);
|
|
// The serialized field (plus the tool name) must land under the total
|
|
// per-session budget cap, i.e. no multi-megabyte field is ever emitted.
|
|
assert.ok(serialized!.length <= 2_000);
|
|
assert.ok(serialized!.includes("/tmp/report.md"));
|
|
|
|
// Short payloads still serialize fully and stay searchable.
|
|
const searchable = createSession("b", "Ops", [
|
|
{
|
|
role: "assistant",
|
|
toolCalls: [{
|
|
name: "shell",
|
|
id: "t2",
|
|
arguments: { command: "systemctl restart nginx" },
|
|
}],
|
|
},
|
|
]);
|
|
assert.ok(
|
|
collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')),
|
|
);
|
|
});
|
|
|
|
test("wide tool call argument objects and long property names stay within the cap", () => {
|
|
// A wide object: thousands of own properties must be iterated lazily
|
|
// instead of materializing the full entries array on every keystroke.
|
|
const wide: Record<string, unknown> = {};
|
|
for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`;
|
|
// An unusually large property name must be capped before it is escaped.
|
|
const longKey = "k".repeat(50_000);
|
|
const sessions = [
|
|
createSession("a", "Ops", [
|
|
{
|
|
role: "assistant",
|
|
toolCalls: [{
|
|
name: "shell",
|
|
id: "t1",
|
|
arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide },
|
|
}],
|
|
},
|
|
]),
|
|
];
|
|
|
|
const fields = collectSessionSearchFields(sessions[0]);
|
|
const serialized = fields.find((field) => field.includes("systemctl restart nginx"));
|
|
assert.ok(serialized);
|
|
assert.ok(serialized!.length <= 2_000);
|
|
// The oversized key (and the wide object past the cap) never materialize:
|
|
// the long key is truncated before escaping.
|
|
assert.ok(!fields.some((field) => field.length > 2_000));
|
|
});
|
|
|
|
test("filterSessionHistory matches persisted status text", () => {
|
|
const sessions = [
|
|
createSession("a", "Untitled", [
|
|
{ role: "assistant", content: "", statusText: "generating diff for src/main.ts" },
|
|
]),
|
|
createSession("b", "Deploy", [
|
|
{ role: "user", content: "ship it" },
|
|
]),
|
|
];
|
|
|
|
assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
|
|
|
const fields = collectSessionSearchFields(sessions[0]);
|
|
assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts")));
|
|
});
|
|
|
|
// Regression: bounds enforcement must run once per scan, not on every cache
|
|
// miss. Evicting per miss makes each rebuild drop the index of a later
|
|
// session in the same scan, so any history longer than the entry cap
|
|
// cycle-evicts the whole cache and rebuilds every corpus on every keystroke.
|
|
test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => {
|
|
const sessionCount = 80;
|
|
const sessions = Array.from({ length: sessionCount }, (_, i) =>
|
|
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
|
// Observe corpus (re)builds: a cache hit serves the prepared haystack
|
|
// without touching `messages`, while a rebuild collects the fields again.
|
|
const readSessions = new Set<string>();
|
|
for (const session of sessions) {
|
|
const messages = session.messages;
|
|
Object.defineProperty(session, "messages", {
|
|
get() {
|
|
readSessions.add(session.id);
|
|
return messages;
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
filterSessionHistory(sessions, "deploy");
|
|
readSessions.clear();
|
|
const second = filterSessionHistory(sessions, "deploy");
|
|
|
|
// End-of-scan eviction only prunes the per-scan overshoot: the indexes of
|
|
// every session retained by the last prune are cache hits. Per-miss
|
|
// eviction would have rebuilt all 80 sessions here.
|
|
const rebuilt = readSessions.size;
|
|
assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`);
|
|
assert.deepEqual(second, sessions);
|
|
// Eviction must retain the FRONT of the ranked list (the sessions the next
|
|
// scan visits first) and rebuild only the oldest tail above the entry cap —
|
|
// evicting the Map's first key would drop the head and thrash it instead.
|
|
const rebuiltIds = [...readSessions].sort();
|
|
assert.deepEqual(
|
|
rebuiltIds,
|
|
sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(),
|
|
);
|
|
});
|
|
|
|
// Regression: hard-rejecting NEW entries once the entry cap is saturated by
|
|
// live sessions froze the cache's membership. New sessions are prepended to
|
|
// the ranked list, so every subsequently created conversation (and its
|
|
// immutable streaming updates) would rebuild its index on every keystroke
|
|
// while stale lower-ranked entries stayed cached — and the end-of-scan prune
|
|
// could not correct it because the cache was already within both bounds.
|
|
test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => {
|
|
const initial = Array.from({ length: 64 }, (_, i) =>
|
|
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
|
filterSessionHistory(initial, "deploy");
|
|
|
|
// A newly created session is prepended: it ranks above every cached entry.
|
|
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
|
|
const sessions = [created, ...initial];
|
|
|
|
// Observe corpus (re)builds as in the thrash regression test above.
|
|
const readSessions = new Set<string>();
|
|
for (const session of sessions) {
|
|
const messages = session.messages;
|
|
Object.defineProperty(session, "messages", {
|
|
get() {
|
|
readSessions.add(session.id);
|
|
return messages;
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
// First scan over the new list: "fresh" misses (its index was never cached)
|
|
// and must be admitted; the prune evicts the lowest-ranked tail instead.
|
|
filterSessionHistory(sessions, "deploy");
|
|
readSessions.clear();
|
|
const third = filterSessionHistory(sessions, "deploy");
|
|
|
|
assert.deepEqual(third, sessions);
|
|
// The fresh, highest-ranked session stays cached; only the evicted tail
|
|
// above the entry cap is rebuilt.
|
|
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
|
assert.deepEqual([...readSessions], ["s63"]);
|
|
});
|
|
|
|
// Regression: the entry cap must be enforced at cache-admission time, not only
|
|
// by the end-of-scan prune. When misses are small (empty/short searchable
|
|
// content), the char budget consumes almost no headroom per insert, so
|
|
// overshooting the entry cap mid-scan and letting the prune trim it made a
|
|
// single keystroke over a very long history build and transiently retain one
|
|
// index + Map entry per live session. Admission must instead evict the stale
|
|
// lowest-ranked untouched tail to make room (the same entry the prune would
|
|
// otherwise drop moments later), keeping the cache within the entry cap
|
|
// throughout the scan.
|
|
test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => {
|
|
const initial = Array.from({ length: 64 }, (_, i) =>
|
|
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
|
filterSessionHistory(initial, "deploy");
|
|
|
|
// A newly created session is prepended: it ranks above every cached entry.
|
|
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
|
|
const sessions = [created, ...initial];
|
|
|
|
// Observe corpus (re)builds as in the regression tests above.
|
|
const readSessions = new Set<string>();
|
|
for (const session of sessions) {
|
|
const messages = session.messages;
|
|
Object.defineProperty(session, "messages", {
|
|
get() {
|
|
readSessions.add(session.id);
|
|
return messages;
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
// The single scan over the prepended list must admit "fresh" by evicting
|
|
// the stalest cached tail entry ("s63") during the scan: under the old
|
|
// overshoot-then-prune behavior "s63" would stay cached until the prune,
|
|
// so it would not be rebuilt in this same scan at all. Rebuilding it here
|
|
// is the (bounded, one-entry) cost of never exceeding the entry cap
|
|
// mid-scan.
|
|
filterSessionHistory(sessions, "deploy");
|
|
assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]);
|
|
|
|
// Recovery is unchanged: the fresh session stays cached, and only the
|
|
// tail displaced below the entry cap rebuilds on the next keystroke.
|
|
readSessions.clear();
|
|
const next = filterSessionHistory(sessions, "deploy");
|
|
assert.deepEqual(next, sessions);
|
|
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
|
assert.deepEqual([...readSessions], ["s63"]);
|
|
});
|
|
|
|
// Regression: a hard-reject at the CHAR-budget gate froze cache membership
|
|
// just like the earlier entry-cap bug. With ~20 large sessions (each near the
|
|
// 64,000-character corpus cap) already consuming nearly the 4M-character
|
|
// cache budget, a newly prepended high-ranked session was rejected without
|
|
// evicting anything, and the end-of-scan prune — seeing a cache within both
|
|
// bounds — kept the stale lower-ranked entries: the new large conversation
|
|
// then rebuilt its index on every search keystroke.
|
|
test("filterSessionHistory admits a newly created session after the char budget is saturated", () => {
|
|
// Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each
|
|
// index retaining ~192K chars (normalized fields + haystack + compact
|
|
// haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it.
|
|
const createLargeSession = (id: string) =>
|
|
createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) })));
|
|
|
|
const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`));
|
|
filterSessionHistory(initial, "x");
|
|
|
|
// A newly created large session is prepended: it ranks above every cached
|
|
// entry, and its index can only fit if the stale tail is evicted.
|
|
const created = createLargeSession("fresh");
|
|
const sessions = [created, ...initial];
|
|
|
|
// Observe corpus (re)builds as in the regression tests above.
|
|
const readSessions = new Set<string>();
|
|
for (const session of sessions) {
|
|
const messages = session.messages;
|
|
Object.defineProperty(session, "messages", {
|
|
get() {
|
|
readSessions.add(session.id);
|
|
return messages;
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
// First scan over the new list: "fresh" misses (its index was never cached)
|
|
// and must be admitted by evicting the stalest cached tail entry.
|
|
filterSessionHistory(sessions, "x");
|
|
readSessions.clear();
|
|
const second = filterSessionHistory(sessions, "x");
|
|
|
|
assert.deepEqual(second, sessions);
|
|
// The fresh, highest-ranked session stays cached; only the evicted stalest
|
|
// tail session (which no longer fits the char budget) rebuilds.
|
|
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
|
assert.deepEqual([...readSessions], ["s19"]);
|
|
// And the steady state holds on subsequent scans too.
|
|
readSessions.clear();
|
|
filterSessionHistory(sessions, "x");
|
|
assert.deepEqual([...readSessions], ["s19"]);
|
|
});
|
|
|
|
test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => {
|
|
const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]);
|
|
const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]);
|
|
|
|
filterSessionHistory([kept, removed], "nginx");
|
|
// Deleting a session then clearing the query goes through the blank path,
|
|
// which must prune the cache against the remaining list.
|
|
filterSessionHistory([kept], "");
|
|
// Subsequent searches are unaffected: the surviving session stays cached
|
|
// (its cached index is still there) and matching still works.
|
|
assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]);
|
|
assert.deepEqual(filterSessionHistory([kept], "nothing here"), []);
|
|
// The removed session's index is gone entirely (no match via a stale cache).
|
|
assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]);
|
|
});
|
|
|
|
test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => {
|
|
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
|
|
|
|
filterSessionHistory([session], "nginx");
|
|
clearSessionHistorySearchCache();
|
|
// After the clear, the next search rebuilds from scratch and still matches.
|
|
assert.deepEqual(filterSessionHistory([session], "nginx"), [session]);
|
|
assert.deepEqual(filterSessionHistory([session], ""), [session]);
|
|
});
|
|
|
|
test("independent search instances own isolated caches", () => {
|
|
const first = createSessionHistorySearch();
|
|
const second = createSessionHistorySearch();
|
|
const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]);
|
|
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
|
|
// Clearing one instance must not disturb the other's state: both stay fully
|
|
// functional afterwards (each store is private to its instance).
|
|
second.clearSessionHistorySearchCache();
|
|
assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]);
|
|
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
|
|
});
|
|
|
|
test("filterSessionHistory caps the query and matches on its capped prefix", () => {
|
|
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
|
|
|
|
// A pasted multi-megabyte query must not be re-normalized per session: it is
|
|
// capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's
|
|
// input and normalized once per scan, so matching falls back to the capped
|
|
// prefix (which still spans tokens here).
|
|
const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`;
|
|
assert.deepEqual(filterSessionHistory([session], longQuery), [session]);
|
|
// The uncapped-looking but normal query still matches exactly.
|
|
assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []);
|
|
});
|
|
|
|
// Keep the author's long-title rescan protection local to history. Shared
|
|
// search must still match complete fields, including later joined fields.
|
|
test("history pinyin cache preserves long titles across same-order rescans", () => {
|
|
resetPinyinCacheForTests();
|
|
const sessions = Array.from({ length: 17 }, (_, index) =>
|
|
createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, []));
|
|
for (let pass = 0; pass < 2; pass += 1) {
|
|
assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []);
|
|
}
|
|
const stats = getPinyinCacheStatsForTests();
|
|
assert.equal(stats.size, sessions.length);
|
|
assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS);
|
|
resetPinyinCacheForTests();
|
|
});
|
|
|
|
test("history alone bounds title pinyin while retaining literal title and body matches", () => {
|
|
const sessions = [
|
|
createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]),
|
|
createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]),
|
|
];
|
|
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
|
|
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
|
|
});
|
|
|
|
test("cooperative search yields and preserves the synchronous matching and order", () => {
|
|
const search = createSessionHistorySearch();
|
|
const sessions = Array.from({ length: 90 }, (_, index) => createSession(
|
|
String(index), `Session ${index}`, [{ content: index % 7 === 0 ? "needle 中文" : "ordinary body" }],
|
|
));
|
|
const expected = search.filterSessionHistory(sessions, "needle");
|
|
const scan = search.createScan<typeof sessions[number]>("needle");
|
|
assert.equal(scan.step(sessions), null, "a large cached search still yields between slices");
|
|
let result: typeof sessions | null = null;
|
|
for (let step = 0; step < 20 && result === null; step += 1) result = scan.step(sessions);
|
|
assert.deepEqual(result, expected);
|
|
scan.dispose();
|
|
assert.equal(scan.step(sessions), null);
|
|
});
|
|
|
|
test("cooperative search incorporates live replacements and removals without restarting stable work", () => {
|
|
let reads = 0;
|
|
const stable = Array.from({ length: 90 }, (_, index) => {
|
|
const session = createSession(String(index), `Session ${index}`, []);
|
|
session.messages = [{ get content() { reads += 1; return index % 7 === 0 ? "needle" : "ordinary"; } }];
|
|
return session;
|
|
});
|
|
const search = createSessionHistorySearch();
|
|
const scan = search.createScan<typeof stable[number]>("needle");
|
|
let result: typeof stable | null = null;
|
|
let sessions = stable;
|
|
let steps = 0;
|
|
while (result === null && steps < 30) {
|
|
// The newest session changes on every slice, like an active stream.
|
|
const streaming = createSession("streaming", `Streaming ${steps}`, [{ content: "needle" }]);
|
|
sessions = [streaming, ...stable.slice(0, -1).reverse()];
|
|
result = scan.step(sessions);
|
|
steps += 1;
|
|
}
|
|
assert.ok(result, "a live stream cannot starve the other sessions");
|
|
assert.ok(steps < 10, `stable work restarted (${steps} slices)`);
|
|
assert.deepEqual(result, sessions.filter((session) => session.id === "streaming" || Number(session.id) % 7 === 0));
|
|
assert.equal(reads, 89, "each retained stable session is indexed only once");
|
|
scan.dispose();
|
|
});
|