From 3c265476ead7cfcce1d4e1499678fd5eeaefc166 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Thu, 30 Jul 2026 22:15:54 -0700 Subject: [PATCH 1/3] Make session listing independent of transcript size --- server/session/dto.ts | 3 +- server/session/service.ts | 37 ++++++------ server/session/shallowList.ts | 91 ++++++++++++++++++++++++++++++ src/app/types.ts | 2 +- src/realtime/realtime.ts | 9 ++- src/sessions/sessionDrawer.ts | 37 +++++++++++- src/status/statusBar.ts | 1 - tests/shallow-session-list.test.ts | 30 ++++++++++ 8 files changed, 182 insertions(+), 28 deletions(-) create mode 100644 server/session/shallowList.ts create mode 100644 tests/shallow-session-list.test.ts diff --git a/server/session/dto.ts b/server/session/dto.ts index 3e3d609..d10bbc5 100644 --- a/server/session/dto.ts +++ b/server/session/dto.ts @@ -95,7 +95,8 @@ export interface SessionInfoDto { firstMessage?: string; created: string; modified: string; - messageCount: number; + /** Exact for live sessions; omitted for cold sessions because deriving it requires a transcript parse. */ + messageCount?: number; cwd: string; isCurrent: false; } diff --git a/server/session/service.ts b/server/session/service.ts index 47c45db..1c87254 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -27,6 +27,7 @@ import type { SessionServiceEvent, SlashCommandDto, } from "./dto.js"; +import { shallowListSessions } from "./shallowList.js"; import { conversationTreeForSession, getSessionSlashCommands, @@ -427,10 +428,20 @@ export class LocalSessionService implements SessionService { const request = (async () => { const groups = await Promise.all(orderedCwds.map(async (cwd) => { try { - const infos = this.deps.sessionFactory?.list - ? await this.deps.sessionFactory.list(cwd) - : await SessionManager.list(cwd); - return infos.map((info) => this.simplifySessionInfo(info, cwd)); + if (this.deps.sessionFactory?.list) { + const infos = await this.deps.sessionFactory.list(cwd); + return infos.map((info) => this.simplifySessionInfo(info, cwd)); + } + return (await shallowListSessions(cwd, this.defaultSessionDir(cwd))).map((info) => { + this.rememberSessionLocation(info, cwd); + const live = this.liveById.get(info.id); + return live ? { + ...info, + name: live.getSessionName?.() || info.name, + messageCount: live.messages.length, + cwd: this.sessionCwd(live), + } : info; + }); } catch { return []; } })); return groups.flat().sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified)); @@ -820,20 +831,10 @@ export class LocalSessionService implements SessionService { const infos = await this.deps.sessionFactory.list(cwd || this.deps.globalCwd()); return infos.find((info) => info.id === id); } - if (cwd?.trim()) { - const resolvedCwd = resolve(cwd); - const info = (await SessionManager.list(resolvedCwd)).find((item) => item.id === id); - if (info?.cwd) this.knownSessionCwds.add(resolve(info.cwd)); - if (info) return info; - } - for (const knownCwd of this.knownCwds()) { - const info = (await SessionManager.list(knownCwd)).find((item) => item.id === id); - if (info?.cwd) this.knownSessionCwds.add(resolve(info.cwd)); - if (info) return info; - } - const info = (await SessionManager.listAll()).find((item) => item.id === id); - if (info?.cwd) this.knownSessionCwds.add(resolve(info.cwd)); - return info; + const location = await this.resolveSessionLocation(id, cwd); + if (!location) return undefined; + // Delete needs only the deterministic path; opening parses the selected file later. + return { id, path: location.path, cwd: location.cwd } as Awaited>[number]; } private defaultSessionDir(cwd: string) { diff --git a/server/session/shallowList.ts b/server/session/shallowList.ts new file mode 100644 index 0000000..8b9e28f --- /dev/null +++ b/server/session/shallowList.ts @@ -0,0 +1,91 @@ +import { open, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { SessionInfoDto } from "./dto.js"; + +const HEAD_BYTES = 32 * 1024; +const TAIL_BYTES = 8 * 1024; + +function parseLines(text: string) { + const entries: any[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { entries.push(JSON.parse(line)); } catch { /* a bounded read may end mid-entry */ } + } + return entries; +} + +function textContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.flatMap((part: any) => part?.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n"); +} + +function filenameMetadata(name: string) { + const match = name.match(/^(.+)_([^_]+)\.jsonl$/); + if (!match) return undefined; + const encoded = match[1]; + const iso = encoded.replace( + /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, + "$1T$2:$3:$4.$5Z", + ); + const created = new Date(iso); + if (Number.isNaN(created.getTime())) return undefined; + return { id: match[2], created }; +} + +async function boundedContents(path: string, size: number) { + const handle = await open(path, "r"); + try { + const headSize = Math.min(size, HEAD_BYTES); + const head = Buffer.allocUnsafe(headSize); + const { bytesRead: headRead } = await handle.read(head, 0, headSize, 0); + let text = head.subarray(0, headRead).toString("utf8"); + if (size > HEAD_BYTES) { + const tailSize = Math.min(size - HEAD_BYTES, TAIL_BYTES); + const tail = Buffer.allocUnsafe(tailSize); + const position = size - tailSize; + const { bytesRead: tailRead } = await handle.read(tail, 0, tailSize, position); + // Discard the first possibly partial line. + const tailText = tail.subarray(0, tailRead).toString("utf8"); + text += `\n${tailText.slice(Math.max(0, tailText.indexOf("\n") + 1))}`; + } + return text; + } finally { await handle.close(); } +} + +/** A bounded projection of pi's append-only JSONL. It never reads transcript bodies. */ +export async function shallowListSessions(cwd: string, directory: string): Promise { + let names: string[]; + try { names = await readdir(directory); } catch { return []; } + return (await Promise.all(names.filter((name) => name.endsWith(".jsonl")).map(async (name) => { + const metadata = filenameMetadata(name); + if (!metadata) return undefined; + const path = join(directory, name); + try { + const fileStat = await stat(path); + if (!fileStat.isFile()) return undefined; + const entries = parseLines(await boundedContents(path, fileStat.size)); + const header = entries.find((entry) => entry?.type === "session"); + if (header?.id && header.id !== metadata.id) return undefined; + let sessionName: string | undefined; + let firstMessage: string | undefined; + for (const entry of entries) { + if (entry?.type === "session_info") sessionName = typeof entry.name === "string" && entry.name ? entry.name : undefined; + if (!firstMessage && entry?.type === "message" && entry.message?.role === "user") { + firstMessage = textContent(entry.message.content).replace(/\s+/g, " ").trim() || undefined; + } + } + const result: SessionInfoDto = { + id: metadata.id, + path, + name: sessionName, + firstMessage, + created: metadata.created.toISOString(), + modified: fileStat.mtime.toISOString(), + cwd: typeof header?.cwd === "string" && header.cwd ? header.cwd : cwd, + isCurrent: false as const, + }; + return result; + } catch { return undefined; } + }))).filter((value): value is SessionInfoDto => value !== undefined); +} diff --git a/src/app/types.ts b/src/app/types.ts index 9100a06..6416b62 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -248,7 +248,7 @@ export type SessionInfo = { firstMessage?: string; created: string; modified: string; - messageCount: number; + messageCount?: number; cwd?: string; isCurrent: boolean; runtime?: { diff --git a/src/realtime/realtime.ts b/src/realtime/realtime.ts index 151a775..10334ce 100644 --- a/src/realtime/realtime.ts +++ b/src/realtime/realtime.ts @@ -85,10 +85,7 @@ export function createRealtime(options: { function shouldRefreshSessionsForPiEvent(event: PiEvent | undefined) { switch (event?.type) { - case "session_info_changed": case "message_end": - case "agent_end": - case "compaction_end": return true; default: return false; @@ -751,7 +748,7 @@ export function createRealtime(options: { return; } if (data.type === "session_deleted") { - if (!isReplay) scheduleSessionRefresh(); + if (!isReplay) sessions.removeSession(String(data.sessionId || "")); return; } if (data.type === "session_runtime_changed") { @@ -848,7 +845,9 @@ export function createRealtime(options: { if (data.type === "pi_event") { const eventSessionKey = String(data.sessionId || data.sessionFile || ""); noteRuntimeEvent(eventSessionKey, data.event); - if (!isReplay && shouldRefreshSessionsForPiEvent(data.event)) scheduleSessionRefresh(); + if (!isReplay && data.event?.type === "session_info_changed") { + sessions.updateSessionName(String(data.sessionId || ""), String(data.event.name || "")); + } else if (!isReplay && shouldRefreshSessionsForPiEvent(data.event)) scheduleSessionRefresh(); if (data.sessionId) { if (data.event?.type === "agent_start") { sessions.updateSessionRuntime(String(data.sessionId), { loaded: true, isRunning: true, isStreaming: true, isRetrying: false, isCompacting: false, pendingMessageCount: 0 }); diff --git a/src/sessions/sessionDrawer.ts b/src/sessions/sessionDrawer.ts index bfd202f..0207cee 100644 --- a/src/sessions/sessionDrawer.ts +++ b/src/sessions/sessionDrawer.ts @@ -12,6 +12,8 @@ export type SessionsController = { setSessionDrawerOpen: (open: boolean) => void; startNewSession: (cwd?: string) => Promise; updateSessionRuntime: (sessionId: string, runtime: SessionInfo["runtime"]) => void; + updateSessionName: (sessionId: string, name: string) => void; + removeSession: (sessionId: string) => void; beginTranscriptLoading: () => void; updateEmptyCwdChooser: () => void; finishTranscriptLoading: () => void; @@ -411,7 +413,11 @@ export function createSessions(options: { const params = new URLSearchParams(); for (const cwd of readKnownSessionCwds()) params.append("cwd", cwd); const url = params.toString() ? `/api/sessions?${params}` : "/api/sessions"; - const res = await fetch(url, { headers: api.headers() }); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15_000); + let res: Response; + try { res = await fetch(url, { headers: api.headers(), signal: controller.signal }); } + finally { window.clearTimeout(timeout); } if (!res.ok) throw new Error(await res.text()); const data = await res.json(); cachedSessions = (data.sessions || []).map((item: SessionInfo) => ({ ...item, isCurrent: item.id === state.currentSessionId })); @@ -450,6 +456,29 @@ export function createSessions(options: { renderSessionBar(); } + function updateSessionName(sessionId: string, name: string) { + if (!sessionId) return; + let changed = false; + cachedSessions = cachedSessions.map((session) => { + if (session.id !== sessionId) return session; + changed = true; + return { ...session, name: name || undefined }; + }); + if (state.sessionsById[sessionId]) state.sessionsById[sessionId].name = name || undefined; + if (changed && !elements.sessionDrawer.hidden) renderSessionList(cachedSessions); + renderSessionBar(); + } + + function removeSession(sessionId: string) { + if (!sessionId) return; + cachedSessions = cachedSessions.filter((session) => session.id !== sessionId); + delete state.sessionsById[sessionId]; + pinnedRuntimes.delete(sessionId); + if (!elements.sessionDrawer.hidden) renderSessionList(cachedSessions); + renderSessionBar(); + updateSessionButtonUnread(); + } + function updateSessionRuntime(sessionId: string, runtime: SessionInfo["runtime"]) { if (!sessionId) return; // Always cache runtime for pinned sessions — this lets renderSessionBar show @@ -1884,7 +1913,9 @@ export function createSessions(options: { const meta = document.createElement("span"); meta.className = "sessionItemMeta"; - meta.textContent = `${formatRelativeTime(item.modified)} · ${item.messageCount}`; + meta.textContent = item.messageCount === undefined + ? formatRelativeTime(item.modified) + : `${formatRelativeTime(item.modified)} · ${item.messageCount}`; navBtn.append(titleRow, meta); navBtn.addEventListener("click", async () => { @@ -2038,6 +2069,8 @@ export function createSessions(options: { updateEmptyCwdChooser, finishTranscriptLoading, updateSessionRuntime, + updateSessionName, + removeSession, renderSessionBar, renderCurrentSessionBucketButton, applySessionUiState, diff --git a/src/status/statusBar.ts b/src/status/statusBar.ts index a2dbd63..80025f9 100644 --- a/src/status/statusBar.ts +++ b/src/status/statusBar.ts @@ -75,7 +75,6 @@ export function createStatusBar(options: { const data = text ? JSON.parse(text) : {}; if (!res.ok || data.ok === false) throw new Error(data.error || text); updateMeta(data); - if (!elements.sessionDrawer.hidden) refreshSessions().catch(() => undefined); } catch (error) { setStatusTitle(previous); addMessage("system", error instanceof Error ? error.message : String(error), "error"); diff --git a/tests/shallow-session-list.test.ts b/tests/shallow-session-list.test.ts new file mode 100644 index 0000000..40f1e90 --- /dev/null +++ b/tests/shallow-session-list.test.ts @@ -0,0 +1,30 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { shallowListSessions } from "../server/session/shallowList.js"; + +const roots: string[] = []; +afterEach(async () => Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true })))); + +describe("shallow session listing", () => { + it("projects bounded metadata without depending on transcript body size", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-web-shallow-")); + roots.push(cwd); + const directory = join(cwd, "sessions"); + await mkdir(directory); + const id = "019f328a-bc2c-772b-a095-81b1ad27d054"; + const path = join(directory, `2026-07-05T13-49-40-780Z_${id}.jsonl`); + const lines = [ + { type: "session", id, timestamp: "2026-07-05T13:49:40.780Z", cwd }, + { type: "message", message: { role: "user", content: [{ type: "text", text: " First prompt " }] } }, + { type: "session_info", name: "Initial" }, + ].map(JSON.stringify); + const inflatedBody = `${JSON.stringify({ type: "message", message: { role: "assistant", content: "x".repeat(1024) } })}\n`.repeat(100); + await writeFile(path, `${lines.join("\n")}\n${inflatedBody}${JSON.stringify({ type: "session_info", name: "Late rename" })}\n`); + + const [info] = await shallowListSessions(cwd, directory); + expect(info).toMatchObject({ id, cwd, name: "Late rename", firstMessage: "First prompt", created: "2026-07-05T13:49:40.780Z" }); + expect(info.messageCount).toBeUndefined(); + }); +}); From 18cf576cbf19d0a497b9e1856b70552f929b91e3 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Thu, 30 Jul 2026 22:57:25 -0700 Subject: [PATCH 2/3] Preserve bookmarked session discovery --- server/session/service.ts | 44 ++++++++++++++++++++++++++--------- server/session/shallowList.ts | 11 ++++++++- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/server/session/service.ts b/server/session/service.ts index 1c87254..6eb1d72 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -27,7 +27,7 @@ import type { SessionServiceEvent, SlashCommandDto, } from "./dto.js"; -import { shallowListSessions } from "./shallowList.js"; +import { shallowListSessions, shallowSessionCwd } from "./shallowList.js"; import { conversationTreeForSession, getSessionSlashCommands, @@ -226,7 +226,7 @@ export class LocalSessionService implements SessionService { async cwdForSessionId(id: string) { const live = this.liveById.get(id); if (live) return this.sessionCwd(live); - const info = await this.findSessionInfoById(id); + const info = await this.findSessionLocationById(id); if (!info) throw new SessionServiceError("Session not found", 404); return info.cwd || this.deps.globalCwd(); } @@ -467,7 +467,7 @@ export class LocalSessionService implements SessionService { async delete(sessionId: string, cwd?: string): Promise { if (this.noSession) throw new Error("Sessions are disabled."); - const info = await this.findSessionInfoById(sessionId, cwd); + const info = await this.findSessionLocationById(sessionId, cwd); if (!info) throw new SessionServiceError("Session not found", 404); const live = this.liveSessions.get(info.path); if (live?.session.isStreaming || live?.session.isCompacting) throw new SessionServiceError("Wait for the session to finish before deleting it.", 409); @@ -825,16 +825,14 @@ export class LocalSessionService implements SessionService { }); } - private async findSessionInfoById(id: string, cwd?: string) { + private async findSessionLocationById(id: string, cwd?: string): Promise<{ id: string; path: string; cwd: string } | undefined> { if (!id || this.noSession) return undefined; if (this.deps.sessionFactory?.list) { - const infos = await this.deps.sessionFactory.list(cwd || this.deps.globalCwd()); - return infos.find((info) => info.id === id); + const info = (await this.deps.sessionFactory.list(cwd || this.deps.globalCwd())).find((item) => item.id === id); + return info ? { id, path: info.path, cwd: info.cwd || cwd || this.deps.globalCwd() } : undefined; } const location = await this.resolveSessionLocation(id, cwd); - if (!location) return undefined; - // Delete needs only the deterministic path; opening parses the selected file later. - return { id, path: location.path, cwd: location.cwd } as Awaited>[number]; + return location ? { id, ...location } : undefined; } private defaultSessionDir(cwd: string) { @@ -850,16 +848,40 @@ export class LocalSessionService implements SessionService { return info ? this.sessionLocations.get(id) : undefined; } const suffix = `_${id}.jsonl`; + const checkedDirectories = new Set(); for (const resolvedCwd of new Set([resolve(cwd), ...this.knownCwds()])) { + const directory = this.defaultSessionDir(resolvedCwd); + checkedDirectories.add(directory); let names: string[]; - try { names = await readdir(this.defaultSessionDir(resolvedCwd)); } catch { continue; } + try { names = await readdir(directory); } catch { continue; } const name = names.find((entry) => entry.endsWith(suffix)); if (!name) continue; - const location = { path: join(this.defaultSessionDir(resolvedCwd), name), cwd: resolvedCwd }; + const location = { path: join(directory, name), cwd: resolvedCwd }; this.sessionLocations.set(id, location); this.knownSessionCwds.add(resolvedCwd); return location; } + + // Bookmarked IDs may be opened before their cwd has been visited in this process. + // Scan directory names and filenames only, then read the one matching header. + const sessionsRoot = join(getAgentDir(), "sessions"); + let directories: string[]; + try { directories = await readdir(sessionsRoot); } catch { return undefined; } + for (const directoryName of directories) { + const directory = join(sessionsRoot, directoryName); + if (checkedDirectories.has(directory)) continue; + let names: string[]; + try { names = await readdir(directory); } catch { continue; } + const name = names.find((entry) => entry.endsWith(suffix)); + if (!name) continue; + const path = join(directory, name); + const sessionCwd = await shallowSessionCwd(path); + if (!sessionCwd || this.defaultSessionDir(sessionCwd) !== directory) continue; + const location = { path, cwd: resolve(sessionCwd) }; + this.sessionLocations.set(id, location); + this.knownSessionCwds.add(location.cwd); + return location; + } return undefined; } diff --git a/server/session/shallowList.ts b/server/session/shallowList.ts index 8b9e28f..b40935f 100644 --- a/server/session/shallowList.ts +++ b/server/session/shallowList.ts @@ -45,7 +45,8 @@ async function boundedContents(path: string, size: number) { const tail = Buffer.allocUnsafe(tailSize); const position = size - tailSize; const { bytesRead: tailRead } = await handle.read(tail, 0, tailSize, position); - // Discard the first possibly partial line. + // Deliberately drop any entry straddling the head/tail boundary, including + // contiguous 32–40 KiB reads; bounded metadata projection tolerates that loss. const tailText = tail.subarray(0, tailRead).toString("utf8"); text += `\n${tailText.slice(Math.max(0, tailText.indexOf("\n") + 1))}`; } @@ -53,6 +54,14 @@ async function boundedContents(path: string, size: number) { } finally { await handle.close(); } } +export async function shallowSessionCwd(path: string): Promise { + try { + const fileStat = await stat(path); + const header = parseLines(await boundedContents(path, fileStat.size)).find((entry) => entry?.type === "session"); + return typeof header?.cwd === "string" && header.cwd ? header.cwd : undefined; + } catch { return undefined; } +} + /** A bounded projection of pi's append-only JSONL. It never reads transcript bodies. */ export async function shallowListSessions(cwd: string, directory: string): Promise { let names: string[]; From 6040de7312a0a543a313483950d8923b7dd2754e Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Thu, 30 Jul 2026 23:15:17 -0700 Subject: [PATCH 3/3] Add session performance regression coverage --- server/session/shallowList.ts | 11 ++++++--- src/realtime/realtime.ts | 13 ++++------ src/sessions/sessionDrawer.ts | 13 ++++++---- tests/session-list-lifecycle.test.ts | 26 ++++++++++++++++++++ tests/session-refresh-contract.test.ts | 21 ++++++++++++++++ tests/shallow-session-list.test.ts | 33 ++++++++++++++++++++++++-- 6 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 tests/session-list-lifecycle.test.ts create mode 100644 tests/session-refresh-contract.test.ts diff --git a/server/session/shallowList.ts b/server/session/shallowList.ts index b40935f..9032f35 100644 --- a/server/session/shallowList.ts +++ b/server/session/shallowList.ts @@ -33,18 +33,22 @@ function filenameMetadata(name: string) { return { id: match[2], created }; } -async function boundedContents(path: string, size: number) { +export interface ShallowListMetrics { files: number; bytesRead: number } + +async function boundedContents(path: string, size: number, metrics?: ShallowListMetrics) { const handle = await open(path, "r"); try { const headSize = Math.min(size, HEAD_BYTES); const head = Buffer.allocUnsafe(headSize); const { bytesRead: headRead } = await handle.read(head, 0, headSize, 0); + if (metrics) metrics.bytesRead += headRead; let text = head.subarray(0, headRead).toString("utf8"); if (size > HEAD_BYTES) { const tailSize = Math.min(size - HEAD_BYTES, TAIL_BYTES); const tail = Buffer.allocUnsafe(tailSize); const position = size - tailSize; const { bytesRead: tailRead } = await handle.read(tail, 0, tailSize, position); + if (metrics) metrics.bytesRead += tailRead; // Deliberately drop any entry straddling the head/tail boundary, including // contiguous 32–40 KiB reads; bounded metadata projection tolerates that loss. const tailText = tail.subarray(0, tailRead).toString("utf8"); @@ -63,7 +67,7 @@ export async function shallowSessionCwd(path: string): Promise { +export async function shallowListSessions(cwd: string, directory: string, metrics?: ShallowListMetrics): Promise { let names: string[]; try { names = await readdir(directory); } catch { return []; } return (await Promise.all(names.filter((name) => name.endsWith(".jsonl")).map(async (name) => { @@ -73,7 +77,8 @@ export async function shallowListSessions(cwd: string, directory: string): Promi try { const fileStat = await stat(path); if (!fileStat.isFile()) return undefined; - const entries = parseLines(await boundedContents(path, fileStat.size)); + if (metrics) metrics.files += 1; + const entries = parseLines(await boundedContents(path, fileStat.size, metrics)); const header = entries.find((entry) => entry?.type === "session"); if (header?.id && header.id !== metadata.id) return undefined; let sessionName: string | undefined; diff --git a/src/realtime/realtime.ts b/src/realtime/realtime.ts index 10334ce..cd24739 100644 --- a/src/realtime/realtime.ts +++ b/src/realtime/realtime.ts @@ -15,6 +15,10 @@ import type { ConversationTreeController } from "../tree/conversationTree.js"; import { renderWebFooters } from "../extensions/webFooter.js"; import { assistantErrorBody, normalizeAssistantError } from "../messages/content.js"; +export function shouldRefreshSessionsForPiEvent(event: PiEvent | undefined) { + return event?.type === "message_end"; +} + export type RealtimeController = { connect: () => void; handlePiEvent: (event: PiEvent) => void; @@ -83,15 +87,6 @@ export function createRealtime(options: { return Boolean(event?.willRetry); } - function shouldRefreshSessionsForPiEvent(event: PiEvent | undefined) { - switch (event?.type) { - case "message_end": - return true; - default: - return false; - } - } - function noteRuntimeEvent(sessionKey: string, event: PiEvent | undefined) { if (!sessionKey) return; switch (event?.type) { diff --git a/src/sessions/sessionDrawer.ts b/src/sessions/sessionDrawer.ts index 0207cee..6c0bc63 100644 --- a/src/sessions/sessionDrawer.ts +++ b/src/sessions/sessionDrawer.ts @@ -6,6 +6,13 @@ import type { RightPanelHandle, RightPanelManager } from "../layout/rightPanel.j import type { AppState, SessionInfo, SessionMarkerColorId, SessionUiState } from "../app/types.js"; import { defaultSessionUiState, normalizeSessionUiState, persistCollapsedSessionFolders, sessionFolderPreviewLimit, sessionMarkerColors, writeActiveSessionIdToUrl } from "../app/types.js"; +export async function fetchSessionList(url: string, headers: HeadersInit, timeoutMs = 15_000) { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + try { return await fetch(url, { headers, signal: controller.signal }); } + finally { window.clearTimeout(timeout); } +} + export type SessionsController = { init: () => void; refreshSessions: () => Promise; @@ -413,11 +420,7 @@ export function createSessions(options: { const params = new URLSearchParams(); for (const cwd of readKnownSessionCwds()) params.append("cwd", cwd); const url = params.toString() ? `/api/sessions?${params}` : "/api/sessions"; - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 15_000); - let res: Response; - try { res = await fetch(url, { headers: api.headers(), signal: controller.signal }); } - finally { window.clearTimeout(timeout); } + const res = await fetchSessionList(url, api.headers()); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); cachedSessions = (data.sessions || []).map((item: SessionInfo) => ({ ...item, isCurrent: item.id === state.currentSessionId })); diff --git a/tests/session-list-lifecycle.test.ts b/tests/session-list-lifecycle.test.ts new file mode 100644 index 0000000..536edca --- /dev/null +++ b/tests/session-list-lifecycle.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchSessionList } from "../src/sessions/sessionDrawer.js"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("session list request lifecycle", () => { + it("aborts a hung request and allows the trailing retry to succeed", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { setTimeout, clearTimeout }); + const fetchMock = vi.fn() + .mockImplementationOnce((_url, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ sessions: [] }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const first = expect(fetchSessionList("/api/sessions", {}, 25)).rejects.toMatchObject({ name: "AbortError" }); + await vi.advanceTimersByTimeAsync(25); + await first; + await expect(fetchSessionList("/api/sessions", {}, 25)).resolves.toMatchObject({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/session-refresh-contract.test.ts b/tests/session-refresh-contract.test.ts new file mode 100644 index 0000000..1eb70cb --- /dev/null +++ b/tests/session-refresh-contract.test.ts @@ -0,0 +1,21 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { shouldRefreshSessionsForPiEvent } from "../src/realtime/realtime.js"; + +describe("session refresh fanout contract", () => { + it("refreshes only for persisted messages, not terminal or metadata events", () => { + expect(shouldRefreshSessionsForPiEvent({ type: "message_end" } as any)).toBe(true); + for (const type of ["agent_end", "compaction_end", "session_info_changed"]) { + expect(shouldRefreshSessionsForPiEvent({ type } as any)).toBe(false); + } + }); + + it("coalesces a turn and patches rename/delete without snapshots", async () => { + const source = await readFile(new URL("../src/realtime/realtime.ts", import.meta.url), "utf8"); + expect(source).toContain("if (sessionRefreshTimer !== undefined) return;"); + expect(source).toContain("if (sessionRefreshInFlight) {\n sessionRefreshQueued = true;"); + expect(source).toContain("sessions.removeSession(String(data.sessionId || \"\"))"); + expect(source).toContain("sessions.updateSessionName(String(data.sessionId || \"\"), String(data.event.name || \"\"))"); + expect(source).not.toMatch(/session_deleted[\s\S]{0,100}scheduleSessionRefresh/); + }); +}); diff --git a/tests/shallow-session-list.test.ts b/tests/shallow-session-list.test.ts index 40f1e90..15d0202 100644 --- a/tests/shallow-session-list.test.ts +++ b/tests/shallow-session-list.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { shallowListSessions } from "../server/session/shallowList.js"; +import { shallowListSessions, type ShallowListMetrics } from "../server/session/shallowList.js"; const roots: string[] = []; afterEach(async () => Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true })))); @@ -23,8 +23,37 @@ describe("shallow session listing", () => { const inflatedBody = `${JSON.stringify({ type: "message", message: { role: "assistant", content: "x".repeat(1024) } })}\n`.repeat(100); await writeFile(path, `${lines.join("\n")}\n${inflatedBody}${JSON.stringify({ type: "session_info", name: "Late rename" })}\n`); - const [info] = await shallowListSessions(cwd, directory); + const baseline: ShallowListMetrics = { files: 0, bytesRead: 0 }; + const [info] = await shallowListSessions(cwd, directory, baseline); expect(info).toMatchObject({ id, cwd, name: "Late rename", firstMessage: "First prompt", created: "2026-07-05T13:49:40.780Z" }); expect(info.messageCount).toBeUndefined(); + + await writeFile(path, `${lines.join("\n")}\n${inflatedBody.repeat(10)}${JSON.stringify({ type: "session_info", name: "Late rename" })}\n`); + const inflated: ShallowListMetrics = { files: 0, bytesRead: 0 }; + await shallowListSessions(cwd, directory, inflated); + expect(inflated).toEqual(baseline); + expect(inflated.bytesRead).toBeLessThanOrEqual(40 * 1024); + }); + + it("keeps bounded per-session work as visited cwd and corpus size grow", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-web-soak-")); + roots.push(root); + const metrics: ShallowListMetrics = { files: 0, bytesRead: 0 }; + let listed = 0; + for (let cwdIndex = 0; cwdIndex < 20; cwdIndex += 1) { + const cwd = join(root, `cwd-${cwdIndex}`); + const directory = join(cwd, "sessions"); + await mkdir(directory, { recursive: true }); + for (let sessionIndex = 0; sessionIndex < 10; sessionIndex += 1) { + const id = `${cwdIndex}-${sessionIndex}`; + const path = join(directory, `2026-07-05T13-49-40-780Z_${id}.jsonl`); + const header = JSON.stringify({ type: "session", id, timestamp: "2026-07-05T13:49:40.780Z", cwd }); + await writeFile(path, `${header}\n${"x".repeat(96 * 1024)}\n`); + } + listed += (await shallowListSessions(cwd, directory, metrics)).length; + } + expect(listed).toBe(200); + expect(metrics.files).toBe(200); + expect(metrics.bytesRead).toBeLessThanOrEqual(metrics.files * 40 * 1024); }); });