Files
Netcatty/application/state/sessionHistorySearch.test.ts
T
陈大猫 99e9879c9a fix(ai): keep large chat history searches responsive (#3719)
* fix(ai): keep large chat history searches responsive

* fix(ai): reconcile pending search matches with current sessions
2026-10-18 13:20:06 +08:00

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();
});