diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 3535e54ef..12ac2fe57 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -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 }, ); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index d0076061e..a288bc4f7 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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 => ({ + "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); + }); +}); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index ac067f930..26ef11aec 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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);