diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579a..e79da6078d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. +- Fixed saved-session catalog refreshes repeatedly rebuilding the full agents view and rescanning complete append-only session files ([#944](https://github.com/PrimeIntellect-ai/prime-agent/issues/944)). - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) ## [0.7.1] - 2026-08-07 diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index f9161168e5..5de42f9322 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -12,10 +12,11 @@ import { realpathSync, renameSync, rmSync, + type Stats, statSync, writeFileSync, } from "fs"; -import { readdir, readFile, stat } from "fs/promises"; +import { open, readdir, readFile, stat } from "fs/promises"; import { basename, dirname, join, resolve } from "path"; import { v7 as uuidv7 } from "uuid"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; @@ -34,6 +35,9 @@ export const CURRENT_SESSION_VERSION = 3; const SESSION_LIST_SEARCH_TEXT_MAX_CHARS = 64 * 1024; const SESSION_LIST_PARSE_MAX_LINE_CHARS = 1024 * 1024; const SESSION_LIST_LARGE_MESSAGE_PREVIEW_MAX_CHARS = 256; +const SESSION_LIST_PREFIX_PROOF_BYTES = 4096; +const SESSION_LIST_READ_BUFFER_BYTES = 64 * 1024; +export const SESSION_LIST_METADATA_CONCURRENCY = 16; const SESSION_STREAMING_LOAD_THRESHOLD_BYTES = 128 * 1024 * 1024; const SESSION_ASYNC_PARSE_YIELD_BYTES = 4 * 1024 * 1024; @@ -992,148 +996,439 @@ function extractOversizedMessageSummary(line: string): { }; } +type SessionFileStats = Stats; + +export type SessionInfoReadMode = "cache" | "append" | "full"; + +export interface SessionInfoReadDiagnostics { + onBytesRead?: (bytes: number) => void; + onMode?: (mode: SessionInfoReadMode) => void; +} + +interface SessionFileIdentity { + dev: number; + ino: number; + birthtimeMs: number; +} + +interface SessionInfoAccumulator { + header?: SessionHeader; + messageCount: number; + firstMessage: string; + allMessagesText: string; + name?: string; + state?: SessionState; + agentStatus?: AgentStatus; + lastActivityTime?: number; +} + +interface SessionInfoCacheSnapshot { + offset: number; + accumulator: SessionInfoAccumulator; + verificationPrefix: Buffer; + verificationSuffix: Buffer; +} + interface SessionInfoCacheEntry { + identity: SessionFileIdentity; size: number; mtimeMs: number; + ctimeMs: number; info: SessionInfo | null; + snapshot?: SessionInfoCacheSnapshot; +} + +interface SessionInfoScanResult { + accumulator: SessionInfoAccumulator; + verificationPrefix: Buffer; + verificationSuffix: Buffer; + safeToResume: boolean; + invalidHeader: boolean; } -// Session files are append-only, so an unchanged (size, mtimeMs) means identical -// content: cache list metadata and rescan only files that changed. const sessionInfoCache = new Map(); -export async function readSessionInfo(filePath: string): Promise { - let stats: Awaited>; +export async function readSessionInfo( + filePath: string, + diagnostics: SessionInfoReadDiagnostics = {}, +): Promise { try { - stats = await stat(filePath); + return await readVerifiedSessionInfo(filePath, diagnostics); } catch { + sessionInfoCache.delete(filePath); return null; } - const cached = sessionInfoCache.get(filePath); - if (cached && cached.size === stats.size && cached.mtimeMs === stats.mtimeMs) { - return cached.info; - } - const info = await scanSessionInfo(filePath, stats); - sessionInfoCache.set(filePath, { size: stats.size, mtimeMs: stats.mtimeMs, info }); - return info; } -async function scanSessionInfo(filePath: string, stats: Awaited>): Promise { - try { - let header: SessionHeader | undefined; - let messageCount = 0; - let firstMessage = ""; - let allMessagesText = ""; - let name: string | undefined; - let state: SessionState | undefined; - let agentStatus: AgentStatus | undefined; - let lastActivityTime: number | undefined; - - for await (const lineBuffer of readLinesAsBuffers(filePath)) { - const line = lineBuffer.toString("utf8"); - if (!line.trim()) continue; - - // Large tool-result entries can be many MB. They do not carry the - // session-list metadata we need, and parsing them during every refresh - // can exhaust the daemon heap. - if (line.length > SESSION_LIST_PARSE_MAX_LINE_CHARS) { - if (looksLikeMessageEntry(line)) { - messageCount++; - const summary = extractOversizedMessageSummary(line); - if (typeof summary.timestamp === "number" && (summary.role === "user" || summary.role === "assistant")) { - lastActivityTime = Math.max(lastActivityTime ?? 0, summary.timestamp); - } - if (summary.role === "user" && !firstMessage) { - firstMessage = summary.textPreview || "(large message)"; - } +async function readVerifiedSessionInfo( + filePath: string, + diagnostics: SessionInfoReadDiagnostics, +): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + let stats: SessionFileStats; + try { + stats = await stat(filePath); + } catch { + sessionInfoCache.delete(filePath); + return null; + } + const identity = getSessionFileIdentity(stats); + const cached = sessionInfoCache.get(filePath); + if ( + cached && + sameSessionFileIdentity(cached.identity, identity) && + cached.size === stats.size && + cached.mtimeMs === stats.mtimeMs && + cached.ctimeMs === stats.ctimeMs + ) { + diagnostics.onMode?.("cache"); + return cached.info; + } + + let mode: SessionInfoReadMode = "full"; + let scan: SessionInfoScanResult | undefined; + if ( + cached?.snapshot && + sameSessionFileIdentity(cached.identity, identity) && + stats.size > cached.size && + cached.snapshot.offset === cached.size + ) { + // Session files are append-only. Identity plus bounded opening/boundary + // proofs catch replacement and practical rewrites without turning every + // append into another O(file size) scan. Arbitrary same-inode middle + // rewrites remain outside the session store's append-only contract. + const prefix = await readSessionFileRange(filePath, 0, cached.snapshot.verificationPrefix.length, diagnostics); + const proofStart = Math.max(0, cached.size - cached.snapshot.verificationSuffix.length); + const suffix = await readSessionFileRange(filePath, proofStart, cached.size, diagnostics); + if (prefix.equals(cached.snapshot.verificationPrefix) && suffix.equals(cached.snapshot.verificationSuffix)) { + const appended = await scanSessionInfoRange( + filePath, + stats, + cached.size, + cloneSessionInfoAccumulator(cached.snapshot.accumulator), + cached.snapshot.verificationPrefix, + cached.snapshot.verificationSuffix, + diagnostics, + ); + if (appended.safeToResume && !appended.invalidHeader) { + scan = appended; + mode = "append"; } - continue; } + } + if (!scan) { + scan = await scanSessionInfoRange( + filePath, + stats, + 0, + createSessionInfoAccumulator(), + Buffer.alloc(0), + Buffer.alloc(0), + diagnostics, + ); + } - const trimmed = line.trim(); - let entry: FileEntry; - try { - entry = JSON.parse(trimmed) as FileEntry; - } catch { - // Skip malformed lines - continue; - } + let stableStats: SessionFileStats; + try { + stableStats = await stat(filePath); + } catch { + sessionInfoCache.delete(filePath); + return null; + } + let cacheStats = stableStats; + if (!sameSessionFileVersion(stats, stableStats)) { + if (!(await isVerifiedSessionFileGrowth(filePath, stats, stableStats, scan, diagnostics))) continue; + // The bytes through stats.size were stable and the same file only grew. + // Return that complete catalog snapshot now; its old offset catches up on + // the next refresh instead of hiding a continuously active session. + cacheStats = stats; + } + + const info = scan.invalidHeader ? null : buildSessionInfo(filePath, scan.accumulator, cacheStats); + sessionInfoCache.set(filePath, { + identity: getSessionFileIdentity(cacheStats), + size: cacheStats.size, + mtimeMs: cacheStats.mtimeMs, + ctimeMs: cacheStats.ctimeMs, + info, + ...(scan.safeToResume && info + ? { + snapshot: { + offset: cacheStats.size, + accumulator: cloneSessionInfoAccumulator(scan.accumulator), + verificationPrefix: Buffer.from(scan.verificationPrefix), + verificationSuffix: Buffer.from(scan.verificationSuffix), + }, + } + : {}), + }); + diagnostics.onMode?.(mode); + return info; + } + sessionInfoCache.delete(filePath); + return null; +} - // Extract session name (use latest, including explicit clears) - if (entry.type === "session_info") { - const infoEntry = entry as SessionInfoEntry; - name = infoEntry.name?.trim() || undefined; - } - if (entry.type === "session_state") { - const stateEntry = entry as SessionStateEntry; - const status = normalizeSessionStateStatus(stateEntry.state?.status); - if (status) { - state = { status }; - } - } - // Keep the latest recap/verdict so off-daemon sessions don't all show as - // unjudged in the agents view. Append-only, so last seen wins. - if (entry.type === "agent_status") { - agentStatus = (entry as AgentStatusEntry).status; - } +function createSessionInfoAccumulator(): SessionInfoAccumulator { + return { + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }; +} - if (!header) { - if (entry.type !== "session") { - return null; - } - header = entry as SessionHeader; - } +function cloneSessionInfoAccumulator(accumulator: SessionInfoAccumulator): SessionInfoAccumulator { + return { + ...accumulator, + ...(accumulator.header ? { header: { ...accumulator.header } } : {}), + ...(accumulator.state ? { state: { ...accumulator.state } } : {}), + ...(accumulator.agentStatus ? { agentStatus: { ...accumulator.agentStatus } } : {}), + }; +} + +function getSessionFileIdentity(stats: SessionFileStats): SessionFileIdentity { + return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs }; +} - lastActivityTime = updateLastActivityTime(lastActivityTime, entry); +function sameSessionFileIdentity(a: SessionFileIdentity, b: SessionFileIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs; +} - if (entry.type !== "message") continue; - messageCount++; +function sameSessionFileVersion(a: SessionFileStats, b: SessionFileStats): boolean { + return ( + sameSessionFileIdentity(getSessionFileIdentity(a), getSessionFileIdentity(b)) && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs + ); +} - const message = (entry as SessionMessageEntry).message; - if (!isMessageWithContent(message)) continue; - if (message.role !== "user" && message.role !== "assistant") continue; +async function isVerifiedSessionFileGrowth( + filePath: string, + before: SessionFileStats, + after: SessionFileStats, + scan: SessionInfoScanResult, + diagnostics: SessionInfoReadDiagnostics, +): Promise { + if ( + !scan.safeToResume || + !sameSessionFileIdentity(getSessionFileIdentity(before), getSessionFileIdentity(after)) || + after.size <= before.size + ) { + return false; + } + const prefix = await readSessionFileRange(filePath, 0, scan.verificationPrefix.length, diagnostics); + const suffixStart = Math.max(0, before.size - scan.verificationSuffix.length); + const suffix = await readSessionFileRange(filePath, suffixStart, before.size, diagnostics); + return prefix.equals(scan.verificationPrefix) && suffix.equals(scan.verificationSuffix); +} - const textContent = extractTextContent(message); - if (!textContent) continue; +async function readSessionFileRange( + filePath: string, + start: number, + end: number, + diagnostics: SessionInfoReadDiagnostics, +): Promise { + const handle = await open(filePath, "r"); + const chunks: Buffer[] = []; + let position = start; + try { + while (position < end) { + const buffer = Buffer.allocUnsafe(Math.min(SESSION_LIST_READ_BUFFER_BYTES, end - position)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); + if (bytesRead === 0) break; + diagnostics.onBytesRead?.(bytesRead); + chunks.push(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + } finally { + await handle.close(); + } + return Buffer.concat(chunks); +} - allMessagesText = appendCappedSearchText(allMessagesText, textContent); - if (!firstMessage && message.role === "user") { - firstMessage = textContent; +async function scanSessionInfoRange( + filePath: string, + stats: SessionFileStats, + start: number, + accumulator: SessionInfoAccumulator, + initialVerificationPrefix: Buffer, + initialVerificationSuffix: Buffer, + diagnostics: SessionInfoReadDiagnostics, +): Promise { + const handle = await open(filePath, "r"); + let position = start; + const pendingParts: Buffer[] = []; + let pendingBytes = 0; + let verificationPrefix: Buffer = Buffer.from(initialVerificationPrefix); + let verificationSuffix: Buffer = Buffer.from(initialVerificationSuffix); + let lastNonBlankMalformed = false; + let endedAtBoundary = start === stats.size; + let invalidHeader = false; + try { + while (position < stats.size && !invalidHeader) { + const buffer = Buffer.allocUnsafe(Math.min(SESSION_LIST_READ_BUFFER_BYTES, stats.size - position)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); + if (bytesRead === 0) break; + const chunk = buffer.subarray(0, bytesRead); + diagnostics.onBytesRead?.(bytesRead); + position += bytesRead; + verificationPrefix = appendVerificationPrefix(verificationPrefix, chunk); + verificationSuffix = appendVerificationSuffix(verificationSuffix, chunk); + endedAtBoundary = chunk[chunk.length - 1] === 0x0a; + let lineStart = 0; + for (let newline = chunk.indexOf(0x0a); newline >= 0; newline = chunk.indexOf(0x0a, lineStart)) { + const part = chunk.subarray(lineStart, newline); + let line = part; + if (pendingParts.length > 0) { + pendingParts.push(part); + line = Buffer.concat(pendingParts, pendingBytes + part.length); + } + pendingParts.length = 0; + pendingBytes = 0; + const outcome = applySessionInfoLine(accumulator, line); + if (outcome !== "blank") lastNonBlankMalformed = outcome === "malformed"; + if (outcome === "invalid-header") { + invalidHeader = true; + break; + } + lineStart = newline + 1; + } + if (!invalidHeader && lineStart < chunk.length) { + const part = chunk.subarray(lineStart); + pendingParts.push(part); + pendingBytes += part.length; } } + if (!invalidHeader && pendingBytes > 0) { + const outcome = applySessionInfoLine(accumulator, Buffer.concat(pendingParts, pendingBytes)); + if (outcome !== "blank") lastNonBlankMalformed = outcome === "malformed"; + invalidHeader = outcome === "invalid-header"; + endedAtBoundary = false; + } + } finally { + await handle.close(); + } + return { + accumulator, + verificationPrefix, + verificationSuffix, + safeToResume: + !invalidHeader && + position === stats.size && + endedAtBoundary && + !lastNonBlankMalformed && + accumulator.header !== undefined, + invalidHeader, + }; +} - if (!header) return null; - const cwd = typeof header.cwd === "string" ? header.cwd : ""; - const parentSessionPath = header.parentSession; - const rlmDepth = resolveSessionRlmDepth(header, filePath); - const modified = getSessionModifiedDateFromLastActivity(lastActivityTime, header, stats.mtime); +function appendVerificationPrefix(current: Buffer, chunk: Buffer): Buffer { + if (current.length >= SESSION_LIST_PREFIX_PROOF_BYTES) return current; + return Buffer.concat([current, chunk.subarray(0, SESSION_LIST_PREFIX_PROOF_BYTES - current.length)]); +} - return { - path: filePath, - id: header.id, - cwd, - name, - state, - parentSessionPath, - rlmDepth, - created: new Date(header.timestamp), - modified, - messageCount, - firstMessage: firstMessage || "(no messages)", - allMessagesText, - agentStatus, - }; +function appendVerificationSuffix(current: Buffer, chunk: Buffer): Buffer { + if (chunk.length >= SESSION_LIST_PREFIX_PROOF_BYTES) { + return Buffer.from(chunk.subarray(chunk.length - SESSION_LIST_PREFIX_PROOF_BYTES)); + } + const combined = Buffer.concat([current, chunk]); + return combined.length <= SESSION_LIST_PREFIX_PROOF_BYTES + ? combined + : combined.subarray(combined.length - SESSION_LIST_PREFIX_PROOF_BYTES); +} + +function applySessionInfoLine( + accumulator: SessionInfoAccumulator, + lineBuffer: Buffer, +): "blank" | "valid" | "malformed" | "invalid-header" { + const line = lineBuffer.toString("utf8"); + if (!line.trim()) return "blank"; + if (line.length > SESSION_LIST_PARSE_MAX_LINE_CHARS) { + if (looksLikeMessageEntry(line)) { + accumulator.messageCount++; + const summary = extractOversizedMessageSummary(line); + if (typeof summary.timestamp === "number" && (summary.role === "user" || summary.role === "assistant")) { + accumulator.lastActivityTime = Math.max(accumulator.lastActivityTime ?? 0, summary.timestamp); + } + if (summary.role === "user" && !accumulator.firstMessage) { + accumulator.firstMessage = summary.textPreview || "(large message)"; + } + } + return "valid"; + } + + let entry: FileEntry; + try { + entry = JSON.parse(line.trim()) as FileEntry; } catch { - return null; + return "malformed"; + } + if (entry.type === "session_info") { + accumulator.name = (entry as SessionInfoEntry).name?.trim() || undefined; + } + if (entry.type === "session_state") { + const status = normalizeSessionStateStatus((entry as SessionStateEntry).state?.status); + if (status) accumulator.state = { status }; + } + if (entry.type === "agent_status") { + accumulator.agentStatus = (entry as AgentStatusEntry).status; } + if (!accumulator.header) { + if (entry.type !== "session") return "invalid-header"; + accumulator.header = entry as SessionHeader; + } + accumulator.lastActivityTime = updateLastActivityTime(accumulator.lastActivityTime, entry); + if (entry.type !== "message") return "valid"; + accumulator.messageCount++; + + const message = (entry as SessionMessageEntry).message; + if (!isMessageWithContent(message) || (message.role !== "user" && message.role !== "assistant")) return "valid"; + const textContent = extractTextContent(message); + if (!textContent) return "valid"; + accumulator.allMessagesText = appendCappedSearchText(accumulator.allMessagesText, textContent); + if (!accumulator.firstMessage && message.role === "user") accumulator.firstMessage = textContent; + return "valid"; +} + +function buildSessionInfo( + filePath: string, + accumulator: SessionInfoAccumulator, + stats: SessionFileStats, +): SessionInfo | null { + const header = accumulator.header; + if (!header) return null; + return { + path: filePath, + id: header.id, + cwd: typeof header.cwd === "string" ? header.cwd : "", + name: accumulator.name, + state: accumulator.state, + parentSessionPath: header.parentSession, + rlmDepth: resolveSessionRlmDepth(header, filePath), + created: new Date(header.timestamp), + modified: getSessionModifiedDateFromLastActivity(accumulator.lastActivityTime, header, stats.mtime), + messageCount: accumulator.messageCount, + firstMessage: accumulator.firstMessage || "(no messages)", + allMessagesText: accumulator.allMessagesText, + agentStatus: accumulator.agentStatus, + }; } export type SessionListProgress = (loaded: number, total: number) => void; export type SessionListItem = (session: SessionInfo) => void; +export interface SessionListDiagnostics { + onReadStart?: (filePath: string) => void; + onReadEnd?: (filePath: string) => void; + onBytesRead?: (filePath: string, bytes: number) => void; + onReadMode?: (filePath: string, mode: SessionInfoReadMode) => void; +} + export interface SessionListCallbacks { onProgress?: SessionListProgress; onSession?: SessionListItem; + diagnostics?: SessionListDiagnostics; } async function listSessionsFromDir( @@ -1160,15 +1455,33 @@ async function listSessionsFromDir( } } + const results = new Array(files.length).fill(null); + let nextIndex = 0; let loaded = 0; - for (const file of files) { - const info = await readSessionInfo(file); - loaded++; - callbacks?.onProgress?.(progressOffset + loaded, total); - if (info) { - sessions.push(info); - callbacks?.onSession?.(info); + const readNext = async (): Promise => { + while (nextIndex < files.length) { + const index = nextIndex++; + const file = files[index]!; + callbacks?.diagnostics?.onReadStart?.(file); + let info: SessionInfo | null; + try { + info = await readSessionInfo(file, { + onBytesRead: (bytes) => callbacks?.diagnostics?.onBytesRead?.(file, bytes), + onMode: (mode) => callbacks?.diagnostics?.onReadMode?.(file, mode), + }); + } finally { + callbacks?.diagnostics?.onReadEnd?.(file); + } + results[index] = info; + loaded += 1; + callbacks?.onProgress?.(progressOffset + loaded, total); + if (info) callbacks?.onSession?.(info); } + }; + const workerCount = Math.min(files.length, SESSION_LIST_METADATA_CONCURRENCY); + await Promise.all(Array.from({ length: workerCount }, () => readNext())); + for (const info of results) { + if (info) sessions.push(info); } } catch { // Return empty list on error @@ -2297,6 +2610,7 @@ export class SessionManager { const sessions = ( await listSessionsFromDir(dir, { onProgress: callbacks?.onProgress, + diagnostics: callbacks?.diagnostics, onSession: callbacks?.onSession ? (session) => { if (matchesCwd(session)) { diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index a7e700400a..2a32054841 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -94,6 +94,11 @@ import { type UnifiedSessionIndex, type UnifiedSessionRecord, } from "./agents-view-state.js"; +import { + ProgressiveCatalogBatcher, + SAVED_CATALOG_BATCH_MAX_DELAY_MS, + SAVED_CATALOG_BATCH_SIZE, +} from "./progressive-catalog-batcher.js"; import { matchesSearchText } from "./session-view-search.js"; const POLL_INTERVAL_MS = 1000; @@ -654,6 +659,7 @@ export class AgentsViewMode implements Component, Focusable { private liveCatalogPollPromise: Promise | undefined; private liveCatalogRefreshPending = false; private savedCatalogRefreshPending = false; + private savedCatalogBatcher: ProgressiveCatalogBatcher | undefined; private expandedSubagentParents = new Set(); // Agent row identities whose full spawn program is currently shown. // The program key toggles each agent shown ↔ hidden. @@ -2217,6 +2223,7 @@ export class AgentsViewMode implements Component, Focusable { options: { duringReconnect?: boolean; preserveStatusOnError?: boolean } = {}, ): Promise { if ((!options.duringReconnect && this.reconnectPromise) || this.daemonShutdownReceived) return false; + this.savedCatalogBatcher?.cancel(); const generation = ++this.savedCatalogGeneration; this.persistentState.savedCatalogGeneration = generation; this.savedCatalogRefreshPending = true; @@ -2225,13 +2232,25 @@ export class AgentsViewMode implements Component, Focusable { const progressiveSessions = new Map( successfulSessions.map((session) => [resolvePath(canonicalizePath(session.path)), session]), ); + let terminalSessions: AgentConnectionSavedSessionInfo[] | undefined; + const publish = () => { + if (generation !== this.savedCatalogGeneration || this.stopped) return; + const sessions = terminalSessions ?? [...progressiveSessions.values()]; + this.savedSessions = sessions; + this.persistentState.savedSessions = sessions; + this.reconcileCatalogs(); + }; + const batcher = new ProgressiveCatalogBatcher({ + maxBatchSize: SAVED_CATALOG_BATCH_SIZE, + maxDelayMs: SAVED_CATALOG_BATCH_MAX_DELAY_MS, + onFlush: publish, + }); + this.savedCatalogBatcher = batcher; try { const onSession = (session: AgentConnectionSavedSessionInfo) => { if (generation !== this.savedCatalogGeneration) return; progressiveSessions.set(resolvePath(canonicalizePath(session.path)), session); - this.savedSessions = [...progressiveSessions.values()]; - this.persistentState.savedSessions = this.savedSessions; - this.reconcileCatalogs(); + batcher.add(); }; const sessions = await listDaemonSavedSessions( this.requireClient(), @@ -2241,27 +2260,31 @@ export class AgentsViewMode implements Component, Focusable { onSession, }, ); - if (generation !== this.savedCatalogGeneration) return false; - this.savedSessions = sessions; + if (generation !== this.savedCatalogGeneration) { + batcher.cancel(); + return false; + } + terminalSessions = sessions; this.lastSuccessfulSavedSessions = sessions; this.savedCatalogReady = true; this.persistentState.lastSuccessfulSavedSessions = sessions; - this.persistentState.savedSessions = sessions; - this.reconcileCatalogs(); + batcher.finish(); return true; } catch (error) { if (generation === this.savedCatalogGeneration) { - this.savedSessions = successfulSessions; - this.persistentState.savedSessions = successfulSessions; + terminalSessions = successfulSessions; // Treat a terminal failure as settled so scope fallback cannot soft-lock. this.savedCatalogReady = true; - this.reconcileCatalogs(); + batcher.finish(); if (!options.preserveStatusOnError && !this.reconnectPromise && !this.daemonShutdownReceived) { this.setStatusMessage(formatError("Failed to load saved sessions", error)); } + } else { + batcher.cancel(); } return false; } finally { + if (this.savedCatalogBatcher === batcher) this.savedCatalogBatcher = undefined; if (generation === this.savedCatalogGeneration) { this.savedCatalogRefreshPending = false; this.resolveMissingSelectionAnchor(); @@ -2371,6 +2394,8 @@ export class AgentsViewMode implements Component, Focusable { } this.stopped = true; this.savedCatalogGeneration += 1; + this.savedCatalogBatcher?.cancel(); + this.savedCatalogBatcher = undefined; this.liveCatalogGeneration += 1; this.heartbeatCatalogGeneration += 1; if (this.pollTimer) { diff --git a/packages/coding-agent/src/modes/agents-view/progressive-catalog-batcher.ts b/packages/coding-agent/src/modes/agents-view/progressive-catalog-batcher.ts new file mode 100644 index 0000000000..8ade971ced --- /dev/null +++ b/packages/coding-agent/src/modes/agents-view/progressive-catalog-batcher.ts @@ -0,0 +1,67 @@ +export const SAVED_CATALOG_BATCH_SIZE = 32; +export const SAVED_CATALOG_BATCH_MAX_DELAY_MS = 50; + +interface ProgressiveCatalogBatcherOptions { + maxBatchSize: number; + maxDelayMs: number; + onFlush: () => void; +} + +export class ProgressiveCatalogBatcher { + private pendingItems = 0; + private timer?: ReturnType; + private closed = false; + + constructor(private readonly options: ProgressiveCatalogBatcherOptions) { + if (!Number.isSafeInteger(options.maxBatchSize) || options.maxBatchSize < 1) { + throw new Error("Progressive catalog batch size must be a positive integer"); + } + if (!Number.isFinite(options.maxDelayMs) || options.maxDelayMs < 0) { + throw new Error("Progressive catalog batch delay must be non-negative"); + } + } + + add(): void { + if (this.closed) return; + this.pendingItems += 1; + if (this.pendingItems >= this.options.maxBatchSize) { + this.flushPending(); + return; + } + if (!this.timer) { + this.timer = setTimeout(() => { + this.timer = undefined; + this.flushPending(); + }, this.options.maxDelayMs); + this.timer.unref?.(); + } + } + + finish(): void { + if (this.closed) return; + this.closed = true; + this.clearTimer(); + this.pendingItems = 0; + this.options.onFlush(); + } + + cancel(): void { + if (this.closed) return; + this.closed = true; + this.clearTimer(); + this.pendingItems = 0; + } + + private flushPending(): void { + if (this.closed || this.pendingItems === 0) return; + this.clearTimer(); + this.pendingItems = 0; + this.options.onFlush(); + } + + private clearTimer(): void { + if (!this.timer) return; + clearTimeout(this.timer); + this.timer = undefined; + } +} diff --git a/packages/coding-agent/test/suite/regressions/944-batch-session-catalog.test.ts b/packages/coding-agent/test/suite/regressions/944-batch-session-catalog.test.ts new file mode 100644 index 0000000000..47607c4e36 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/944-batch-session-catalog.test.ts @@ -0,0 +1,540 @@ +import { appendFileSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + readSessionInfo, + SESSION_LIST_METADATA_CONCURRENCY, + type SessionInfoReadMode, + SessionManager, +} from "../../../src/core/session-manager.js"; +import type { AgentConnectionSavedSessionInfo } from "../../../src/modes/agent-connection/types.js"; +import { AgentsViewMode } from "../../../src/modes/agents-view/agents-view-mode.js"; +import { + buildAgentsViewRows, + getAgentsViewSelectionKey, + reconcileUnifiedSessions, + resolveAgentsViewSelectionState, +} from "../../../src/modes/agents-view/agents-view-state.js"; +import { + ProgressiveCatalogBatcher, + SAVED_CATALOG_BATCH_MAX_DELAY_MS, + SAVED_CATALOG_BATCH_SIZE, +} from "../../../src/modes/agents-view/progressive-catalog-batcher.js"; + +const temporaryDirectories: string[] = []; + +function createTemporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "prime-agent-944-")); + temporaryDirectories.push(directory); + return directory; +} + +function sessionHeader(id: string, cwd: string): string { + return JSON.stringify({ + type: "session", + version: 3, + id, + timestamp: "2026-01-01T00:00:00.000Z", + cwd, + }); +} + +function userMessage(id: string, text: string): string { + return JSON.stringify({ + type: "message", + id, + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + message: { role: "user", content: text, timestamp: 1 }, + }); +} + +function writeSession(path: string, id: string, cwd: string, messages: string[] = []): void { + writeFileSync(path, `${[sessionHeader(id, cwd), ...messages].join("\n")}\n`); +} + +function savedSession(index: number, directory: string): AgentConnectionSavedSessionInfo { + return { + path: join(directory, `${index}.jsonl`), + id: `session-${index}`, + cwd: directory, + created: new Date(1_000 + index), + modified: new Date(1_000 + index), + messageCount: 1, + firstMessage: `message ${index}`, + allMessagesText: `message ${index}`, + }; +} + +function invokeAgentsView(method: string, self: object, ...args: unknown[]): unknown { + const member = Reflect.get(AgentsViewMode.prototype, method) as ((...values: unknown[]) => unknown) | undefined; + if (typeof member !== "function") throw new Error(`AgentsViewMode.${method} no longer exists`); + return member.call(self, ...args); +} + +function serializeSavedSession(session: AgentConnectionSavedSessionInfo): Record { + return { + ...session, + created: session.created.toISOString(), + modified: session.modified.toISOString(), + }; +} + +afterEach(() => { + vi.useRealTimers(); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("#944 saved-session metadata cache", () => { + it("reads only a bounded suffix proof and appended bytes after a safe cached boundary", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "append.jsonl"); + writeSession(path, "append", directory, [ + userMessage("filler", "x".repeat(16 * 1024)), + userMessage("one", "first"), + ]); + + const initialBytes: number[] = []; + const initialModes: SessionInfoReadMode[] = []; + expect( + await readSessionInfo(path, { + onBytesRead: (bytes) => initialBytes.push(bytes), + onMode: (mode) => initialModes.push(mode), + }), + ).toMatchObject({ messageCount: 2 }); + + const appended = `${userMessage("two", "second")}\n`; + appendFileSync(path, appended); + const appendBytes: number[] = []; + const appendModes: SessionInfoReadMode[] = []; + expect( + await readSessionInfo(path, { + onBytesRead: (bytes) => appendBytes.push(bytes), + onMode: (mode) => appendModes.push(mode), + }), + ).toMatchObject({ messageCount: 3, allMessagesText: expect.stringContaining("second") }); + + expect(initialModes).toEqual(["full"]); + expect(appendModes).toEqual(["append"]); + expect(appendBytes.reduce((sum, bytes) => sum + bytes, 0)).toBeLessThanOrEqual( + Buffer.byteLength(appended) + 8192, + ); + expect(appendBytes.reduce((sum, bytes) => sum + bytes, 0)).toBeLessThan( + initialBytes.reduce((sum, bytes) => sum + bytes, 0) + Buffer.byteLength(appended), + ); + }); + + it("detects an opening-prefix rewrite that preserves the old append boundary", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "prefix-proof.jsonl"); + writeSession(path, "prefix-aa", directory, [userMessage("one", "x".repeat(16 * 1024))]); + await readSessionInfo(path); + + const rewritten = readFileSync(path); + const idOffset = rewritten.indexOf("prefix-aa"); + expect(idOffset).toBeGreaterThanOrEqual(0); + rewritten.write("prefix-bb", idOffset, "utf8"); + writeFileSync(path, Buffer.concat([rewritten, Buffer.from(`${userMessage("two", "appended")}\n`)])); + const modes: SessionInfoReadMode[] = []; + + expect(await readSessionInfo(path, { onMode: (mode) => modes.push(mode) })).toMatchObject({ + id: "prefix-bb", + messageCount: 2, + }); + expect(modes).toEqual(["full"]); + }); + + it("keeps a verified snapshot visible when the file grows during verification", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "concurrent-append.jsonl"); + writeSession(path, "concurrent-append", directory, [userMessage("one", "first")]); + await readSessionInfo(path); + appendFileSync(path, `${userMessage("two", "second")}\n`); + + let grewDuringRead = false; + const modes: SessionInfoReadMode[] = []; + const info = await readSessionInfo(path, { + onBytesRead: () => { + if (grewDuringRead) return; + grewDuringRead = true; + appendFileSync(path, `${userMessage("three", "third")}\n`); + }, + onMode: (mode) => modes.push(mode), + }); + + expect(info).toMatchObject({ messageCount: 2, allMessagesText: "first second" }); + expect(modes).toEqual(["append"]); + expect(await readSessionInfo(path)).toMatchObject({ + messageCount: 3, + allMessagesText: "first second third", + }); + }); + + it("retries growth from an unterminated boundary instead of caching stale metadata", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "unsafe-growth.jsonl"); + writeFileSync(path, `${sessionHeader("unsafe-growth", directory)}\n${userMessage("one", "first")}`); + let appended = false; + const modes: SessionInfoReadMode[] = []; + + const info = await readSessionInfo(path, { + onBytesRead: () => { + if (appended) return; + appended = true; + appendFileSync(path, `${userMessage("two", "second")}\n`); + }, + onMode: (mode) => modes.push(mode), + }); + + expect(info).toMatchObject({ messageCount: 0, firstMessage: "(no messages)" }); + expect(modes).toEqual(["full"]); + }); + + it("falls back to a full scan for truncation, rewrite, replacement, and prefix mismatch", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "rewritten.jsonl"); + writeSession(path, "original", directory, [userMessage("one", "original message")]); + await readSessionInfo(path); + + writeSession(path, "short", directory); + const modes: SessionInfoReadMode[] = []; + expect(await readSessionInfo(path, { onMode: (mode) => modes.push(mode) })).toMatchObject({ id: "short" }); + + writeSession(path, "same-size", directory, [userMessage("one", "rewritten message")]); + expect(await readSessionInfo(path, { onMode: (mode) => modes.push(mode) })).toMatchObject({ + id: "same-size", + firstMessage: "rewritten message", + }); + + const replacement = join(directory, "replacement.jsonl"); + writeSession(replacement, "replacement", directory, [userMessage("one", "replacement message")]); + renameSync(replacement, path); + expect(await readSessionInfo(path, { onMode: (mode) => modes.push(mode) })).toMatchObject({ id: "replacement" }); + + writeSession(path, "prefix-mismatch", directory, [userMessage("one", `changed prefix ${"x".repeat(512)}`)]); + expect(await readSessionInfo(path, { onMode: (mode) => modes.push(mode) })).toMatchObject({ + id: "prefix-mismatch", + }); + expect(modes).toEqual(["full", "full", "full", "full"]); + }); + + it("does not resume from a malformed or unterminated tail", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "tail.jsonl"); + writeSession(path, "tail", directory, [userMessage("one", "first")]); + await readSessionInfo(path); + + appendFileSync(path, "not-json\n"); + const malformedModes: SessionInfoReadMode[] = []; + expect(await readSessionInfo(path, { onMode: (mode) => malformedModes.push(mode) })).toMatchObject({ + messageCount: 1, + }); + + appendFileSync(path, '{"type":"message"'); + const unterminatedModes: SessionInfoReadMode[] = []; + expect(await readSessionInfo(path, { onMode: (mode) => unterminatedModes.push(mode) })).toMatchObject({ + messageCount: 1, + }); + + appendFileSync( + path, + ',"id":"two","parentId":null,"timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"user","content":"recovered","timestamp":2}}\n', + ); + const recoveredModes: SessionInfoReadMode[] = []; + expect(await readSessionInfo(path, { onMode: (mode) => recoveredModes.push(mode) })).toMatchObject({ + messageCount: 2, + allMessagesText: "first recovered", + }); + expect(malformedModes).toEqual(["full"]); + expect(unterminatedModes).toEqual(["full"]); + expect(recoveredModes).toEqual(["full"]); + }); + + it("drops deleted files from direct reads and catalog results", async () => { + const directory = createTemporaryDirectory(); + const path = join(directory, "deleted.jsonl"); + writeSession(path, "deleted", directory); + expect(await readSessionInfo(path)).toMatchObject({ id: "deleted" }); + rmSync(path); + expect(await readSessionInfo(path)).toBeNull(); + expect(await SessionManager.listAll(undefined, directory)).toEqual([]); + }); +}); + +describe("#944 bounded saved-session ingestion", () => { + it.each([10, 100, 1000])("keeps %i-file progress linear while bounding metadata reads", async (count) => { + const directory = createTemporaryDirectory(); + for (let index = 0; index < count; index++) { + writeSession(join(directory, `${index}.jsonl`), `session-${index}`, directory); + } + let activeReads = 0; + let maximumActiveReads = 0; + const progress: number[] = []; + const discovered = new Set(); + + const sessions = await SessionManager.listAll( + { + onProgress: (loaded, total) => { + expect(total).toBe(count); + progress.push(loaded); + }, + onSession: (session) => discovered.add(session.id), + diagnostics: { + onReadStart: () => { + activeReads += 1; + maximumActiveReads = Math.max(maximumActiveReads, activeReads); + }, + onReadEnd: () => { + activeReads -= 1; + }, + }, + }, + directory, + ); + + expect(sessions).toHaveLength(count); + expect(discovered.size).toBe(count); + expect(progress).toEqual(Array.from({ length: count }, (_, index) => index + 1)); + expect(activeReads).toBe(0); + expect(maximumActiveReads).toBeGreaterThan(1); + expect(maximumActiveReads).toBeLessThanOrEqual(SESSION_LIST_METADATA_CONCURRENCY); + }); +}); + +describe("#944 progressive catalog reconciliation", () => { + it("integrates count batching with one authoritative final reconciliation", async () => { + vi.useFakeTimers(); + const directory = createTemporaryDirectory(); + const sessions = Array.from({ length: 100 }, (_, index) => savedSession(index, directory)); + const reconcileCatalogs = vi.fn(); + const request = vi.fn(async (_command: unknown, _timeout: number, options: Record) => { + const onProgress = options.onProgress as ((update: Record) => void) | undefined; + for (const session of sessions) { + onProgress?.({ + type: "session_list_item", + command: "list_saved_sessions", + session: serializeSavedSession(session), + }); + } + return { + success: true, + data: { sessions: sessions.map(serializeSavedSession) }, + }; + }); + const self: Record = { + reconnectPromise: undefined, + daemonShutdownReceived: false, + savedCatalogBatcher: undefined, + savedCatalogGeneration: 0, + persistentState: {}, + savedCatalogRefreshPending: false, + savedCatalogReady: true, + lastSuccessfulSavedSessions: [], + stopped: false, + requireClient: () => ({ request }), + getSavedSessionCatalogContext: () => ({ cwd: directory }), + reconcileCatalogs, + resolveMissingSelectionAnchor: vi.fn(), + }; + + await expect(invokeAgentsView("refreshSavedSessions", self)).resolves.toBe(true); + + expect(reconcileCatalogs).toHaveBeenCalledTimes(Math.floor(100 / SAVED_CATALOG_BATCH_SIZE) + 1); + expect(self.savedSessions).toEqual(sessions); + expect((self.persistentState as Record).savedSessions).toEqual(sessions); + vi.runAllTimers(); + expect(reconcileCatalogs).toHaveBeenCalledTimes(Math.floor(100 / SAVED_CATALOG_BATCH_SIZE) + 1); + }); + + it("cancels a superseded refresh without publishing its delayed batch", async () => { + vi.useFakeTimers(); + const directory = createTemporaryDirectory(); + const stale = savedSession(1, directory); + const fresh = savedSession(2, directory); + let resolveStale: ((response: Record) => void) | undefined; + let requestCount = 0; + const request = vi.fn((_command: unknown, _timeout: number, options: Record) => { + requestCount += 1; + const onProgress = options.onProgress as ((update: Record) => void) | undefined; + if (requestCount === 1) { + onProgress?.({ + type: "session_list_item", + command: "list_saved_sessions", + session: serializeSavedSession(stale), + }); + return new Promise>((resolve) => { + resolveStale = resolve; + }); + } + return Promise.resolve({ success: true, data: { sessions: [serializeSavedSession(fresh)] } }); + }); + const reconcileCatalogs = vi.fn(); + const self: Record = { + reconnectPromise: undefined, + daemonShutdownReceived: false, + savedCatalogBatcher: undefined, + savedCatalogGeneration: 0, + persistentState: {}, + savedCatalogRefreshPending: false, + savedCatalogReady: true, + lastSuccessfulSavedSessions: [], + stopped: false, + requireClient: () => ({ request }), + getSavedSessionCatalogContext: () => ({ cwd: directory }), + reconcileCatalogs, + resolveMissingSelectionAnchor: vi.fn(), + }; + + const first = invokeAgentsView("refreshSavedSessions", self) as Promise; + const second = invokeAgentsView("refreshSavedSessions", self) as Promise; + await expect(second).resolves.toBe(true); + expect(reconcileCatalogs).toHaveBeenCalledOnce(); + expect(self.savedSessions).toEqual([fresh]); + + vi.runAllTimers(); + resolveStale?.({ success: true, data: { sessions: [serializeSavedSession(stale)] } }); + await expect(first).resolves.toBe(false); + expect(reconcileCatalogs).toHaveBeenCalledOnce(); + expect(self.savedSessions).toEqual([fresh]); + }); + + it("cancels a delayed saved-catalog batch when the agents view finishes", () => { + vi.useFakeTimers(); + const reconcile = vi.fn(); + const batcher = new ProgressiveCatalogBatcher({ + maxBatchSize: SAVED_CATALOG_BATCH_SIZE, + maxDelayMs: SAVED_CATALOG_BATCH_MAX_DELAY_MS, + onFlush: reconcile, + }); + batcher.add(); + const self: Record = { + stopped: false, + savedCatalogGeneration: 0, + savedCatalogBatcher: batcher, + liveCatalogGeneration: 0, + heartbeatCatalogGeneration: 0, + pollTimer: undefined, + heartbeatPollTimer: undefined, + animationTimer: undefined, + clearCtrlCExitHint: vi.fn(), + clearDeleteConfirmation: vi.fn(), + setStatusMessage: vi.fn(), + ui: { stop: vi.fn() }, + stopThemeWatcher: vi.fn(), + unsubscribeClientClose: undefined, + unsubscribeClientMessage: undefined, + client: undefined, + resolveRun: vi.fn(), + }; + + invokeAgentsView("finish", self, { type: "exit" }); + vi.runAllTimers(); + + expect(reconcile).not.toHaveBeenCalled(); + expect(self.savedCatalogBatcher).toBeUndefined(); + }); + + it.each([10, 100, 1000])("bounds %i progressive items by count and emits one final reconciliation", (count) => { + vi.useFakeTimers(); + const reconcile = vi.fn(); + const batcher = new ProgressiveCatalogBatcher({ + maxBatchSize: SAVED_CATALOG_BATCH_SIZE, + maxDelayMs: SAVED_CATALOG_BATCH_MAX_DELAY_MS, + onFlush: reconcile, + }); + for (let index = 0; index < count; index++) batcher.add(); + const intermediateCount = Math.floor(count / SAVED_CATALOG_BATCH_SIZE); + expect(reconcile).toHaveBeenCalledTimes(intermediateCount); + + batcher.finish(); + expect(reconcile).toHaveBeenCalledTimes(intermediateCount + 1); + vi.runAllTimers(); + expect(reconcile).toHaveBeenCalledTimes(intermediateCount + 1); + }); + + it("flushes a partial batch after the time bound and cancels without a stale update", () => { + vi.useFakeTimers(); + const reconcile = vi.fn(); + const batcher = new ProgressiveCatalogBatcher({ + maxBatchSize: SAVED_CATALOG_BATCH_SIZE, + maxDelayMs: SAVED_CATALOG_BATCH_MAX_DELAY_MS, + onFlush: reconcile, + }); + batcher.add(); + vi.advanceTimersByTime(SAVED_CATALOG_BATCH_MAX_DELAY_MS - 1); + expect(reconcile).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(reconcile).toHaveBeenCalledOnce(); + batcher.add(); + batcher.cancel(); + vi.runAllTimers(); + expect(reconcile).toHaveBeenCalledOnce(); + }); + + it("preserves selection across progressive batches", () => { + vi.useFakeTimers(); + const directory = createTemporaryDirectory(); + const target = savedSession(0, directory); + const sessions = [target]; + let rows = buildAgentsViewRows(reconcileUnifiedSessions([], sessions)); + let selectedIndex = 0; + const selectedIdentity = rows[0]?.identity; + const selectedKey = getAgentsViewSelectionKey(rows[0]!.summary); + const batcher = new ProgressiveCatalogBatcher({ + maxBatchSize: SAVED_CATALOG_BATCH_SIZE, + maxDelayMs: SAVED_CATALOG_BATCH_MAX_DELAY_MS, + onFlush: () => { + rows = buildAgentsViewRows(reconcileUnifiedSessions([], sessions)); + selectedIndex = resolveAgentsViewSelectionState(rows, selectedIndex, selectedIdentity, selectedKey).index; + }, + }); + + for (let index = 1; index <= 100; index++) { + sessions.push(savedSession(index, directory)); + batcher.add(); + } + batcher.finish(); + + expect(rows[selectedIndex]?.summary.sessionId).toBe(target.id); + }); + + it("preserves the selected row through mode-level catalog reconciliation", () => { + const directory = createTemporaryDirectory(); + const target = savedSession(0, directory); + const initialRows = buildAgentsViewRows(reconcileUnifiedSessions([], [target])); + const persistentState: Record = {}; + const self: Record = { + lastListedSummaries: [], + savedSessions: [target], + heartbeats: [], + inactiveAgentIdentities: new Set(), + pendingDeleteAgent: undefined, + liveCatalogReady: true, + savedCatalogReady: true, + persistentState, + scopeKey: undefined, + expandedSubagentParents: new Set(), + programShownParents: new Set(), + selectedIndex: 0, + selectedRowIdentity: initialRows[0]?.identity, + selectedSessionKey: getAgentsViewSelectionKey(initialRows[0]!.summary), + selectionAnchorPending: false, + withPendingDeleteSession: (sessions: unknown[]) => sessions, + getFilteredRecords: () => self.scopedRecords, + applyPendingAncestorExpansion: vi.fn(), + ui: { requestRender: vi.fn() }, + setStatusMessage: vi.fn(), + }; + self.restoreSelection = () => invokeAgentsView("restoreSelection", self); + self.syncSelectedRowState = () => invokeAgentsView("syncSelectedRowState", self); + + self.savedSessions = [target, ...Array.from({ length: 100 }, (_, index) => savedSession(index + 1, directory))]; + invokeAgentsView("reconcileCatalogs", self); + + const rows = self.rows as Array<{ summary: { sessionId: string } }>; + expect(rows[self.selectedIndex as number]?.summary.sessionId).toBe(target.id); + }); +});