test: add coverage for readLastMessagePreviewFromTranscript

Also add CHANGELOG entry and perf docs for session list flags.
This commit is contained in:
CJ Winslow
2026-01-19 16:49:27 -08:00
committed by Peter Steinberger
parent f2666d2092
commit 36719690a2
4 changed files with 361 additions and 178 deletions

View File

@@ -63,6 +63,7 @@ Docs: https://docs.clawd.bot
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview.
### Fixes ### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.

View File

@@ -3,84 +3,92 @@ import { Type } from "@sinclair/typebox";
import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const SessionsListParamsSchema = Type.Object( export const SessionsListParamsSchema = Type.Object(
{ {
limit: Type.Optional(Type.Integer({ minimum: 1 })), limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
includeDerivedTitles: Type.Optional(Type.Boolean()), /**
includeLastMessage: Type.Optional(Type.Boolean()), * Read first 8KB of each session transcript to derive title from first user message.
label: Type.Optional(SessionLabelString), * Performs a file read per session - use `limit` to bound result set on large stores.
spawnedBy: Type.Optional(NonEmptyString), */
agentId: Type.Optional(NonEmptyString), includeDerivedTitles: Type.Optional(Type.Boolean()),
search: Type.Optional(Type.String()), /**
}, * Read last 16KB of each session transcript to extract most recent message preview.
{ additionalProperties: false }, * Performs a file read per session - use `limit` to bound result set on large stores.
*/
includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()),
},
{ additionalProperties: false },
); );
export const SessionsResolveParamsSchema = Type.Object( export const SessionsResolveParamsSchema = Type.Object(
{ {
key: Type.Optional(NonEmptyString), key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsPatchParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional( responseUsage: Type.Optional(
Type.Union([ Type.Union([
Type.Literal("off"), Type.Literal("off"),
Type.Literal("tokens"), Type.Literal("tokens"),
Type.Literal("full"), Type.Literal("full"),
// Backward compat with older clients/stores. // Backward compat with older clients/stores.
Type.Literal("on"), Type.Literal("on"),
Type.Null(), Type.Null(),
]), ]),
), ),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional( sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
), ),
groupActivation: Type.Optional( groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
), ),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResetParamsSchema = Type.Object( export const SessionsResetParamsSchema = Type.Object(
{ key: NonEmptyString }, { key: NonEmptyString },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsDeleteParamsSchema = Type.Object( export const SessionsDeleteParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()), deleteTranscript: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsCompactParamsSchema = Type.Object( export const SessionsCompactParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })), maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@@ -2,135 +2,309 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js"; import {
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
} from "./session-utils.fs.js";
describe("readFirstUserMessageFromTranscript", () => { describe("readFirstUserMessageFromTranscript", () => {
let tmpDir: string; let tmpDir: string;
let storePath: string; let storePath: string;
beforeEach(() => { beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json"); storePath = path.join(tmpDir, "sessions.json");
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}); });
test("returns null when transcript file does not exist", () => { test("returns null when transcript file does not exist", () => {
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns first user message from transcript with string content", () => { test("returns first user message from transcript with string content", () => {
const sessionId = "test-session-1"; const sessionId = "test-session-1";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Hello world" } }), JSON.stringify({ message: { role: "user", content: "Hello world" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Hello world"); expect(result).toBe("Hello world");
}); });
test("returns first user message from transcript with array content", () => { test("returns first user message from transcript with array content", () => {
const sessionId = "test-session-2"; const sessionId = "test-session-2";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ JSON.stringify({
message: { message: {
role: "user", role: "user",
content: [{ type: "text", text: "Array message content" }], content: [{ type: "text", text: "Array message content" }],
}, },
}), }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Array message content"); expect(result).toBe("Array message content");
}); });
test("skips non-user messages to find first user message", () => { test("skips non-user messages to find first user message", () => {
const sessionId = "test-session-3"; const sessionId = "test-session-3";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "System prompt" } }), JSON.stringify({ message: { role: "system", content: "System prompt" } }),
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
JSON.stringify({ message: { role: "user", content: "First user question" } }), JSON.stringify({ message: { role: "user", content: "First user question" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("First user question"); expect(result).toBe("First user question");
}); });
test("returns null when no user messages exist", () => { test("returns null when no user messages exist", () => {
const sessionId = "test-session-4"; const sessionId = "test-session-4";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "System prompt" } }), JSON.stringify({ message: { role: "system", content: "System prompt" } }),
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("handles malformed JSON lines gracefully", () => { test("handles malformed JSON lines gracefully", () => {
const sessionId = "test-session-5"; const sessionId = "test-session-5";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
"not valid json", "not valid json",
JSON.stringify({ message: { role: "user", content: "Valid message" } }), JSON.stringify({ message: { role: "user", content: "Valid message" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Valid message"); expect(result).toBe("Valid message");
}); });
test("uses sessionFile parameter when provided", () => { test("uses sessionFile parameter when provided", () => {
const sessionId = "test-session-6"; const sessionId = "test-session-6";
const customPath = path.join(tmpDir, "custom-transcript.jsonl"); const customPath = path.join(tmpDir, "custom-transcript.jsonl");
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Custom file message" } }), JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
]; ];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
expect(result).toBe("Custom file message"); expect(result).toBe("Custom file message");
}); });
test("trims whitespace from message content", () => { test("trims whitespace from message content", () => {
const sessionId = "test-session-7"; const sessionId = "test-session-7";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
JSON.stringify({ message: { role: "user", content: " Padded message " } }), fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Padded message"); expect(result).toBe("Padded message");
}); });
test("returns null for empty content", () => { test("returns null for empty content", () => {
const sessionId = "test-session-8"; const sessionId = "test-session-8";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ message: { role: "user", content: "" } }), JSON.stringify({ message: { role: "user", content: "" } }),
JSON.stringify({ message: { role: "user", content: "Second message" } }), JSON.stringify({ message: { role: "user", content: "Second message" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Second message"); expect(result).toBe("Second message");
}); });
});
describe("readLastMessagePreviewFromTranscript", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("returns null when transcript file does not exist", () => {
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
expect(result).toBeNull();
});
test("returns null for empty file", () => {
const sessionId = "test-last-empty";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, "", "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBeNull();
});
test("returns last user message from transcript", () => {
const sessionId = "test-last-user";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "user", content: "First user" } }),
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Last user message");
});
test("returns last assistant message from transcript", () => {
const sessionId = "test-last-assistant";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "user", content: "User question" } }),
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Final assistant reply");
});
test("skips system messages to find last user/assistant", () => {
const sessionId = "test-last-skip-system";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "user", content: "Real last" } }),
JSON.stringify({ message: { role: "system", content: "System at end" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Real last");
});
test("returns null when no user/assistant messages exist", () => {
const sessionId = "test-last-no-match";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "Only system" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBeNull();
});
test("handles malformed JSON lines gracefully", () => {
const sessionId = "test-last-malformed";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "user", content: "Valid first" } }),
"not valid json at end",
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Valid first");
});
test("handles array content format", () => {
const sessionId = "test-last-array";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
message: {
role: "assistant",
content: [{ type: "text", text: "Array content response" }],
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Array content response");
});
test("uses sessionFile parameter when provided", () => {
const sessionId = "test-last-custom";
const customPath = path.join(tmpDir, "custom-last.jsonl");
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
expect(result).toBe("Custom file last");
});
test("trims whitespace from message content", () => {
const sessionId = "test-last-trim";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Padded response");
});
test("skips empty content to find previous message", () => {
const sessionId = "test-last-skip-empty";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
JSON.stringify({ message: { role: "user", content: "" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Has content");
});
test("reads from end of large file (16KB window)", () => {
const sessionId = "test-last-large";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
const lines: string[] = [];
for (let i = 0; i < 50; i++) {
lines.push(padding);
}
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Last in large file");
});
test("handles valid UTF-8 content", () => {
const sessionId = "test-last-utf8";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const validLine = JSON.stringify({
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
});
fs.writeFileSync(transcriptPath, validLine, "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
});
}); });

View File

@@ -101,9 +101,9 @@ export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
/** /**
* Prepare items for fuzzy filtering by pre-computing lowercase search text. * Prepare items for fuzzy filtering by pre-computing lowercase search text.
*/ */
export function prepareSearchItems< export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>(
T extends { label?: string; description?: string; searchText?: string }, items: T[],
>(items: T[]): (T & { searchTextLower: string })[] { ): (T & { searchTextLower: string })[] {
return items.map((item) => { return items.map((item) => {
const parts: string[] = []; const parts: string[] = [];
if (item.label) parts.push(item.label); if (item.label) parts.push(item.label);