From 0ce42f2c312d190f78024d8b4b1ae5f00d9e5939 Mon Sep 17 00:00:00 2001 From: Slapoguzov Alexander Date: Tue, 27 Oct 2026 00:44:18 +0100 Subject: [PATCH] feat: add batch session metadata lookup (#1408) A session browser may load only the 50 most recent conversations, while pinned sessions and saved folders refer to much older ones. After a restart, the client still has those IDs but needs their current titles, locations and archive state to rebuild the saved views. Finding a handful of retained sessions by paging through the entire history becomes increasingly expensive as that history grows. Add `_session/batch_get` to retrieve metadata for known session IDs in one request; the client chooses how many IDs to send per request. Successful results use the same row format as `session/list`, including archived sessions and sessions in worktrees, so the client can merge them with its recent page and apply its usual display filters. The response contains the sessions whose metadata is available, in request order. Other IDs are simply omitted. The client can refresh the rows it receives while keeping its saved references, so pinned and folder views stay independent of the recent-list window. --------- Co-authored-by: Aleksandr Slapoguzov --- docs/air-extensions.md | 76 ++++- src/acp-agent.ts | 11 +- src/air-extension.ts | 3 + src/session-index/connection.ts | 22 +- src/session-index/project-dirs.ts | 11 + src/session-index/service.ts | 51 +++- src/session-index/session-index.ts | 154 ++++++++-- src/tests/session-index.test.ts | 432 ++++++++++++++++++++++++++++- 8 files changed, 713 insertions(+), 47 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 9574dc09..d32bb45c 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -141,8 +141,8 @@ The `initialize` response of an AIR client carries the agent side of the extensi } ``` -The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`. -An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`. +The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`. +An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`. The response to a client that is not AIR has no `_meta.jetbrains` key. ### Capabilities @@ -158,6 +158,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key. | `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) | | `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) | | `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) | +| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) | | `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) | | `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) | | `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) | @@ -1282,8 +1283,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`. Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2. -Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)). -The client does not declare these four; declaring them without `sessionIndex` enables nothing. +Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)). +The client does not declare these five; declaring them without `sessionIndex` enables nothing. Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise. A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`. It gets no new notification, and the adapter starts no watcher for it. @@ -1442,6 +1443,71 @@ In all of the session index methods and in `session/delete`, a session id matche Loading an archived session does not unarchive it. The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not. +### Batch get + +`_session/batch_get` refreshes metadata for retained session IDs, such as pinned +sessions and folder members outside the recent list page. Check for +`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before +calling it. + +The params object must contain only `sessionIds`: an array of distinct, +non-blank strings. IDs match exactly, without trimming or case normalization. +Malformed params, duplicates or extra fields return `Invalid params` (`-32602`). +The agent sets no limit on the number of IDs; the client chooses the batch size +for the latency it accepts. Archived sessions and sessions in other projects +or worktrees are eligible. + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "method": "_session/batch_get", + "params": { "sessionIds": ["oldA", "oldB", "oldC"] } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "sessions": [ + { + "sessionId": "oldA", + "cwd": "/project", + "title": "Fix the build", + "updatedAt": "2026-01-01T00:00:00.000Z", + "_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } } + } + ] + } +} +``` + +The example IDs are placeholders for stored Claude session UUIDs. `sessions` +contains the same full rows as `session/list` without a project filter, including +archived sessions and `_meta.jetbrains.air`. Copies use the same +newest-readable-copy selection as the list. Returned sessions preserve their +relative request order. Non-UUID IDs and IDs without a listable transcript are +omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions +whose continuation has history, and transcripts whose cwd cannot be recovered. +A sidecar alone does not supply a row. + +`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store. +A store-wide failure, such as an unreadable projects root, rejects the request. +Individual results reflect the store as each ID is read. + +Lookup normally addresses only the requested transcript filenames in each project +and reads their metadata. A transcript without a cwd can use a sibling's cwd, +as the list does. This fallback enumerates and stats sibling transcripts only +in the directories of rows still missing a cwd, then reads at most 64 siblings +per directory, newest mtime first. A failed recovery is cached for 60 seconds +unless the directory mtime changes. Other project histories are not enumerated. +The agent looks up 100 IDs at a time; the client may request any number of IDs. +Merge returned rows with the recent page by `sessionId`. A batch read does not +expand an existing list subscription's scope; refresh retained IDs outside that +scope explicitly. + ### List subscription `_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`. @@ -1465,6 +1531,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi - `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored. - The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list. +- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly. - `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included. - After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change. - `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id. @@ -1517,6 +1584,7 @@ Names and semantics follow them; the transport differs, and so do the `archived` | capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` | | capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) | | capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) | +| capability `sessionBatchGet`, `_session/batch_get` | none | | `_meta.jetbrains.air.list.limit` | `session/list` `limit` | | `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` | | `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there | diff --git a/src/acp-agent.ts b/src/acp-agent.ts index d02b477b..5856fd1c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -147,7 +147,11 @@ import { withAirMeta, } from "./air-extension.js"; import * as sessionIndex from "./session-index/connection.js"; -import type { RenameSessionRequest, SessionIdRequest } from "./session-index/service.js"; +import type { + BatchGetSessionsRequest, + RenameSessionRequest, + SessionIdRequest, +} from "./session-index/service.js"; import type { ListSubscribeRequest, ListUnsubscribeRequest, @@ -3249,7 +3253,7 @@ export class ClaudeAcpAgent { return this.sessionIndex.clientTitle(title); } - /** `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`, + /** `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`, * `_session/archive`, `_session/unarchive`. */ async subscribeSessionList(params: ListSubscribeRequest) { return this.sessionIndex.subscribeList(params); @@ -3257,6 +3261,9 @@ export class ClaudeAcpAgent { async unsubscribeSessionList(params: ListUnsubscribeRequest) { return this.sessionIndex.unsubscribeList(params); } + async batchGetSessions(params: BatchGetSessionsRequest) { + return this.sessionIndex.batchGet(params); + } renameSessionTitle(params: RenameSessionRequest) { return this.sessionIndex.rename(params); } diff --git a/src/air-extension.ts b/src/air-extension.ts index 553ddd55..c4425ac2 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -22,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile"; * subscription. Advertised only to a client that declares it. */ export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex"; +/** Metadata lookup of known ids with `_session/batch_get`. Advertised + * exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */ +export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet"; /** * The agent supports `_session/archive` and `_session/unarchive`. Advertised * exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not diff --git a/src/session-index/connection.ts b/src/session-index/connection.ts index 61d96719..69b762aa 100644 --- a/src/session-index/connection.ts +++ b/src/session-index/connection.ts @@ -20,6 +20,7 @@ import type { Query } from "@anthropic-ai/claude-agent-sdk"; import type { AcpClient, Logger, Session } from "../acp-agent.js"; import { AIR_SESSION_ARCHIVE_CAPABILITY, + AIR_SESSION_BATCH_GET_CAPABILITY, AIR_SESSION_INDEX_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, @@ -46,6 +47,8 @@ import { renameTo, parseRenameSessionRequest, parseSessionIdRequest, + parseBatchGetSessionsRequest, + SESSION_BATCH_GET_METHOD, SESSION_ARCHIVE_METHOD, SESSION_RENAME_METHOD, SESSION_UNARCHIVE_METHOD, @@ -54,6 +57,8 @@ import { type RetitleOptions, type SessionIdRequest, type TitleChange, + type BatchGetSessionsRequest, + type BatchGetSessionsResponse, } from "./service.js"; /** What the session index keeps on each `Session`. */ @@ -144,12 +149,13 @@ export class SessionIndexConnection { } /** The AIR capabilities the agent advertises for the index: the index - * itself, archive, rename, the list subscription and the `agentWorktree` + * itself, batch get, archive, rename, the list subscription and the `agentWorktree` * row field, all only to a client that declared `sessionIndex`. */ capabilities(): string[] { return this.service ? [ AIR_SESSION_INDEX_CAPABILITY, + AIR_SESSION_BATCH_GET_CAPABILITY, AIR_SESSION_ARCHIVE_CAPABILITY, AIR_SESSION_RENAME_CAPABILITY, AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY, @@ -230,6 +236,12 @@ export class SessionIndexConnection { return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId)); } + batchGet(params: BatchGetSessionsRequest): Promise { + return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(params, (sessionId) => + this.ownSessionState(sessionId), + ); + } + /** An AIR client archives with session/delete (see {@link deleteSession}): * its archived sessions (an archived custom title) stay hidden from the * old list, as when the delete removed them. */ @@ -448,6 +460,7 @@ export class SessionIndexConnection { /** The agent methods behind the session index extension methods. */ type SessionIndexMethods = { + batchGetSessions(params: BatchGetSessionsRequest): Promise; subscribeSessionList(params: ListSubscribeRequest): Promise; unsubscribeSessionList(params: ListUnsubscribeRequest): Promise; renameSessionTitle(params: RenameSessionRequest): Promise; @@ -455,10 +468,15 @@ type SessionIndexMethods = { unarchiveSession(params: SessionIdRequest): Promise; }; -/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`, +/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, * `_session/rename`, `_session/archive` and `_session/unarchive`. */ export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp { return app + .onRequest( + SESSION_BATCH_GET_METHOD, + { parse: parseBatchGetSessionsRequest }, + (ctx) => agent().batchGetSessions(ctx.params), + ) .onRequest( LIST_SUBSCRIBE_METHOD, { parse: parseListSubscribeRequest }, diff --git a/src/session-index/project-dirs.ts b/src/session-index/project-dirs.ts index 14ade477..37f19e9c 100644 --- a/src/session-index/project-dirs.ts +++ b/src/session-index/project-dirs.ts @@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise { } } +/** Whether the path exists, including a symlink whose target is missing. */ +export async function exists(target: string): Promise { + try { + await fs.lstat(target); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false; + throw error; + } +} + /** The names of the project directories of `cwd` among `rootEntries`. */ export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] { const exact = encodeProjectPath(cwd); diff --git a/src/session-index/service.ts b/src/session-index/service.ts index ffa090c1..75b4bd77 100644 --- a/src/session-index/service.ts +++ b/src/session-index/service.ts @@ -15,6 +15,7 @@ import { RequestError, type ListSessionsRequest, type ListSessionsResponse, + type SessionInfo, } from "@agentclientprotocol/sdk/unstable"; import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk"; import { airExtensionMeta } from "../air-extension.js"; @@ -37,7 +38,7 @@ import { import { LiveSessionRegistry } from "./live-registry.js"; import { canonicalPath, - errorCode, + exists, isExactProjectDir, isSessionId, projectDirMatches, @@ -65,6 +66,7 @@ import { sessionInfoOf } from "./session-info.js"; export const SESSION_RENAME_METHOD = "_session/rename"; export const SESSION_ARCHIVE_METHOD = "_session/archive"; export const SESSION_UNARCHIVE_METHOD = "_session/unarchive"; +export const SESSION_BATCH_GET_METHOD = "_session/batch_get"; /** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */ const RESOURCE_NOT_FOUND = -32002; @@ -73,6 +75,27 @@ const CURSOR_VERSION = 5; export type SessionIdRequest = { sessionId: string }; export type RenameSessionRequest = { sessionId: string; title: string }; +export type BatchGetSessionsRequest = { sessionIds: string[] }; +export type BatchGetSessionsResponse = { + sessions: SessionInfo[]; +}; + +export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest { + const record = asRecord(value); + const ids = record.sessionIds; + if ( + Object.keys(record).length !== 1 || + !Array.isArray(ids) || + ids.some((id) => typeof id !== "string" || !id.trim()) || + new Set(ids).size !== ids.length + ) { + throw RequestError.invalidParams( + undefined, + "params require only sessionIds: distinct, non-blank strings", + ); + } + return { sessionIds: ids }; +} export function sessionNotFound(sessionId: string): RequestError { return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId }); @@ -364,16 +387,6 @@ function isSdkNotFound(error: unknown): boolean { return error instanceof Error && /^Session \S+ not found in /.test(error.message); } -async function exists(target: string): Promise { - try { - await fs.lstat(target); - return true; - } catch (error) { - if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false; - throw error; - } -} - /** The error the SDK `deleteSession` gives for a session id it rejects or * does not find, which a client without `sessionIndex` got before. */ function sdkDeleteError(sessionId: string): Error { @@ -520,6 +533,22 @@ export class SessionIndexService { return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions }; } + /** Metadata for known ids, independent of list pages and project filters. */ + async batchGet( + { sessionIds }: BatchGetSessionsRequest, + own: (sessionId: string) => OwnSessionState | undefined, + ): Promise { + if (sessionIds.length === 0) return { sessions: [] }; + const live = await this.registry.snapshot(); + const now = this.now(); + const rows = await this.index.getSessions(sessionIds); + return { + sessions: rows.map((row) => + sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now), + ), + }; + } + /** Runs `mutation` after the previous mutation of the session ended. */ private exclusive(sessionId: string, mutation: () => Promise): Promise { const previous = this.mutations.get(sessionId) ?? Promise.resolve(); diff --git a/src/session-index/session-index.ts b/src/session-index/session-index.ts index 0f0a2bd5..a8c2b17a 100644 --- a/src/session-index/session-index.ts +++ b/src/session-index/session-index.ts @@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js"; import { canonicalPath, encodeProjectPath, + errorCode, + exists, isExactProjectDir, isSessionId, normalizePath, @@ -58,11 +60,15 @@ import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js"; export const DEFAULT_LIST_LIMIT = 50; export const MAX_LIST_LIMIT = 200; +/** Discovery could not establish which sessions the store contains. */ +export class SessionStoreUnavailableError extends Error {} /** Entries of the metadata cache: a few hundred bytes each, so the * transcripts of a large history (tens of thousands) stay cached and deep * pages do not read them again. */ const METADATA_CACHE_SIZE = 50_000; const READ_BATCH_SIZE = 16; +/** Session ids looked up together by {@link SessionIndex.getSessions}. */ +const LOOKUP_CHUNK_SIZE = 100; /** Transcripts read at most per directory to recover a sibling's cwd. */ const MAX_CWD_PROBES_PER_DIR = 64; /** How long a directory that gave no cwd is not probed again. */ @@ -97,6 +103,24 @@ export type TranscriptCandidate = { ino: number; }; +type TranscriptFile = Pick< + TranscriptCandidate, + "sessionId" | "filePath" | "dirName" | "projectPath" +>; + +/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */ +async function transcriptCandidates( + files: readonly TranscriptFile[], +): Promise { + const stats = await statFiles(files.map(({ filePath }) => filePath)); + return files.flatMap((file, i) => { + const current = stats[i]; + return current?.isFile() && current.size > 0 + ? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }] + : []; + }); +} + /** The cached metadata of one transcript. */ type TranscriptMetadata = { /** The visible title: without the archive prefix. */ @@ -526,25 +550,16 @@ export class SessionIndex { const listed = await this.listings.namesOfAll(dirPaths); const files = dirs.flatMap(({ dirName, projectPath }, i) => listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map( - (name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }), + (name) => ({ + sessionId: name.slice(0, -6), + filePath: path.join(root, dirName, name), + dirName, + projectPath, + }), ), ); - const stats = await statFiles(files.map(({ filePath }) => filePath)); - const all: TranscriptCandidate[] = []; - files.forEach(({ name, filePath, dirName, projectPath }, i) => { - const fileStats = stats[i]; - if (!fileStats?.isFile() || fileStats.size === 0) return; - all.push({ - sessionId: name.slice(0, -6), - filePath, - dirName, - projectPath, - mtimeMs: fileStats.mtimeMs, - size: fileStats.size, - ino: fileStats.ino, - }); - }); - return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all }; + const candidates = await transcriptCandidates(files); + return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates }; } /** @@ -580,6 +595,90 @@ export class SessionIndex { }; } + /** Known ids in request order, using the same rows as the list. + * Addresses exact filenames; only rows without a cwd need sibling discovery. */ + async getSessions(sessionIds: readonly string[]): Promise { + if (sessionIds.length === 0) return []; + const root = projectsRoot(); + let projectEntries: string[]; + try { + projectEntries = await fs.readdir(root); + } catch (cause) { + if (errorCode(cause) === "ENOENT" && !(await exists(root))) { + return []; + } + throw new SessionStoreUnavailableError("Session store could not be read", { cause }); + } + const ids = sessionIds.filter(isSessionId); + const byId = new Map(); + // Paths are project directories × ids: bound them per chunk, not per request. + for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) { + const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE); + for (const row of await this.lookupRows(root, projectEntries, chunk)) { + byId.set(row.sessionId, row); + } + } + return sessionIds.flatMap((id) => { + const row = byId.get(id); + return row ? [row] : []; + }); + } + + /** The rows of `ids` found under `projectEntries`. All copies of an id are + * in the same call, so copy selection is unchanged by chunking. */ + private async lookupRows( + root: string, + projectEntries: readonly string[], + ids: readonly string[], + ): Promise { + let candidates = await transcriptCandidates( + projectEntries.flatMap((dirName) => + ids.map((sessionId) => ({ + sessionId, + filePath: path.join(root, dirName, `${sessionId}.jsonl`), + dirName, + })), + ), + ); + if (CASE_INSENSITIVE_PATHS) { + // On a case-insensitive file system, stat of `ABC.jsonl` also finds + // `abc.jsonl`. Keep a file only when its real name is the requested one, + // or differs by more than case (a symlink, as the list would read it). + // A failed realpath means the file is unreadable, so the id is omitted. + const exact = await Promise.all( + candidates.map(async ({ filePath }) => { + try { + const actual = path.basename(await fs.realpath(filePath)); + const expected = path.basename(filePath); + return actual === expected || actual.toLowerCase() !== expected.toLowerCase(); + } catch { + return false; + } + }), + ); + candidates = candidates.filter((_, i) => exact[i]); + } + return this.collectRows(candidates.sort(byMtimeDescending), { + inScope: scopeOf(undefined), + archived: "all", + wanted: Infinity, + unread: async (read, dirNames) => { + const dirs = [...dirNames]; + const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir))); + const siblings = dirs.flatMap((dirName, i) => + listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))) + .map((name) => ({ + sessionId: name.slice(0, -6), + filePath: path.join(root, dirName, name), + dirName, + })) + .filter(({ filePath }) => !read.has(filePath)), + ); + return (await transcriptCandidates(siblings)).sort(byMtimeDescending); + }, + }); + } + /** Every transcript file of `sessionId` (see {@link scanSession}). Empty * files only with `includeEmpty`. */ async findTranscripts( @@ -778,9 +877,12 @@ export class SessionIndex { archived: ArchivedFilter; after?: ListCursor; wanted: number; - /** The transcripts not read (by path), which may supply a sibling's - * cwd. */ - unread: (read: ReadonlySet) => readonly TranscriptCandidate[]; + /** The unread transcripts of the directories still eligible for cwd + * recovery. Called only after checking the negative cache. */ + unread?: ( + read: ReadonlySet, + dirNames: ReadonlySet, + ) => readonly TranscriptCandidate[] | Promise; restat?: boolean; }, ): Promise { @@ -852,14 +954,15 @@ export class SessionIndex { } await settlePending(); } - if (pending.length > 0) { + const unread = options.unread; + if (pending.length > 0 && unread) { // A row recovered after the scan stopped still sorts into the page: it // was read before the stop, and more rows only raise the bound. A // sibling that the archive filter or the cursor leaves out may still // supply the cwd. await this.learnDirCwds( new Set(pending.map(({ candidate }) => candidate.dirName)), - options.unread(read), + (dirNames) => unread(read, dirNames), options.restat ?? false, ); await settlePending(); @@ -893,7 +996,9 @@ export class SessionIndex { * changes. `restat` as in {@link collectRows}. */ private async learnDirCwds( dirNames: ReadonlySet, - unread: readonly TranscriptCandidate[], + unread: ( + dirNames: ReadonlySet, + ) => readonly TranscriptCandidate[] | Promise, restatFirst: boolean, ): Promise { const root = projectsRoot(); @@ -909,8 +1014,9 @@ export class SessionIndex { if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue; mtimes.set(dirName, mtimeMs); } + if (mtimes.size === 0) return; const probes = new Map(); - let remaining = unread.filter((candidate) => { + let remaining = (await unread(new Set(mtimes.keys()))).filter((candidate) => { if (!mtimes.has(candidate.dirName)) return false; const count = probes.get(candidate.dirName) ?? 0; if (count >= MAX_CWD_PROBES_PER_DIR) return false; diff --git a/src/tests/session-index.test.ts b/src/tests/session-index.test.ts index c754eef7..beea1a80 100644 --- a/src/tests/session-index.test.ts +++ b/src/tests/session-index.test.ts @@ -16,13 +16,18 @@ import { listSessions, } from "@anthropic-ai/claude-agent-sdk"; import { spawn } from "node:child_process"; -import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js"; -import type { SessionInfo } from "@agentclientprotocol/sdk/unstable"; +import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js"; +import { + client as acpClient, + type InitializeResponse, + type SessionInfo, +} from "@agentclientprotocol/sdk/unstable"; import { encodeProjectPath } from "../session-index/project-dirs.js"; import { archiveInsteadOfDelete, archiveTo, renameTo, + parseBatchGetSessionsRequest, SessionIndexService, writeCustomTitleSidecar, } from "../session-index/service.js"; @@ -45,6 +50,11 @@ import { } from "./session-doubles.js"; import { Pushable } from "../utils.js"; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readdir: vi.fn(actual.readdir) }; +}); + vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { const actual = await importOriginal(); return { @@ -262,6 +272,7 @@ const airCapabilities = (response: { _meta?: Record | null }) = describe("sessionIndex negotiation", () => { const indexCapabilities = [ "sessionIndex", + "sessionBatchGet", "sessionArchive", "sessionRename", "sessionListSubscribe", @@ -295,7 +306,7 @@ describe("sessionIndex negotiation", () => { // Declaring archive or rename alone enables nothing. const withoutIndex = await createAgent().agent.initialize({ protocolVersion: 1, - clientCapabilities: air("sessionArchive", "sessionRename"), + clientCapabilities: air("sessionArchive", "sessionRename", "sessionBatchGet"), }); expect(airCapabilities(withoutIndex)).toEqual(baseline); @@ -319,7 +330,7 @@ describe("sessionIndex negotiation", () => { it("answers method-not-found to the new methods without the capability", async () => { // Declaring sessionArchive or sessionRename without sessionIndex enables // nothing either. - for (const capabilities of [[], ["sessionArchive", "sessionRename"]]) { + for (const capabilities of [[], ["sessionArchive", "sessionRename", "sessionBatchGet"]]) { const { agent } = createAgent(); await initializeClient(agent, air(...capabilities)); const sessionId = randomUUID(); @@ -328,10 +339,423 @@ describe("sessionIndex negotiation", () => { }); await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 }); await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 }); + await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toMatchObject({ + code: -32601, + }); } }); }); +describe("_session/batch_get", () => { + it("routes and validates the extension through the ACP connection", async () => { + let agent!: ClaudeAcpAgent; + const connection = acpClient({ name: "batch-get-test" }).connect( + v1AgentApp({ log: () => {}, error: () => {} }, (created) => { + agent = created; + }), + ); + try { + const response = await connection.agent.request("initialize", { + protocolVersion: 1, + clientCapabilities: air("sessionIndex"), + }); + expect(airCapabilities(response)).toContain("sessionBatchGet"); + expect(await connection.agent.request("_session/batch_get", { sessionIds: [] })).toEqual({ + sessions: [], + }); + await expect( + connection.agent.request("_session/batch_get", { sessionIds: [], cwd: "/repo" }), + ).rejects.toMatchObject({ code: -32602 }); + } finally { + connection.close(); + await agent.dispose(); + } + }); + + it("accepts only distinct, non-blank string ids and no other fields", () => { + for (const params of [ + null, + [], + {}, + { sessionIds: null }, + { sessionIds: "id" }, + { sessionIds: [""] }, + { sessionIds: [" "] }, + { sessionIds: [1] }, + { sessionIds: ["id", "id"] }, + { sessionIds: [], cwd: null }, + ]) { + expect(() => parseBatchGetSessionsRequest(params)).toThrow( + expect.objectContaining({ code: -32602 }), + ); + } + const sessionIds = Array.from({ length: 1000 }, (_, i) => `id-${i}`); + expect(parseBatchGetSessionsRequest({ sessionIds })).toEqual({ sessionIds }); + // Ids are exact, not trimmed or otherwise normalized. + expect(parseBatchGetSessionsRequest({ sessionIds: [" id ", "id"] })).toEqual({ + sessionIds: [" id ", "id"], + }); + }); + + it("returns empty results without touching the store", async () => { + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const lookup = vi.spyOn(index, "getSessions").mockRejectedValue(new Error("store down")); + expect(await agent.batchGetSessions({ sessionIds: [] })).toEqual({ + sessions: [], + }); + expect(lookup).not.toHaveBeenCalled(); + await agent.dispose(); + }); + + it("returns no sessions when the store is absent", async () => { + const { agent } = await indexAgent(); + const sessionIds = [randomUUID(), "retained-id"]; + expect(await agent.batchGetSessions({ sessionIds })).toEqual({ + sessions: [], + }); + await agent.dispose(); + }); + + it("finds ids older than the recent 50 without listing or reading unrelated history", async () => { + const base = Date.parse("2026-03-01T00:00:00Z"); + const ids: string[] = []; + for (let i = 0; i < 55; i++) { + ids.push( + ( + await writeTranscript({ + cwd: path.join(workspace, `project-${i % 2}`), + lastMessageAt: base - i * 60_000, + }) + ).id, + ); + } + const recent = await indexAgent(); + const page = await recent.agent.listSessions({}); + expect(page.sessions).toHaveLength(50); + expect(page.sessions.map((s) => s.sessionId)).not.toContain(ids[54]); + await recent.agent.dispose(); + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const list = vi.spyOn(index, "list").mockRejectedValue(new Error("must not list")); + const names = vi.spyOn(index.listings, "namesOfAll").mockRejectedValue(new Error("history")); + const wanted = [ids[54]!, ids[53]!, ids[0]!]; + const readdir = vi.mocked(fs.readdir); + readdir.mockClear(); + try { + const result = await agent.batchGetSessions({ sessionIds: wanted }); + expect(result.sessions.map((s) => s.sessionId)).toEqual(wanted); + expect(transcriptsRead(agent)).toBe(wanted.length); + const root = path.join(configDir, "projects"); + const storeReads = readdir.mock.calls + .map(([dir]) => String(dir)) + .filter((dir) => dir === root || dir.startsWith(root + path.sep)); + expect(storeReads).toEqual([root]); + expect(list).not.toHaveBeenCalled(); + expect(names).not.toHaveBeenCalled(); + expect(listSessions).not.toHaveBeenCalled(); + expect(getSessionInfo).not.toHaveBeenCalled(); + } finally { + readdir.mockClear(); + await agent.dispose(); + } + }); + + it("keeps request order and copy selection across lookup chunks", async () => { + const base = Date.parse("2026-03-01T00:00:00Z"); + const first = await writeTranscript({ lastMessageAt: base }); + const last = await writeTranscript({ lastMessageAt: base - 60_000 }); + const copyId = randomUUID(); + await writeTranscript({ sessionId: copyId, prompt: "Old copy", lastMessageAt: base - 120_000 }); + await writeTranscript({ + sessionId: copyId, + cwd: path.join(workspace, "other"), + prompt: "New copy", + lastMessageAt: base - 30_000, + }); + // 250 ids: three lookup chunks of 100, with found ids in the first, second and third. + const sessionIds: string[] = Array.from({ length: 250 }, () => randomUUID()); + sessionIds[0] = last.id; + sessionIds[120] = copyId; + sessionIds[249] = first.id; + const { agent } = await indexAgent(); + try { + const result = await agent.batchGetSessions({ sessionIds }); + expect(result.sessions.map((s) => s.sessionId)).toEqual([last.id, copyId, first.id]); + expect(result.sessions[1]!.title).toBe("New copy"); + } finally { + await agent.dispose(); + } + }); + + it("recovers a cold batch row from a sibling and keeps it in the next list", async () => { + const base = Date.parse("2026-04-01T00:00:00Z"); + const other = path.join(workspace, "other"); + const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base }); + const fillers: string[] = []; + for (let i = 1; i <= 16; i++) { + fillers.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id); + } + // This sibling is older than the transcripts needed to fill the page. + await writeTranscript({ cwd: other, lastMessageAt: base - 3600_000 }); + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const names = vi.spyOn(index.listings, "namesOfAll"); + const now = vi.spyOn(Date, "now").mockReturnValue(Date.now()); + try { + const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] }); + expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]); + expect(transcriptsRead(agent)).toBe(2); + expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]); + names.mockRestore(); + + // Use the same index without changing files or letting the negative cache expire. + const page = await agent.listSessions({ _meta: listMeta({ limit: 2 }) }); + expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([ + [noCwd.id, other], + [fillers[0], workspace], + ]); + expect(page.sessions[0]).toEqual(result.sessions[0]); + } finally { + names.mockRestore(); + now.mockRestore(); + await agent.dispose(); + } + }); + + it("bounds sibling cwd probes and retries a negative result after its cache expires", async () => { + const base = Date.parse("2026-04-01T00:00:00Z"); + const other = path.join(workspace, "other"); + const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base }); + const siblings: { id: string; file: string }[] = []; + for (let i = 1; i <= 70; i++) { + siblings.push( + await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base - i * 1000 }), + ); + } + await writeTranscript({ lastMessageAt: base }); + const { agent } = await indexAgent(); + const index = (agent as any).sessionIndex.service.index; + const names = vi.spyOn(index.listings, "namesOfAll"); + const at = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(at); + try { + expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] }); + expect(transcriptsRead(agent)).toBe(1 + 64); + expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]); + + // Rewriting an existing sibling does not change the directory mtime. + await writeTranscript({ sessionId: siblings[0]!.id, cwd: other, lastMessageAt: base }); + expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] }); + expect(transcriptsRead(agent)).toBe(1 + 64); + expect(names).toHaveBeenCalledTimes(1); + + now.mockReturnValue(at + 60_001); + const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] }); + expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]); + expect(transcriptsRead(agent)).toBe(1 + 64 + 1); + expect(names.mock.calls).toEqual([ + [[path.dirname(noCwd.file)]], + [[path.dirname(noCwd.file)]], + ]); + } finally { + names.mockRestore(); + now.mockRestore(); + await agent.dispose(); + } + }); + + it("returns the same unscoped list rows for archived, worktree and duplicate copies in request order", async () => { + const repo = path.join(workspace, "repo"); + const worktree = path.join(workspace, "worktree"); + await linkWorktrees(repo, worktree); + const archivedId = randomUUID(); + const archived = await writeTranscript({ + sessionId: archivedId, + cwd: repo, + trailer: titleRecords(archivedId, "[archived] Done"), + }); + const worktreeId = randomUUID(); + await writeTranscript({ + sessionId: worktreeId, + cwd: worktree, + trailer: [worktreeState(worktreeId, { path: worktree })], + }); + const copyId = randomUUID(); + await writeTranscript({ sessionId: copyId, cwd: repo, prompt: "Old copy" }); + await writeTranscript({ + sessionId: copyId, + cwd: worktree, + prompt: "New copy", + mtimeMs: Date.parse("2026-02-01"), + }); + const { agent } = await indexAgent(); + const requested = [worktreeId, archived.id, copyId]; + const result = await agent.batchGetSessions({ sessionIds: requested }); + const read = transcriptsRead(agent); + const listed = await agent.listSessions({ _meta: listMeta({ archived: "all" }) }); + expect(transcriptsRead(agent)).toBe(read); + expect(result.sessions).toEqual( + requested.map((id) => listed.sessions.find((s) => s.sessionId === id)), + ); + expect(airRow(result.sessions[1]!).archived).toBe(true); + expect(agentWorktreeOfRow(result.sessions[0])).toBe(true); + expect(result.sessions[2]).toMatchObject({ title: "New copy", cwd: worktree }); + await agent.dispose(); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "returns readable sessions in request order and omits unavailable ids", + async () => { + const first = await writeTranscript({}); + const found = await writeTranscript({}); + const unreadable = await writeTranscript({}); + await fs.chmod(unreadable.file, 0); + const absent = randomUUID(); + const { agent } = await indexAgent(); + try { + const result = await agent.batchGetSessions({ + sessionIds: [absent, first.id, unreadable.id, found.id], + }); + expect(result.sessions.map((s) => s.sessionId)).toEqual([first.id, found.id]); + expect(Object.keys(result)).toEqual(["sessions"]); + } finally { + await fs.chmod(unreadable.file, 0o600); + await agent.dispose(); + } + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "uses a readable copy when another copy cannot be read or stat'ed, as the unscoped list does", + async () => { + const sessionId = randomUUID(); + await writeTranscript({ sessionId, prompt: "Readable copy" }); + const hiddenProject = path.join(workspace, "inaccessible"); + const unreadable = await writeTranscript({ + sessionId, + cwd: hiddenProject, + prompt: "Unreadable newer copy", + mtimeMs: Date.parse("2026-02-01"), + }); + const { agent } = await indexAgent(); + try { + // A failed metadata read, then a failed stat of the same copy. + for (const target of [unreadable.file, path.dirname(unreadable.file)]) { + await fs.chmod(target, 0); + const result = await agent.batchGetSessions({ sessionIds: [sessionId] }); + const listed = await agent.listSessions({}); + expect(result).toEqual({ sessions: listed.sessions }); + expect(result.sessions[0]?.title).toBe("Readable copy"); + await fs.chmod(target, 0o700); + } + } finally { + await fs.chmod(path.dirname(unreadable.file), 0o700); + await fs.chmod(unreadable.file, 0o600); + await agent.dispose(); + } + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "omits failed stats, dangling links and non-file transcripts", + async () => { + const unreadable = await writeTranscript({ cwd: path.join(workspace, "inaccessible") }); + const dangling = await writeTranscript({}); + await fs.rm(dangling.file); + await fs.symlink(path.join(workspace, "not-there"), dangling.file); + const nonFile = await writeTranscript({}); + await fs.rm(nonFile.file); + await fs.mkdir(nonFile.file); + const { agent } = await indexAgent(); + const projectDir = path.dirname(unreadable.file); + await fs.chmod(projectDir, 0); + try { + const result = await agent.batchGetSessions({ + sessionIds: [unreadable.id, dangling.id, nonFile.id], + }); + expect(result).toEqual({ sessions: [] }); + } finally { + await fs.chmod(projectDir, 0o700); + await agent.dispose(); + } + }, + ); + + it("matches UUIDs exactly without case or whitespace normalization", async () => { + const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + await writeTranscript({ sessionId }); + const { agent } = await indexAgent(); + const result = await agent.batchGetSessions({ + sessionIds: [sessionId.toUpperCase(), ` ${sessionId} `, sessionId], + }); + expect(result.sessions.map((row) => row.sessionId)).toEqual([sessionId]); + await agent.dispose(); + }); + + it("omits empty, malformed, sidecar-only and unresolved-cwd records", async () => { + const empty = await writeTranscript({}); + await fs.writeFile(empty.file, ""); + const malformed = await writeTranscript({}); + await fs.writeFile(malformed.file, "not json\n"); + const sidecar = randomUUID(); + const sidecarDir = path.join(configDir, "projects", encodeProjectPath(workspace), sidecar); + await fs.mkdir(sidecarDir, { recursive: true }); + await fs.writeFile(path.join(sidecarDir, "custom-title.json"), '{"customTitle":"Saved"}'); + const unresolved = await writeTranscript({ + cwd: path.join(workspace, "unknown"), + recordCwd: null, + }); + const { agent } = await indexAgent(); + const ids = [empty.id, malformed.id, sidecar, unresolved.id]; + const result = await agent.batchGetSessions({ sessionIds: ids }); + expect(result).toEqual({ sessions: [] }); + await agent.dispose(); + }); + + it + .skipIf(process.platform === "win32" || process.getuid?.() === 0) + .each(["unreadable", "unreadable after a cached list", "a file", "a dangling symlink"])( + "rejects a projects root that is %s", + async (kind) => { + const found = await writeTranscript({}); + const root = path.join(configDir, "projects"); + const { agent } = await indexAgent(); + try { + if (kind === "unreadable after a cached list") { + const old = new Date(Date.now() - 60_000); + await fs.utimes(root, old, old); + await agent.listSessions({}); + } + if (kind.startsWith("unreadable")) { + await fs.chmod(root, 0); + } else { + await fs.rm(root, { recursive: true }); + if (kind === "a file") await fs.writeFile(root, "not a store"); + else await fs.symlink(path.join(workspace, "missing-store"), root); + } + for (const sessionId of [found.id, "retained-id"]) { + await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toThrow( + "Session store could not be read", + ); + } + } finally { + if (kind.startsWith("unreadable")) await fs.chmod(root, 0o700); + await agent.dispose(); + } + }, + ); + + it("rejects under ACP v2 even when sessionIndex was declared", async () => { + const { agent } = createAgent({ v2: true }); + await initializeClient(agent, air("sessionIndex")); + await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({ + code: -32601, + }); + await agent.dispose(); + }); +}); + describe("session/list of a sessionIndex client", () => { const base = Date.parse("2026-03-01T00:00:00Z");