Compare commits

...
Author SHA1 Message Date
binaricat 00f2953fe1 fix(search): scope pinyin window to session history 2026-10-12 03:40:29 +00:00
netcatty-bot a600c4df3a fix: address Codex review on PR #3601 2026-10-12 03:18:58 +00:00
binaricat 9444038ea9 fix(ai): keep history search input within matching limit 2026-10-12 02:51:33 +00:00
netcatty-bot 36b45ca5d9 fix: address Codex review on PR #3601 2026-10-11 03:03:04 +00:00
netcatty-bot 0cdfe1b294 fix: address Codex review on PR #3601 2026-10-11 02:28:44 +00:00
netcatty-bot 3fd0577b86 fix: address Codex review on PR #3601 2026-10-11 02:04:00 +00:00
netcatty-bot 347d141eae fix: address Codex review on PR #3601 2026-10-11 01:22:02 +00:00
陈奇 932bea9b9f docs(ai): clarify bounded history search scope 2026-10-11 00:42:13 +00:00
陈奇 fb96ee163d refactor(ai): move session search cache into application state 2026-10-10 23:55:05 +00:00
netcatty-bot 53ffe7cbb3 fix: address Codex review on PR #3601 2026-10-10 21:06:18 +00:00
netcatty-bot 9cda88f22a fix: address Codex review on PR #3601 2026-10-10 20:36:38 +00:00
netcatty-bot 634d182d1e fix: address Codex review on PR #3601 2026-10-10 19:59:46 +00:00
netcatty-bot a110489331 fix: address Codex review on PR #3601 2026-10-10 19:35:05 +00:00
netcatty-bot 357121d33d fix: address Codex review on PR #3601 2026-10-10 19:13:31 +00:00
netcatty-bot c2724a9d16 fix: address Codex review on PR #3601 2026-10-10 18:47:07 +00:00
netcatty-bot 34582d2020 fix: address Codex review on PR #3601 2026-10-10 18:00:49 +00:00
netcatty-bot 4e14b6a303 fix: address Codex review on PR #3601 2026-10-10 17:35:07 +00:00
netcatty-bot 4f6408a87f fix: address Codex review on PR #3601 2026-10-10 17:06:48 +00:00
netcatty-bot bbf6d24529 fix: address Codex review on PR #3601 2026-10-10 16:28:57 +00:00
netcatty-bot 7eb622df79 fix: address Codex review on PR #3601 2026-10-10 16:04:47 +00:00
netcatty-bot e0ea6f603a fix: address Codex review on PR #3601 2026-10-10 15:34:41 +00:00
netcatty-bot eb863ba984 fix: address Codex review on PR #3601 2026-10-10 15:06:49 +00:00
netcatty-bot 5dd50e20dc fix: address Codex review on PR #3601 2026-10-10 14:35:40 +00:00
netcatty-bot d64104560c fix: address Codex review on PR #3601 2026-10-10 13:58:48 +00:00
netcatty-bot f433b78ccd fix: address Codex review on PR #3601 2026-10-10 13:29:56 +00:00
netcatty-bot c9cb99132b fix: address Codex review on PR #3601 2026-10-10 12:52:16 +00:00
netcatty-bot 02256f4181 fix: address Codex review on PR #3601 2026-10-10 12:10:10 +00:00
陈奇 354b8fb642 fix(ai): bound history search trimming and clarify coverage 2026-10-10 11:47:33 +00:00
netcatty-bot d89ab82356 fix: address Codex review on PR #3601 2026-10-08 11:02:06 +00:00
netcatty-bot 1bc1a6526f fix: address Codex review on PR #3601 2026-10-08 10:25:23 +00:00
netcatty-bot b9b4e694f1 fix: address Codex review on PR #3601 2026-10-08 09:52:16 +00:00
netcatty-bot 76bebb91b3 fix: address Codex review on PR #3601 2026-10-08 09:29:12 +00:00
netcatty-bot 57c13a7809 fix: address Codex review on PR #3601 2026-10-08 09:08:03 +00:00
netcatty-bot d0d9468bff fix: address Codex review on PR #3601 2026-10-08 08:46:22 +00:00
netcatty-bot c653fa6c2e fix: address Codex review on PR #3601 2026-10-08 07:55:26 +00:00
netcatty-bot 985f8f080c fix: address Codex review on PR #3601 2026-10-08 07:06:34 +00:00
netcatty-bot 7fe4a9efc6 fix(#3598): automated AI fix 2026-10-08 06:15:52 +00:00
13 changed files with 2052 additions and 25 deletions

No files matched your search

+4
View File
@@ -527,6 +527,10 @@ export const enAiMessages: Messages = {
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.searchSessions': 'Search titles and recent content…',
'ai.chat.clearSessionSearch': 'Clear session search',
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
+4
View File
@@ -518,6 +518,10 @@ export const esAiMessages: Messages = {
'ai.chat.jumpUntitled': '(mensaje vacío)',
'ai.chat.usedTools': 'Herramientas usadas: {n}',
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
+4
View File
@@ -474,6 +474,10 @@ export const ruAiMessages: Messages = {
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
+4
View File
@@ -527,6 +527,10 @@ export const zhCNAiMessages: Messages = {
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.searchSessions': '搜索标题和近期内容…',
'ai.chat.clearSessionSearch': '清空会话搜索',
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
+4
View File
@@ -517,6 +517,10 @@ export const zhTWAiMessages: Messages = {
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.searchSessions': '搜尋標題和近期內容…',
'ai.chat.clearSessionSearch': '清空會話搜尋',
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
@@ -0,0 +1,790 @@
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);
});
+382
View File
@@ -0,0 +1,382 @@
import {
getSessionSearchTitle,
matchesSessionSearchIndex,
prepareSessionSearchIndex,
type SessionHistorySearchTarget,
type SessionSearchIndex,
} from '../../domain/sessionHistorySearch';
import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher';
/**
* Memory bounds for the per-session search index cache. `filterSessionHistory`
* runs on every search keystroke, but `pruneSessionsForStorage` only bounds the
* persisted copy — `useAIState` retains hundreds of live sessions in memory,
* and rebuilding plus re-normalizing each session's (up to 64,000-character)
* haystack per keystroke scans tens of megabytes and freezes the renderer.
*
* The raw collected fields are NOT retained (only the prepared search fields),
* and
* the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries
* within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an
* unbounded cache would let one entry per live session (several normalized
* copies of each session's searchable corpus) permanently pin hundreds of
* megabytes in a long-running process. Both bounds hold after every scan's
* prune and are enforced eagerly on every insert (see
* `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate
* first evicts the cache's stale, lowest-ranked untouched tail (entries the
* in-flight scan has not yet visited — they rank below every visited session,
* so this can never cycle-evict an entry the same scan still needs); if no
* such entry remains, the insert is skipped — the miss is then rebuilt on the
* next search, exactly what the end-of-scan prune would have done to it.
* Skipping is only reachable for misses ranked below every cached entry, so
* cache membership never freezes for freshly prepended high-ranked sessions:
* at their (earliest) visit nothing is touched yet and the stale tail yields.
*/
const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64;
const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000;
/**
* Mutable cache state owned by exactly one search client (one mounted history
* drawer). Keeping this out of module scope means several concurrently mounted
* drawers (one per terminal tab) never share a cache: each drawer's prune only
* drops entries no longer in that drawer's scoped session list, and one
* drawer's unmount releases only its own store (previously a shared
* module-level cache was discarded for every drawer on any unmount and pruned
* against whichever drawer scanned last).
*/
export type SessionHistorySearchStore = {
/**
* Sessions in state are updated immutably (`{ ...s, messages: next }`), so
* object identity is a reliable cache key: each session's normalized/compact
* haystacks are built once and reused until that session's object changes.
* A changed display title (e.g. a localized fallback switch) forces a rebuild.
*/
cache: Map<SessionHistorySearchTarget, SessionSearchIndex>;
/**
* Session keys the in-flight `filterSessionHistory` scan has already visited
* (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex`
* never evicts a touched key: it either ranks above the current insert (it was
* visited earlier in this newest-first scan) or it is the current session
* itself (whose stale entry a rebuild is about to replace), and evicting either
* would cycle-evict entries the same scan still needs.
*/
scanTouched: Set<SessionHistorySearchTarget>;
/**
* Running total of the cache's retained characters. Maintained at every
* mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays
* O(1) per miss instead of re-summing the whole cache on every keystroke
* (mid-scan overshoot of the entry cap would otherwise make that sum O(n²)
* across a scan).
*/
cacheChars: number;
};
/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */
function indexRetainedChars(index: SessionSearchIndex): number {
// `normalizedFields`, `haystack` and `haystackCompact` are all retained
// (`haystack` is `normalizedFields` joined, `haystackCompact` is derived
// from `haystack`), so their combined length approximates the cache's
// per-entry memory cost. Counting only the joined haystacks would
// undercount by roughly a full copy of the corpus and let the char budget
// retain ~50% more than advertised.
let total = index.prepared.haystack.length + index.prepared.haystackCompact.length;
for (const field of index.prepared.normalizedFields) total += field.length;
return total;
}
/**
* Whether a freshly built index may be inserted into the cache without
* pushing either bound past its cap. Both bounds must fit eagerly — the prune
* that re-enforces them runs once per scan, so admitting every prepared index
* would let a single search over hundreds/thousands of uncached sessions
* transiently pin the whole corpus (several normalized haystacks per session)
* or hold one index and Map entry per live session — for empty/short sessions
* the char budget consumes little room, so the entry cap would be enforced
* only after the complete scan — before pruning, briefly allocating memory
* for the whole scanned list.
*
* Rather than rejecting inserts outright (which froze cache membership the
* same way a hard entry-count gate would), lack of headroom in either bound
* first evicts the cache's stale tail — the lowest-ranked entries this scan
* has not yet visited. Because the scan visits the ranked list newest-first,
* every untouched entry ranks below the current insert, so evicting any of
* them never drops an index a later visit of the same scan still needs (no
* cycle thrash) and always preserves the highest-ranked cached prefix. Only
* if no untouched entry remains does the insert get skipped — that is
* reachable only for misses ranked below every cached entry, which the
* end-of-scan prune would have evicted from the Map's back anyway, so the
* skip changes no steady-state membership: the index is simply rebuilt on
* the next search.
*
* The bounds check is applied to NEW entries only: cache hits merely
* re-append an existing entry (no growth), and a rebuild replaces the same
* session's entry (no entry-count growth; the char delta against the
* replaced index is checked).
*/
function canCacheSessionSearchIndex(
store: SessionHistorySearchStore,
previous: SessionSearchIndex | undefined,
index: SessionSearchIndex,
): boolean {
const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0);
// A rebuild replaces its own entry (no entry-count growth); a new entry
// grows the Map by one.
const growsEntries = !previous;
if (
deltaChars <= 0
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
if (
store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
) {
return true;
}
// Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan
// the Map's order is old-insertion-order front to back with this scan's
// touched keys re-appended at the end, so scanning the keys back-to-front
// and skipping touched ones reaches the untouched (stalest, lowest-ranked)
// entries first.
let entriesToFree = growsEntries
? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
: 0;
const keysInInsertionOrder = [...store.cache.keys()];
for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) {
if (entriesToFree <= 0
&& store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) {
break;
}
const key = keysInInsertionOrder[i];
if (store.scanTouched.has(key)) continue;
const evicted = store.cache.get(key);
if (!evicted) continue;
store.cacheChars -= indexRetainedChars(evicted);
store.cache.delete(key);
if (growsEntries) entriesToFree--;
}
return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES);
}
function getSessionSearchIndex(
store: SessionHistorySearchStore,
session: SessionHistorySearchTarget,
untitledLabel: string,
): SessionSearchIndex {
const indexedTitle = getSessionSearchTitle(session, untitledLabel);
const cached = store.cache.get(session);
if (cached && cached.displayTitle === indexedTitle) {
// Re-append on every visit so the cache's insertion order mirrors the
// latest scan's visitation order (the ranked, newest-first session list);
// `pruneSessionSearchIndexCache` relies on that order when evicting.
store.cache.delete(session);
store.cache.set(session, cached);
store.scanTouched.add(session);
return cached;
}
const index = prepareSessionSearchIndex(session, untitledLabel);
// Mark the session touched before the gate so the gate's admission eviction
// can never pick this session's own (stale) entry — the rebuild below is
// about to replace it, and evicting it would corrupt the running char total.
store.scanTouched.add(session);
if (canCacheSessionSearchIndex(store, cached, index)) {
store.cache.set(session, index);
// Keep the eager gate's running total in sync (also covers the
// display-title-change rebuild, which replaces an existing entry).
store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0);
}
return index;
}
/**
* Eviction runs once per full scan (at the end of `filterSessionHistory`), not
* on every cache miss: evicting during the scan would make each miss drop the
* index of a later session in the same scan, so any history longer than the
* entry cap would cycle-evict the whole cache and rebuild every session's
* haystack on every keystroke. Deferring eviction keeps every retained entry
* usable within a scan while still bounding the cache — first by dropping
* indexes for sessions no longer in the scanned list, then by evicting the
* lowest-ranked entries (the oldest tail of the newest-first ranked list)
* until both bounds hold, preserving the front of the ranked list.
*
* In practice the eviction loop is cheap: both bounds are enforced eagerly
* on every insert (see `canCacheSessionSearchIndex`), so the cache never
* overshoots the entry cap or char budget mid-scan — the prune only drops
* indexes for sessions that left the scanned list and re-syncs the running
* char total.
*/
function pruneSessionSearchIndexCache(
store: SessionHistorySearchStore,
sessions: readonly SessionHistorySearchTarget[],
): void {
const live = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!live.has(key)) store.cache.delete(key);
}
let retained = 0;
for (const cachedIndex of store.cache.values()) {
retained += indexRetainedChars(cachedIndex);
}
// Re-sync the eager gate's running total: the sum above is authoritative
// here (once per scan), so any drift accumulated mid-scan is corrected.
store.cacheChars = retained;
// Evict from the END of the Map until both bounds hold. Every scan visits
// the full ranked session list newest-first and `getSessionSearchIndex`
// re-appends each visited entry, so after a scan the Map's insertion order
// mirrors visitation order: the highest-ranked (newest) sessions sit at the
// FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on
// Map order) would therefore drop the newest, highest-ranked sessions and
// retain the stalest tail — every subsequent keystroke would rebuild every
// session above the cache cap. Evicting the back instead preserves the
// front of the ranked list. The single remaining entry is always kept, even
// if it alone exceeds the char budget (a single entry is bounded by ~192K
// chars by the collector caps).
// Snapshot the keys once: prune only deletes (never inserts), so popping
// from this array visits keys exactly in Map order, back to front.
const keysInInsertionOrder = [...store.cache.keys()];
while (
store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|| retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
) {
// A single entry can never push the cache past the char budget (it is
// bounded by ~192K chars by the collector caps), so keep the last entry.
if (store.cache.size <= 1) break;
const evictKey = keysInInsertionOrder.pop();
if (evictKey === undefined) break;
const evicted = store.cache.get(evictKey);
if (evicted) retained -= indexRetainedChars(evicted);
store.cache.delete(evictKey);
}
store.cacheChars = retained;
}
/**
* Release every cached index and touched-session reference for one store
* (invoked for the drawer's unmount lifecycle via the per-drawer search
* instance). Cache keys are the session objects themselves, so clearing fully
* drops all strong session references — the retained haystack characters
* become garbage together with their sessions.
*/
function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void {
store.cache.clear();
store.scanTouched.clear();
store.cacheChars = 0;
}
export type SessionHistorySearchOptions = {
/**
* Localized fallback label rendered by the drawer for sessions with an
* empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the
* displayed title so searching for the visible label finds the session.
*/
untitledLabel?: string;
};
function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
store: SessionHistorySearchStore,
sessions: readonly T[],
query: string,
options: SessionHistorySearchOptions = {},
): T[] {
const trimmed = query.trim();
if (!trimmed) {
// The blank-query path skips the scan entirely; release any stale
// scan-touched state so it cannot pin visited sessions (and their
// message/attachment memory) while the drawer is open without a query.
// Normally this set is already empty — the nonblank scan clears it in its
// `finally` — but clearing here too keeps every entry path leak-free.
store.scanTouched.clear();
// Cache keys are the session objects themselves, so an entry for a
// deleted/replaced session strongly retains that session (and its full
// message/attachment payloads). Prune against the current list here too:
// the blank-query path may run while the drawer stays open after
// deletions, and a later nonblank search that would prune otherwise may
// never happen (e.g. the user just closes the drawer).
pruneSessionSearchIndexCache(store, sessions);
return [...sessions];
}
const untitledLabel = options.untitledLabel ?? '';
// Bound and prepare the query once per scan: a pasted-in multi-megabyte
// query would otherwise be re-normalized (NFKC), tokenized and compacted
// inside `matchesSessionSearchIndex` for every session, freezing the
// renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is
// applied here at this scan's input — not inside the shared `prepareSearchQuery`
// — so the meaning of over-limit queries stays local to session history and
// the normalized forms are computed a single time, letting the per-session
// work below only do `includes` scans over the cached haystacks.
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed));
// A new scan starts with no visited sessions: the touched set records only
// this scan's visits so `canCacheSessionSearchIndex` can never mistake a
// previous scan's keys for keys the current scan still needs.
store.scanTouched.clear();
try {
// Drop entries still held by sessions that left the scan list (e.g. the
// history shrank or session objects changed since the previous scan). Those
// entries are never visited by this scan — the end-of-scan prune would
// remove them too — but dropping them up front keeps their characters
// available to this scan's inserts (via the eager char gate). Cheap: one
// Set plus one pass over the cached keys.
const currentLive = new Set<SessionHistorySearchTarget>(sessions);
for (const key of [...store.cache.keys()]) {
if (!currentLive.has(key)) {
const removed = store.cache.get(key);
if (removed) store.cacheChars -= indexRetainedChars(removed);
store.cache.delete(key);
}
}
// Scan all sessions first (a miss admits its index into the cache —
// evicting this scan's untouched stale tail whenever either bound lacks
// headroom, and skipping the insert only when no untouched entry remains),
// then prune entries that fell out of the scan list at the end of the
// scan.
const result = sessions.filter((session) => {
const index = getSessionSearchIndex(store, session, untitledLabel);
return matchesSessionSearchIndex(preparedQuery, index);
});
// Prune against the full scanned list (not the filtered result): sessions
// that merely did not match this query must keep their cached indexes.
pruneSessionSearchIndexCache(store, sessions);
return result;
} finally {
// The touched set only guards against cycle-eviction within the current
// scan; once the scan ends (even if it throws), release its strong
// references to session objects so deleted/replaced sessions and their
// large message/attachment payloads aren't retained until the next
// nonblank search.
store.scanTouched.clear();
}
}
/**
* One stateful search instance per consumer (one mounted session history
* drawer). The cache lives in the returned instance's store instead of module
* scope, so concurrently mounted drawers (one per terminal tab) never share
* state: an unmount of one drawer clears only that drawer's indexes, and each
* drawer's prune is scoped to its own session list.
*/
export type SessionHistorySearch = {
filterSessionHistory: <T extends SessionHistorySearchTarget>(
sessions: readonly T[],
query: string,
options?: SessionHistorySearchOptions,
) => T[];
clearSessionHistorySearchCache: () => void;
};
export function createSessionHistorySearch(): SessionHistorySearch {
const store: SessionHistorySearchStore = {
cache: new Map(),
scanTouched: new Set(),
cacheChars: 0,
};
return {
filterSessionHistory(sessions, query, options = {}) {
return filterSessionHistoryStore(store, sessions, query, options);
},
clearSessionHistorySearchCache() {
clearSessionHistorySearchStore(store);
},
};
}
@@ -0,0 +1,24 @@
import { useEffect, useMemo } from 'react';
import {
createSessionHistorySearch,
type SessionHistorySearch,
} from './sessionHistorySearch';
/**
* Owns one search instance per mounted session history drawer.
*
* The search index cache is bound to this instance (not module scope), so
* several concurrently mounted drawers — one per terminal tab — each keep
* their own indexes: pruning in one drawer never drops another drawer's
* entries, and the unmount below releases only this drawer's store instead of
* discarding the shared cache for every drawer. Cache keys are the session
* objects themselves, so clearing on unmount drops all strong session
* references together with the retained haystack characters.
*/
export function useSessionHistorySearch(): SessionHistorySearch {
const search = useMemo(() => createSessionHistorySearch(), []);
useEffect(() => () => {
search.clearSessionHistorySearchCache();
}, [search]);
return search;
}
@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { AISession } from "../infrastructure/ai/types.ts";
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
const dom = installDomEnvironment();
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(dom.document);
const sessions: AISession[] = Array.from({ length: 141 }, (_, i) => ({
id: `s-${i}`,
title: `Session ${i}`,
agentId: "catty",
scope: { type: "terminal", targetId: "terminal-1" },
messages: [{
id: `m-${i}`, role: "user", timestamp: 0,
content: i === 140 ? "needle [prod].json" : "ordinary content",
}],
createdAt: 0,
updatedAt: 0,
}));
const selected: string[] = [];
const deleted: string[] = [];
let closed = 0;
const render = (items = sessions, locale = "en") => renderer.render(
<I18nProvider locale={locale}>
<TooltipProvider>
<SessionHistoryDrawer
sessions={items}
activeSessionId="s-140"
onSelect={(id) => selected.push(id)}
onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }}
onClose={() => { closed += 1; }}
/>
</TooltipProvider>
</I18nProvider>,
);
const rows = () => renderer.container.querySelectorAll('[role="button"]');
const click = async (element: Element | null | undefined) => {
assert.ok(element);
await dispatchDomEvent(element, new dom.window.MouseEvent("click", { bubbles: true }));
};
const enterQuery = async (query: string) => {
const input = renderer.container.querySelector("input");
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
setValue.call(input, query);
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
};
const loadMore = () => [...renderer.container.querySelectorAll("button")]
.find((button) => button.textContent?.includes("Load more"));
try {
await render();
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await click(loadMore());
assert.equal(rows().length, 140);
await enterQuery("[prod].json");
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
assert.equal(loadMore(), undefined);
await click(rows()[0]);
await dispatchDomEvent(rows()[0], new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await click(rows()[0].querySelector("button"));
assert.deepEqual(selected, ["s-140", "s-140"]);
assert.deepEqual(deleted, ["s-140"]);
// A pasted suffix must not remain visible while the matcher silently
// ignores it: the controlled input and search accept the same prefix.
const pastedQuery = "needle ".repeat(36) + "needle absent-tail";
await enterQuery(pastedQuery);
const boundedInput = renderer.container.querySelector("input")!;
assert.equal(boundedInput.value, pastedQuery.slice(0, 256));
assert.equal(boundedInput.maxLength, 256);
assert.ok(!boundedInput.value.includes("absent-tail"));
assert.equal(rows().length, 1);
assert.match(rows()[0].textContent ?? "", /Session 140/);
await enterQuery("needle absent-tail");
assert.equal(boundedInput.value, "needle absent-tail");
assert.equal(rows().length, 0, "every visible query token must affect matching");
await enterQuery("x".repeat(255) + "\u{1f63a}");
assert.equal(boundedInput.value, "x".repeat(255), "do not retain half a surrogate pair");
await enterQuery("[](){}");
assert.equal(rows().length, 0);
assert.match(renderer.container.textContent ?? "", /No matches in titles or recent retained content/);
assert.match(renderer.container.textContent ?? "", /prioritizing newer messages and entries/);
assert.match(renderer.container.textContent ?? "", /only a limited portion at the beginning is searched/);
assert.match(renderer.container.textContent ?? "", /not full-text search/);
await click(renderer.container.querySelector("input")?.parentElement?.querySelector("button"));
assert.equal(renderer.container.querySelector("input")?.value, "");
assert.equal(rows().length, 80);
assert.match(loadMore()?.textContent ?? "", /61/);
await enterQuery(" \t");
assert.equal(rows().length, 80);
await click(renderer.container.querySelector("button"));
assert.equal(closed, 1);
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
await render(sessions, locale);
await enterQuery("no-such-content");
const messages = MESSAGES_BY_LOCALE[locale];
const input = renderer.container.querySelector("input")!;
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
assert.ok(messages[key], `${locale} is missing ${key}`);
}
assert.equal(input.placeholder, messages["ai.chat.searchSessions"]);
assert.equal(input.getAttribute("aria-label"), messages["ai.chat.searchSessions"]);
assert.equal(hint?.textContent, messages["ai.chat.searchSessionsHint"]);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
await render([], locale);
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noSessions"]));
assert.ok(!renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
}
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+64 -5
View File
@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, Trash2, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { MAX_SEARCH_QUERY_LENGTH, truncateQueryForMatch } from '../lib/searchMatcher';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
import { useSessionHistorySearch } from '../application/state/useSessionHistorySearch';
// -------------------------------------------------------------------
// Session History Drawer
@@ -31,13 +34,28 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
const [searchQuery, setSearchQuery] = useState('');
const searchHintId = useId();
const searchInputRef = useRef<HTMLInputElement>(null);
// One cache instance per mounted drawer: caches of concurrently mounted
// drawers stay independent (no shared state to prune or discard across tabs).
const search = useSessionHistorySearch();
const filteredSessions = React.useMemo(
// The localized fallback below is displayed for untitled sessions
// (row label), so it must be indexed for search as the displayed title.
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
[sessions, searchQuery, t, search],
);
// Reset the render batch when the list scope or query changes so matching
// sessions are never hidden behind stale paging.
useEffect(() => {
setRenderCount(SESSION_RENDER_BATCH);
}, [sessions]);
}, [sessions, searchQuery]);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -50,6 +68,41 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<X size={14} />
</button>
</div>
<div className="px-3 py-2 shrink-0 border-b border-border/30">
<div className="relative">
<Search
size={13}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 pointer-events-none"
/>
<Input
ref={searchInputRef}
type="text"
value={searchQuery}
maxLength={MAX_SEARCH_QUERY_LENGTH}
placeholder={t('ai.chat.searchSessions')}
aria-label={t('ai.chat.searchSessions')}
aria-describedby={searchHintId}
onChange={(event) => setSearchQuery(truncateQueryForMatch(event.target.value))}
className="h-8 pl-8 pr-7 text-[12px]"
/>
{searchQuery && (
<button
type="button"
aria-label={t('ai.chat.clearSessionSearch')}
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
<p id={searchHintId} className="mt-1.5 text-[11px] text-muted-foreground/60">
{t('ai.chat.searchSessionsHint')}
</p>
</div>
<ScrollArea className="flex-1">
<div className="px-3">
{sessions.length === 0 ? (
@@ -58,6 +111,12 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
{t('ai.chat.noSessions')}
</p>
</div>
) : filteredSessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noMatchingSessions')}
</p>
</div>
) : (
<>
{hiddenSessionCount > 0 && (
+47
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
PINYIN_CACHE_MAX_CHARS,
PINYIN_CACHE_MAX_ENTRIES,
getPinyinCacheStatsForTests,
getHostSearchMatch,
@@ -11,6 +12,21 @@ import {
resetPinyinCacheForTests,
} from "../lib/searchMatcher.ts";
test("shared pinyin search reaches later fields after a long first field", () => {
const prefix = "a".repeat(256);
for (const query of ["fuwuqi", "fwq", "nginx fuwuqi"]) {
assert.equal(matchesSearchQuery(query, `${prefix} nginx`, "服务器"), true);
}
assert.equal(matchesSearchQuery("fuwuqi", prefix, "服务器"), true);
assert.equal(matchesSearchQuery("fuwuqi missing", prefix, "服务器"), false);
});
test("shared pinyin search preserves full fields and host labels", () => {
const label = `${"a".repeat(256)}服务器`;
assert.equal(matchesSearchQuery("fuwuqi", label), true);
assert.equal(matchesHostSearchQuery("fuwuqi", { label }), true);
});
test("matches mixed Chinese and dash-separated numeric suffix with spaced query", () => {
assert.equal(
matchesSearchQuery("山东 6-1", "山东-业务交换机6-1"),
@@ -125,6 +141,18 @@ test("punctuation-only query does not match every field", () => {
assert.equal(matchesSearchQuery("-", "some-host"), false);
});
test("over-limit query keeps its full meaning instead of matching on a truncated prefix", () => {
// The shared matcher never truncates: a >256-char query whose distinguishing
// suffix is beyond the suggested `MAX_SEARCH_QUERY_LENGTH` cap must not
// match a field that only contains the cap-bounded prefix. Callers that
// rescan many haystacks (session history) bound their own input instead.
const field = "a".repeat(300);
const query = `${"a".repeat(300)} missing-suffix`;
assert.equal(matchesSearchQuery(query, field), false);
// The same query still matches when the whole query is present.
assert.equal(matchesSearchQuery(query, `${field} missing-suffix`), true);
});
test("host search avoids compact hostname false positives on numeric segments", () => {
assert.equal(
matchesHostSearchQuery("61", {
@@ -227,3 +255,22 @@ test("pinyin cache covers 8000 hosts and stays hard-bounded through import/edit
assert.ok(!stats.keys.includes("主机1"));
resetPinyinCacheForTests();
});
// Regression: short labels keep the pinyin cache under the entry cap, but the
// entry cap alone is not a memory bound — a Latin query that matches nothing
// transliterates every distinct field it scans, so long fields (e.g. up to
// MAX_SEARCHABLE_FIELD_LENGTH-character session titles) could retain several
// copies of each source in the global cache. The cache must also be bounded
// by retained characters.
test("pinyin cache stays hard-bounded by retained characters, not just entries", () => {
resetPinyinCacheForTests();
// 17 long CJK titles (~20_000 chars each): their entries can never all fit
// under the 1,000,000-char budget, so earlier ones must be evicted instead
// of pinning memory within the 16_384-entry cap.
for (let index = 0; index < 17; index += 1) {
matchesSearchQuery("unmatched-latin", "重启".repeat(10_000) + String(index));
}
assert.ok(getPinyinCacheStatsForTests().chars <= PINYIN_CACHE_MAX_CHARS);
assert.ok(getPinyinCacheStatsForTests().size > 0);
resetPinyinCacheForTests();
});
+414
View File
@@ -0,0 +1,414 @@
import type { AgentActivity } from './agentActivity';
import {
matchesPreparedSearchQuery,
prepareSearchFields,
type PreparedSearchFields,
type PreparedSearchQuery,
} from '../lib/searchMatcher';
/**
* Minimal structural shapes for session history search. Declared here (not
* imported from `infrastructure/ai/types`) so this domain module keeps no
* dependency on the infrastructure layer; persisted `AISession`s satisfy
* these shapes structurally.
*/
export type SessionHistorySearchAttachment = {
/** Rendered file label (absent for terminal selections and note-only files). */
filename?: string;
/** Rendered Vault note label. */
vaultNoteTitle?: string;
};
export type SessionHistorySearchMessage = {
/** Persisted role; 'system' messages are hidden by the UI and never indexed. */
role?: string;
content: string;
thinking?: string;
statusText?: string;
errorInfo?: { message: string };
attachments?: readonly SessionHistorySearchAttachment[];
/** @deprecated Legacy attachment field, kept for backward compatibility. */
images?: readonly SessionHistorySearchAttachment[];
toolCalls?: readonly { name: string; arguments: Record<string, unknown> }[];
toolResults?: readonly { toolName?: string; content: string }[];
agentActivities?: readonly AgentActivity[];
};
/**
* Searchable shape for session history search. Pure domain logic consumed by
* the session history drawer; matches on title plus user/assistant message
* text (hidden `system` messages are excluded, mirroring the reopened-chat
* rendering), including thinking, tool call names/arguments, tool result content,
* persisted agent activities (web-search queries, file paths, plan items,
* warnings) already stored on messages, visible attachment labels (file names
* and Vault note titles) and persisted error messages.
*/
export type SessionHistorySearchTarget = {
/** Raw persisted title; may be empty (the UI then renders a fallback). */
title: string;
messages: readonly SessionHistorySearchMessage[];
};
/** Cap per-field text so very large tool result payloads stay cheap to scan. */
const MAX_SEARCHABLE_FIELD_LENGTH = 20_000;
/** Bound history-only title transliteration without limiting shared search. */
const MAX_TITLE_PINYIN_LENGTH = 256;
/**
* Cap the total searchable text collected per session. Pinyin matching joins
* every collected field into one haystack, so a long retained session with
* hundreds of large messages could otherwise contribute megabytes that gets
* re-normalized (and pinyin-transliterated) on every keystroke.
*/
const MAX_SESSION_SEARCHABLE_TOTAL_LENGTH = 64_000;
/** Cap serialized tool-call argument text so bulky payloads stay cheap to scan. */
const MAX_TOOL_ARGUMENTS_FIELD_LENGTH = 2_000;
type SearchFieldCollector = {
fields: string[];
push: (text: unknown, maxLength?: number) => void;
/** True once the per-session budget is exhausted: further fields are dropped. */
isFull: boolean;
};
function createSearchFieldCollector(): SearchFieldCollector {
const fields: string[] = [];
let totalLength = 0;
const collector = {
fields,
push(text: unknown, maxLength: number = MAX_SEARCHABLE_FIELD_LENGTH) {
if (typeof text !== 'string') return;
// Fill the remaining per-session budget with the freshest evidence:
// truncate an oversized field instead of discarding it wholesale (whole-
// field rejection would let older, smaller fields win the leftover space).
const remaining = MAX_SESSION_SEARCHABLE_TOTAL_LENGTH - totalLength;
if (remaining <= 0) return;
const effectiveMaxLength = Math.min(maxLength, MAX_SEARCHABLE_FIELD_LENGTH, remaining);
// Bound the input before trimming so whitespace outside the searchable
// window cannot trigger an unbounded scan on every keystroke.
const capped = text.slice(0, effectiveMaxLength).trim();
if (!capped) return;
totalLength += capped.length;
fields.push(capped);
},
get isFull() {
return totalLength >= MAX_SESSION_SEARCHABLE_TOTAL_LENGTH;
},
};
return collector;
}
/** Depth bound for argument traversal so pathological nesting cannot recurse unboundedly. */
const MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH = 12;
/**
* Serialize tool-call arguments within a hard character budget: traversal and
* emitted text both stop as soon as the field cap is reached, so a retained
* call carrying a multi-megabyte blob (e.g. an uncapped `content` for
* `sftp_write_file`) never materializes in full on every search keystroke.
* The output mirrors `JSON.stringify` for the region it covers.
*/
function serializeToolCallArguments(args: Record<string, unknown>): string {
try {
let out = '';
let exhausted = false;
const emit = (text: string): boolean => {
const remaining = MAX_TOOL_ARGUMENTS_FIELD_LENGTH - out.length;
if (remaining <= 0) {
exhausted = true;
return false;
}
if (text.length > remaining) {
out += text.slice(0, remaining);
exhausted = true;
return false;
}
out += text;
return true;
};
const writeValue = (value: unknown, depth: number): boolean => {
if (exhausted) return false;
if (value === null || value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return emit('null');
}
switch (typeof value) {
case 'boolean':
return emit(value ? 'true' : 'false');
case 'number':
return emit(Number.isFinite(value) ? String(value) : 'null');
case 'bigint':
// `JSON.stringify` throws on BigInt; degrade to a cappable string.
return emit(JSON.stringify(String(value)) ?? 'null');
case 'string':
// Keep per-value allocation bounded too: escaping a multi-megabyte
// string whole would defeat the budget even if `emit` slices after.
return emit(
JSON.stringify(
value.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH
? value.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH)
: value,
) ?? 'null',
);
default: {
if (depth <= 0) return emit('null');
if (Array.isArray(value)) {
if (!emit('[')) return false;
let first = true;
for (const item of value) {
if (!first && !emit(',')) return false;
first = false;
if (!writeValue(item, depth - 1)) return false;
}
return emit(']');
}
if (typeof value === 'object') {
if (!emit('{')) return false;
let first = true;
// Iterate own properties lazily instead of materializing the full
// `Object.entries` array first: an argument object with thousands
// of keys (or one very large key) must not allocate for every
// keystroke just to have most of it sliced away again.
for (const key in value as Record<string, unknown>) {
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
if (!first && !emit(',')) return false;
first = false;
// Cap the key before escaping it: escaping a multi-megabyte
// property name whole would defeat the per-field budget.
const serializedKey = JSON.stringify(
key.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH
? key.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH)
: key,
) ?? 'null';
if (!emit(serializedKey)) return false;
if (!emit(':')) return false;
if (!writeValue(value[key as keyof typeof value], depth - 1)) return false;
}
return emit('}');
}
return emit('null');
}
}
};
writeValue(args, MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH);
return out;
} catch {
return '';
}
}
/**
* Collect the human-visible text carried by persisted agent activities
* (web-search queries, changed file paths, plan items, warnings) as
* individual fields. For external SDK sessions these live only in
* `message.agentActivities` and are rendered when the conversation is
* reopened, so they must be indexed too. Each value is fed to the collector
* on its own (capped per value, not per message) so activities accumulated
* later in one message stay searchable, and values are pushed newest-first to
* match the newest-first budget allocation of the message loop.
*/
function collectAgentActivityFields(
collector: SearchFieldCollector,
activities: readonly AgentActivity[],
): void {
for (let i = activities.length - 1; i >= 0; i--) {
if (collector.isFull) return;
const activity = activities[i];
switch (activity.type) {
case 'file_change':
for (let j = activity.changes.length - 1; j >= 0; j--) {
if (collector.isFull) return;
collector.push(activity.changes[j].path, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
break;
case 'web_search':
collector.push(activity.query, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
break;
case 'plan_update':
for (let j = activity.items.length - 1; j >= 0; j--) {
if (collector.isFull) return;
collector.push(activity.items[j].text, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
break;
case 'warning':
collector.push(activity.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
break;
}
}
}
/**
* Collect the human-visible labels of a message's attachments: file names
* and Vault note titles. Rendered from `message.attachments` when the
* conversation is reopened (with the legacy `images` field as fallback), so
* a session whose only mention of a term sits in an attachment label must
* stay findable. Only the short labels are indexed — never the base64
* payloads — so attaching files cannot blow up the search haystack.
*/
function collectAttachmentLabelFields(
collector: SearchFieldCollector,
attachments: readonly SessionHistorySearchAttachment[],
): void {
// Individual labels are handed to the collector newest-first so the 2,000-
// character field cap (and the remaining session budget) can truncate the
// list at its oldest end instead of swallowing every later label from the
// reopened conversation.
for (let i = attachments.length - 1; i >= 0; i--) {
if (collector.isFull) return;
const attachment = attachments[i];
collector.push(attachment.vaultNoteTitle, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
if (collector.isFull) return;
collector.push(attachment.filename, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
}
/**
* The title exactly as the history drawer renders it: an empty persisted
* title is displayed with the localized "Untitled" fallback, so the fallback
* text (passed by the caller) must be indexed too — searching for the label
* the user actually sees must not hide the session.
*/
function resolveDisplayTitle(session: SessionHistorySearchTarget, untitledLabel: string): string {
return session.title || untitledLabel;
}
export function collectSessionSearchFields(
session: SessionHistorySearchTarget,
untitledLabel: string = '',
): string[] {
const collector = createSearchFieldCollector();
// The displayed title is indexed first so it always survives the
// total-length cap. Still skipped entirely when both the raw title and the
// fallback are blank.
collector.push(resolveDisplayTitle(session, untitledLabel));
// Fields pushed before the message loop (0 or 1 entries; the title may be
// blank and therefore skipped).
const headCount = collector.fields.length;
// Allocate the remaining budget newest-first: when a long session exceeds
// the cap, recent messages stay searchable instead of being silently
// dropped in favor of the oldest content.
for (let i = session.messages.length - 1; i >= 0; i--) {
// Stop as soon as the per-session budget is exhausted: older messages
// cannot contribute anything, and traversing them (or serializing their
// tool arguments/activities) would waste work on every keystroke.
if (collector.isFull) break;
const message = session.messages[i];
// `ChatMessageList` removes `system` messages from the reopened
// conversation, so their (hidden) content must not make a session
// searchable — otherwise a match would show no matching text when opened.
if (message.role === 'system') continue;
collector.push(message.content);
// `push` drops fields once the budget is spent; skip the remaining
// (potentially expensive) serialization for this message too.
if (collector.isFull) continue;
collector.push(message.thinking);
if (collector.isFull) continue;
// Persisted status text: when an external SDK turn stops after an
// `onStatus` update, the final status remains in `message.statusText`,
// is persisted, and is the only distinctive visible text rendered in the
// reopened conversation. Status strings stay short, hence the tight cap.
collector.push(message.statusText, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
if (collector.isFull) continue;
// Persisted failure diagnostics: when a Catty turn fails with empty
// content the error message is the only visible text in the reopened
// conversation, so it must be indexed. Error strings are short, hence the
// tight cap.
collector.push(message.errorInfo?.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
collectAttachmentLabelFields(collector, message.attachments ?? message.images ?? []);
// Tool calls accumulate at the end of `message.toolCalls`, so iterate
// newest-first: if a burst of calls from this message exhausts the
// remaining session budget, the newest (still visible) calls stay
// searchable instead of being dropped in favor of the oldest ones.
const toolCalls = message.toolCalls ?? [];
for (let i = toolCalls.length - 1; i >= 0; i--) {
if (collector.isFull) break;
const toolCall = toolCalls[i];
collector.push(toolCall.name);
collector.push(serializeToolCallArguments(toolCall.arguments), MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
}
// Tool results accumulate at the end of `message.toolResults` (and
// `ChatMessageList` renders them in array order), so iterate newest-first:
// if a burst of large results from this message exhausts the remaining
// session budget, the newest (still visible) results stay searchable
// instead of being dropped in favor of the oldest ones.
const toolResults = message.toolResults ?? [];
for (let i = toolResults.length - 1; i >= 0; i--) {
if (collector.isFull) break;
const toolResult = toolResults[i];
collector.push(toolResult.toolName);
collector.push(toolResult.content);
}
if (collector.isFull) continue;
collectAgentActivityFields(collector, message.agentActivities ?? []);
}
const fields = collector.fields;
// Restore chronological output order: the head (title) stays first, while
// the message fields — collected newest-to-oldest — are reversed back.
const head = fields.slice(0, headCount);
const tail = fields.slice(headCount);
tail.reverse();
return [...head, ...tail];
}
export type SessionSearchIndex = {
/**
* Displayed title the index was built for (raw title or the fallback), capped
* exactly like the title field admitted by `collectSessionSearchFields`:
* this string feeds the pinyin fallback (`pinyinFields`) on every keystroke,
* so an uncapped restored title could push megabytes through pinyin
* normalization, and characters beyond the collector's cap are never indexed
* (and must not become pinyin-matchable either).
*/
displayTitle: string;
prepared: PreparedSearchFields;
};
export function getSessionSearchTitle(
session: SessionHistorySearchTarget,
untitledLabel: string = '',
): string {
const displayTitle = resolveDisplayTitle(session, untitledLabel);
// Cap the title exactly like the collector does for the title field (the
// first field pushed, so the per-field cap applies in full; trailing
// whitespace is trimmed the same way). The capped form is what the index
// stores and what the cache-change check compares, so a title that changes
// only beyond the cap reuses the cached index — correct, because the
// collector never admits those characters into the prepared haystacks
// either. A title whose capped form is blank was skipped by the collector
// entirely; the blank pinyin field is filtered out downstream.
return displayTitle.slice(0, MAX_SEARCHABLE_FIELD_LENGTH).trim();
}
export function prepareSessionSearchIndex(
session: SessionHistorySearchTarget,
untitledLabel: string = '',
): SessionSearchIndex {
const fields = collectSessionSearchFields(session, untitledLabel);
return {
displayTitle: getSessionSearchTitle(session, untitledLabel),
prepared: prepareSearchFields(fields),
};
}
export function matchesSessionSearchIndex(
query: string | PreparedSearchQuery,
index: SessionSearchIndex,
): boolean {
const titlePrefix = index.displayTitle.slice(0, MAX_TITLE_PINYIN_LENGTH);
// Do not pass a split UTF-16 surrogate pair to the transliterator.
const tail = titlePrefix.charCodeAt(titlePrefix.length - 1);
const pinyinTitle = tail >= 0xd800 && tail <= 0xdbff
? titlePrefix.slice(0, -1)
: titlePrefix;
// Pinyin transliteration is too expensive to run over every collected
// message field on each keystroke; the pinyin fallback is restricted to
// bounded title prefix, while literal/compact matching stays
// global. Restricting pinyin per field still allows tokens within one
// query to span fields: "chongqi nginx" matches a session titled
// "重启服务器" whose message contains "nginx" ("chongqi" via title
// pinyin, "nginx" literally in the message content).
return matchesPreparedSearchQuery(query, index.prepared, {
pinyinFields: [pinyinTitle],
});
}
+185 -20
View File
@@ -6,7 +6,19 @@ const SEARCH_QUERY_SEGMENT_SPLIT_REGEX = /\s+/u;
const SEARCH_QUERY_PUNCT_REGEX = /[\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]/u;
const DASH_SEPARATOR_REGEX = /[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu;
export const PINYIN_CACHE_MAX_ENTRIES = 16_384;
/**
* Hard cap on the total characters the pinyin cache retains (cache key +
* full + initials transliterations per entry, i.e. several copies of the
* source text). A per-entry cap alone is not enough: callers transliterate
* long fields (e.g. up to 20,000-character session titles), so a scan over
* many distinct large fields could retain tens or hundreds of megabytes
* within the entry count. Sources whose transliterations alone exceed this
* budget are not cached at all — they are simply re-transliterated on the
* next lookup instead of pinning memory.
*/
export const PINYIN_CACHE_MAX_CHARS = 1_000_000;
const PINYIN_CACHE = new Map<string, { full: string; initials: string }>();
let pinyinCacheChars = 0;
const IPV4_LIKE_REGEX = /^\d{1,3}(?:\.\d{1,3})+$/;
const HOST_PHASE_SCORE_BONUS = {
strict: 1_000_000,
@@ -33,6 +45,13 @@ function compactText(input: string): string {
return normalizeText(input).replace(SEARCH_REMOVE_REGEX, "");
}
function pinyinCacheEntryChars(
key: string,
entry: { full: string; initials: string },
): number {
return key.length + entry.full.length + entry.initials.length;
}
function getPinyinVariants(sourceText: string): { full: string; initials: string } {
const cacheKey = normalizeText(sourceText);
const cached = PINYIN_CACHE.get(cacheKey);
@@ -62,11 +81,22 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
}
const next = { full, initials };
const entryChars = pinyinCacheEntryChars(cacheKey, next);
// Never cache an entry that by itself exceeds the retained-char budget:
// admitting it (and evicting everything older to fit) would still let one
// oversized field pin memory above the budget, and skipping keeps it a
// simple re-transliteration on the next lookup instead of unbounded decay.
if (entryChars > PINYIN_CACHE_MAX_CHARS) return next;
pinyinCacheChars += entryChars;
PINYIN_CACHE.delete(cacheKey);
PINYIN_CACHE.set(cacheKey, next);
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES) {
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES
|| pinyinCacheChars > PINYIN_CACHE_MAX_CHARS) {
const oldestKey = PINYIN_CACHE.keys().next().value as string | undefined;
if (oldestKey === undefined) break;
const oldest = PINYIN_CACHE.get(oldestKey);
if (!oldest) break;
pinyinCacheChars -= pinyinCacheEntryChars(oldestKey, oldest);
PINYIN_CACHE.delete(oldestKey);
}
return next;
@@ -74,10 +104,19 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
export function resetPinyinCacheForTests(): void {
PINYIN_CACHE.clear();
pinyinCacheChars = 0;
}
export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } {
return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] };
export function getPinyinCacheStatsForTests(): {
size: number;
chars: number;
keys: string[];
} {
return {
size: PINYIN_CACHE.size,
chars: pinyinCacheChars,
keys: [...PINYIN_CACHE.keys()],
};
}
export function tokenizeSearchQuery(query: string): string[] {
@@ -86,35 +125,132 @@ export function tokenizeSearchQuery(query: string): string[] {
return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean);
}
export function matchesSearchQuery(
query: string,
...fields: Array<string | null | undefined>
): boolean {
const normalizedQuery = normalizeText(query);
if (!normalizedQuery) return true;
/**
* Optional matching tweaks. Callers with large unbounded haystacks (e.g.
* full conversation histories) can keep literal/compact matching global
* while restricting the (expensive) pinyin fallback to specific fields via
* `pinyinFields`: each token may still match literally anywhere in the
* haystack, but is only transliteration-matched against the listed fields.
*/
export type SearchMatchOptions = {
allowPinyin?: boolean;
/** Restrict the pinyin fallback to these fields; defaults to every field. */
pinyinFields?: string[];
};
/**
* Suggested query bound for callers that rescan many haystacks per keystroke
* (e.g. session-history search). The shared matcher never truncates: callers
* apply this cap at their own input so the bound stays local to them and the
* global match semantics (full-query meaning) are unchanged for everyone else.
*/
export const MAX_SEARCH_QUERY_LENGTH = 256;
/**
* Bound a caller-side search query to `MAX_SEARCH_QUERY_LENGTH` UTF-16 code
* units without splitting a UTF-16 surrogate pair when the cap lands mid-pair
* (e.g. an astral-plane character such as an emoji; drop the lone high
* surrogate). Truncation is a caller concern: see `MAX_SEARCH_QUERY_LENGTH`.
*/
export function truncateQueryForMatch(query: string): string {
if (query.length <= MAX_SEARCH_QUERY_LENGTH) return query;
const truncated = query.slice(0, MAX_SEARCH_QUERY_LENGTH);
// Avoid splitting a UTF-16 surrogate pair when the cap lands mid-pair (e.g.
// an astral-plane character such as an emoji); drop the lone high surrogate.
const tail = truncated.charCodeAt(truncated.length - 1);
return tail >= 0xd800 && tail <= 0xdbff ? truncated.slice(0, -1) : truncated;
}
/**
* Pre-normalized search query. Callers that match the same query against many
* haystacks (e.g. session-history search scans every session per keystroke)
* can prepare the query once per scan instead of paying NFKC normalization,
* tokenization and compaction once per session.
*/
export type PreparedSearchQuery = {
/** Normalized (NFKC, dash-folded, lowercased, trimmed) query. */
normalized: string;
/** Non-empty tokens of `normalized` after separator splitting. */
tokens: string[];
/** Separator-removed form of `normalized` (same as `compactText(normalized)`). */
compact: string;
/** `normalized` looks like a dotted IPv4 address (contiguous-literal mode). */
ipv4Like: boolean;
};
const EMPTY_PREPARED_QUERY: PreparedSearchQuery = {
normalized: "",
tokens: [],
compact: "",
ipv4Like: false,
};
export function prepareSearchQuery(query: string): PreparedSearchQuery {
// No length cap here: truncating globally would silently change match
// semantics for every caller (an over-limit query's tail would be dropped,
// so a field containing only the prefix would report a match). Callers that
// rescan many haystacks bound their own input via `truncateQueryForMatch`.
const normalized = normalizeText(query);
if (!normalized) return EMPTY_PREPARED_QUERY;
return {
normalized,
tokens: normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean),
compact: compactText(normalized),
ipv4Like: IPV4_LIKE_REGEX.test(normalized),
};
}
/**
* Pre-normalized search haystack. Callers that match the same fields against
* many queries (e.g. session-history search fires on every keystroke) can
* prepare the haystack once, cache it, and reuse it per query instead of
* re-running NFKC normalization and compaction over megabytes each time.
*/
export type PreparedSearchFields = {
/** Per-field normalized text, in input order. */
normalizedFields: string[];
/** Normalized fields joined with a single space. */
haystack: string;
/** Separator-removed form of `haystack` (same as `compactText(haystack)`). */
haystackCompact: string;
};
export function prepareSearchFields(
fields: Array<string | null | undefined>,
): PreparedSearchFields {
const normalizedFields = fields
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.map((field) => normalizeText(field));
if (normalizedFields.length === 0) return false;
const haystack = normalizedFields.join(" ");
return { normalizedFields, haystack, haystackCompact: compactText(haystack) };
}
export function matchesPreparedSearchQuery(
query: string | PreparedSearchQuery,
prepared: PreparedSearchFields,
options: SearchMatchOptions = {},
): boolean {
// Raw strings are still accepted (and prepared inline) for one-off callers;
// repeated-match callers pass `prepareSearchQuery(query)` instead.
const { normalized: normalizedQuery, tokens, compact: compactQuery, ipv4Like } =
typeof query === "string" ? prepareSearchQuery(query) : query;
if (!normalizedQuery) return true;
if (prepared.normalizedFields.length === 0) return false;
// For dotted numeric input (IPv4-like), require contiguous literal match.
if (IPV4_LIKE_REGEX.test(normalizedQuery)) {
return normalizedFields.some((field) => field.includes(normalizedQuery));
if (ipv4Like) {
return prepared.normalizedFields.some((field) => field.includes(normalizedQuery));
}
const tokens = tokenizeSearchQuery(normalizedQuery);
if (tokens.length === 0) return false;
const sourceText = normalizedFields.join(" ");
const haystack = sourceText;
const haystack = prepared.haystack;
if (haystack.includes(normalizedQuery)) {
return true;
}
const haystackCompact = compactText(sourceText);
const compactQuery = compactText(normalizedQuery);
if (compactQuery && haystackCompact.includes(compactQuery)) {
if (compactQuery && prepared.haystackCompact.includes(compactQuery)) {
return true;
}
@@ -123,9 +259,15 @@ export function matchesSearchQuery(
}
const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token));
if (!hasLatinToken) return false;
if (!hasLatinToken || options.allowPinyin === false) return false;
const { full, initials } = getPinyinVariants(sourceText);
const pinyinSource = options.pinyinFields === undefined
? haystack
: options.pinyinFields
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
.map((field) => normalizeText(field))
.join(" ");
const { full, initials } = getPinyinVariants(pinyinSource);
if (!full && !initials) return false;
return tokens.every((token) => {
@@ -138,6 +280,29 @@ export function matchesSearchQuery(
});
}
const DEFAULT_SEARCH_MATCH_OPTIONS: Required<Pick<SearchMatchOptions, "allowPinyin">> = {
allowPinyin: true,
};
export function matchesSearchQuery(
query: string,
...rest: Array<string | null | undefined | SearchMatchOptions>
): boolean {
const args = [...rest];
const options: Pick<SearchMatchOptions, "allowPinyin"> & { pinyinFields?: string[] } =
args.length > 0
&& typeof args[args.length - 1] === "object"
&& args[args.length - 1] !== null
? { ...DEFAULT_SEARCH_MATCH_OPTIONS, ...(args.pop() as SearchMatchOptions) }
: DEFAULT_SEARCH_MATCH_OPTIONS;
const fields = args as Array<string | null | undefined>;
return matchesPreparedSearchQuery(
query,
prepareSearchFields(fields),
{ allowPinyin: options.allowPinyin, pinyinFields: options.pinyinFields },
);
}
/**
* Host search should avoid mixing label/group tokens with hostname/IP tokens.
* Otherwise queries like "山东 6-1" can accidentally match: