feat: add search param to sessions.list RPC

Server-side filtering backup for client-side session picker search.
Case-insensitive substring match on displayName, label, subject,
sessionId, and key.

Closes #1161
This commit is contained in:
CJ Winslow
2026-01-18 03:01:49 -08:00
committed by Peter Steinberger
parent 262e35c219
commit ddb7b5c6a4
3 changed files with 154 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ export const SessionsListParamsSchema = Type.Object(
label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);

View File

@@ -7,6 +7,7 @@ import {
capArrayByJsonBytes,
classifySessionKey,
deriveSessionTitle,
listSessionsFromStore,
parseGroupKey,
resolveGatewaySessionStoreTarget,
resolveSessionStoreKey,
@@ -187,3 +188,147 @@ describe("deriveSessionTitle", () => {
expect(deriveSessionTitle(entry)).toBe("Actual Subject");
});
});
describe("listSessionsFromStore search", () => {
const baseCfg = {
session: { mainKey: "main" },
agents: { list: [{ id: "main", default: true }] },
} as ClawdbotConfig;
const makeStore = (): Record<string, SessionEntry> => ({
"agent:main:work-project": {
sessionId: "sess-work-1",
updatedAt: Date.now(),
displayName: "Work Project Alpha",
label: "work",
} as SessionEntry,
"agent:main:personal-chat": {
sessionId: "sess-personal-1",
updatedAt: Date.now() - 1000,
displayName: "Personal Chat",
subject: "Family Reunion Planning",
} as SessionEntry,
"agent:main:discord:group:dev-team": {
sessionId: "sess-discord-1",
updatedAt: Date.now() - 2000,
label: "discord",
subject: "Dev Team Discussion",
} as SessionEntry,
});
test("returns all sessions when search is empty", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "" },
});
expect(result.sessions.length).toBe(3);
});
test("returns all sessions when search is undefined", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: {},
});
expect(result.sessions.length).toBe(3);
});
test("filters by displayName case-insensitively", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "WORK PROJECT" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
});
test("filters by subject", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "reunion" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].subject).toBe("Family Reunion Planning");
});
test("filters by label", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "discord" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].label).toBe("discord");
});
test("filters by sessionId", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "sess-personal" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].sessionId).toBe("sess-personal-1");
});
test("filters by key", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "dev-team" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team");
});
test("returns empty array when no matches", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "nonexistent-term" },
});
expect(result.sessions.length).toBe(0);
});
test("matches partial strings", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "alpha" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
});
test("trims whitespace from search query", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: " personal " },
});
expect(result.sessions.length).toBe(1);
});
});

View File

@@ -392,6 +392,7 @@ export function listSessionsFromStore(params: {
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const label = typeof opts.label === "string" ? opts.label.trim() : "";
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const search = typeof opts.search === "string" ? opts.search.trim().toLowerCase() : "";
const activeMinutes =
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
? Math.max(1, Math.floor(opts.activeMinutes))
@@ -482,6 +483,13 @@ export function listSessionsFromStore(params: {
})
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
if (search) {
sessions = sessions.filter((s) => {
const fields = [s.displayName, s.label, s.subject, s.sessionId, s.key];
return fields.some((f) => typeof f === "string" && f.toLowerCase().includes(search));
});
}
if (activeMinutes !== undefined) {
const cutoff = now - activeMinutes * 60_000;
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);