mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f2953fe1 | ||
|
|
a600c4df3a | ||
|
|
9444038ea9 | ||
|
|
36b45ca5d9 | ||
|
|
0cdfe1b294 | ||
|
|
3fd0577b86 | ||
|
|
347d141eae | ||
|
|
932bea9b9f | ||
|
|
fb96ee163d | ||
|
|
53ffe7cbb3 | ||
|
|
9cda88f22a | ||
|
|
634d182d1e | ||
|
|
a110489331 | ||
|
|
357121d33d | ||
|
|
c2724a9d16 | ||
|
|
34582d2020 | ||
|
|
4e14b6a303 | ||
|
|
4f6408a87f | ||
|
|
bbf6d24529 | ||
|
|
7eb622df79 | ||
|
|
e0ea6f603a | ||
|
|
eb863ba984 | ||
|
|
5dd50e20dc | ||
|
|
d64104560c | ||
|
|
f433b78ccd | ||
|
|
c9cb99132b | ||
|
|
02256f4181 | ||
|
|
354b8fb642 | ||
|
|
d89ab82356 | ||
|
|
1bc1a6526f | ||
|
|
b9b4e694f1 | ||
|
|
76bebb91b3 | ||
|
|
57c13a7809 | ||
|
|
d0d9468bff | ||
|
|
c653fa6c2e | ||
|
|
985f8f080c | ||
|
|
7fe4a9efc6 |
No files matched your search
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
|
||||
@@ -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 分钟)。你可以重新发送消息来重试。',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user