Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
54 changes: 54 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
88 changes: 88 additions & 0 deletions web/adapter/pi-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } = {},
) {
Expand Down
19 changes: 19 additions & 0 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions web/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading