diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index 83ed87d4..9d75e384 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -154,6 +154,47 @@ test("snapshot pins current and selected sessions while bounding the projection" } }); +test("session metadata search finds matches outside the UI projection cap", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-session-search-")); + const sessionDirectory = join(root, "sessions"); + try { + const current = SessionManager.inMemory(root); + let latePath: string | undefined; + for (let index = 0; index <= WEB_MAX_SESSIONS; index++) { + const manager = SessionManager.create(root, sessionDirectory); + persistSession( + manager, + index === 0 ? "needle-late-unique" : `session-${index}`, + index + 1, + ); + if (index === 0) latePath = manager.getSessionFile(); + } + assert.ok(latePath); + const adapter = new PiWebAdapter( + runtimeFor(root, sessionDirectory, current), + ); + const projection = await adapter.listSessionProjection(); + assert.equal(projection.sessions.length, WEB_MAX_SESSIONS); + assert.equal( + projection.sessions.some((session) => session.path === latePath), + false, + ); + const result = await adapter.searchSessions({ + query: "needle-late-unique", + }); + assert.equal(result.status, "ok"); + if (result.status !== "ok") return; + assert.equal(result.sessions.length, 1); + assert.equal(result.sessions[0]?.path, latePath); + assert.equal(result.truncation.truncated, false); + assert.deepEqual(await adapter.searchSessions({ query: " " }), { + status: "invalid", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("discovers default Pi sessions as bounded read-only projections", async (t) => { const root = await mkdtemp(join(tmpdir(), "openpi-web-terminal-history-")); const sessionDirectory = join(root, "web-sessions"); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 6cccdb40..2cc94783 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -2078,6 +2078,60 @@ test("quiet SSE clients receive heartbeats without advancing the event cursor", } }); +test("session search rejects empty queries and searches canonical metadata", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-search-host-")); + try { + const runtime = testRuntime(cwd); + const created = SessionManager.create(cwd, cwd); + created.appendMessage({ + role: "user", + content: "canonical-search-hit", + timestamp: 1, + }); + created.appendMessage({ + role: "assistant", + content: [], + api: "openai-responses", + provider: "fixture", + model: "fixture", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }); + const { host, launched, headers } = await startTestHost(runtime); + try { + const empty = await fetch(`${launched.origin}/api/sessions/search?q=`, { + headers, + }); + assert.equal(empty.status, 400); + const found = await fetch( + `${launched.origin}/api/sessions/search?q=canonical-search-hit`, + { headers }, + ); + assert.equal(found.status, 200); + const body = (await found.json()) as { + sessions: Array<{ firstMessage: string }>; + }; + assert.equal(body.sessions.length, 1); + assert.match( + body.sessions[0]?.firstMessage ?? "", + /canonical-search-hit/, + ); + } finally { + await host.stop(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + test("adapter initialization fails before the Host starts listening", async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-startup-failure-")); const runtime = testRuntime(cwd); diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index 0a8945ec..0559afc1 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -29,6 +29,8 @@ import { WEB_MAX_ARCHIVED_SESSION_QUERY, WEB_MAX_ARCHIVED_SESSION_SCAN, WEB_MAX_SESSIONS, + WEB_MAX_SESSION_SEARCH_PAGE, + WEB_MAX_SESSION_SEARCH_QUERY, WEB_MAX_SESSION_PREVIEW, WEB_MAX_SNAPSHOT_BYTES, WEB_MAX_WORKSPACES, @@ -808,6 +810,92 @@ export class PiWebAdapter { return (await this.listSessionProjection(pinnedPath)).sessions; } + async searchSessions(options: { + query: string; + includeArchived?: boolean; + offset?: number; + limit?: number; + }) { + await this.ensureWorkspaceStateLoaded(); + await this.ensureArchivesLoaded(); + const query = options.query; + const offset = options.offset ?? 0; + const limit = options.limit ?? WEB_MAX_SESSION_SEARCH_PAGE; + if ( + typeof query !== "string" || + query.trim().length === 0 || + query.length > WEB_MAX_SESSION_SEARCH_QUERY || + /[\u0000-\u001f\u007f]/u.test(query) || + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > WEB_MAX_SESSION_SEARCH_PAGE + ) { + return { status: "invalid" as const }; + } + const normalizedQuery = query.trim().normalize("NFKC").toLocaleLowerCase(); + if (normalizedQuery.length > WEB_MAX_SESSION_SEARCH_QUERY) { + return { status: "invalid" as const }; + } + const allSessions = await SessionManager.listAll( + this.runtime.sessionDirectory, + ); + const currentId = this.runtime.sessionManager.getSessionId(); + const currentFile = this.runtime.sessionManager.getSessionFile(); + const matches = allSessions.filter((session) => { + const archived = this.archivedSessions.has(resolve(session.path)); + if (!options.includeArchived && archived) return false; + const source = [session.name ?? "", session.firstMessage, session.cwd] + .join("\n") + .normalize("NFKC") + .toLocaleLowerCase(); + return source.includes(normalizedQuery); + }); + const selected = matches.slice(offset, offset + limit); + const sessions = selected.map((session) => ({ + id: session.id, + path: session.path, + cwd: resolve(session.cwd), + source: "web-session" as const, + origin: "web" as const, + controller: + session.id === currentId && + currentFile !== undefined && + resolve(session.path) === resolve(currentFile) + ? ("web" as const) + : ("none" as const), + readOnly: false as const, + ...(session.name + ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } + : {}), + modified: session.modified.toISOString(), + created: session.created.toISOString(), + messageCount: session.messageCount, + firstMessage: boundedText( + session.firstMessage, + WEB_MAX_SESSION_PREVIEW, + ), + ...(this.archivedSessions.has(resolve(session.path)) + ? { archived: true } + : {}), + ...(this.ungroupedSessions.has(resolve(session.path)) + ? { ungrouped: true } + : {}), + })); + const pageEnd = offset + sessions.length; + const hasMore = pageEnd < matches.length; + return { + status: "ok" as const, + sessions, + ...(hasMore ? { nextOffset: pageEnd } : {}), + truncation: { + truncated: hasMore, + matchesOmitted: Math.max(0, matches.length - pageEnd), + }, + }; + } + async listReadOnlyTerminalSessions( options: { query?: string; cursor?: number; limit?: number; signal?: AbortSignal } = {}, ) { diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 852119b2..de59125d 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -864,6 +864,25 @@ export class WebHost { }, }); } + if (url.pathname === "/api/sessions/search") { + const query = url.searchParams.get("q") ?? ""; + const includeArchived = url.searchParams.get("archived") === "true"; + const result = await this.adapter.searchSessions({ + query, + includeArchived, + }); + if (result.status === "invalid") { + return this.json(response, 400, { + code: "INVALID_SESSION_QUERY", + error: "session search query is invalid or exceeds its bounds", + }); + } + return this.json(response, 200, { + query, + sessions: result.sessions, + truncation: result.truncation, + }); + } if (url.pathname === "/api/terminal-sessions") { const query = url.searchParams.get("query") ?? ""; if (query.length > 200) { diff --git a/web/protocol/types.ts b/web/protocol/types.ts index 725ababb..8ee9f96f 100644 --- a/web/protocol/types.ts +++ b/web/protocol/types.ts @@ -12,6 +12,8 @@ const WEB_MAX_METADATA_TEXT = 500; export const WEB_MAX_ENTRIES = 250; export const WEB_MAX_MESSAGE_PARTS = 64; export const WEB_MAX_SESSIONS = 500; +export const WEB_MAX_SESSION_SEARCH_QUERY = 200; +export const WEB_MAX_SESSION_SEARCH_PAGE = 100; export const WEB_MAX_WORKSPACES = 250; export const WEB_MAX_MODELS = 250; export const WEB_MAX_ARCHIVED_SESSION_PAGE = 50;