diff --git a/AGENTS.md b/AGENTS.md --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - `src/cursor-provider-errors.ts` owns scrubbed Cursor SDK run failure detail, abort reason formatting, and provider error sanitization. - `src/cursor-provider-lazy.ts` owns the lazy `streamSimple` wrapper that defers Cursor provider runtime imports until the provider is invoked. - `src/cursor-session-scope.ts` owns pi session cwd, session file/id/name/generation scope keys, and `session_start` / `session_info_changed` registration for session-agent pooling, cloud agent names, and debug grouping. +- `src/cursor-session-store.ts` owns per-session Cursor SDK SQLite store identity derivation, open/disposal, temporary fileless stores, and guarded removal. - `src/cursor-http1.ts` owns branch-scoped local HTTP/1.1 session state, global-preference override tracking, and extension-owned SDK configuration/null reset. - `src/cursor-ripgrep-path.ts` owns bundled Cursor SDK platform ripgrep resolution and local-agent environment initialization. - `src/cursor-session-agent.ts` owns session-scoped SDK agent pooling, transport-aware pool identity, send-state commits, busy tracking for in-flight SDK `run.wait()` work, and scoped acquire/dispose state. diff --git a/CHANGELOG.md b/CHANGELOG.md --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Give each persisted pi session its own Cursor SDK SQLite store under the workspace SDK state root and thread that exact store through local create/resume, message reads, checkpoint lookup, delete, and explicit cleanup paths, preventing parallel pi sessions from contending on one workspace `index.db`. Fileless acquisitions use unique OS-temporary stores with guarded graceful removal and start a fresh agent after in-process invalidation instead of reopening a disposed temporary store. Resume entries now version their store identity; legacy entries keep the default workspace store for resume and migrate to the per-session store after fallback or replacement. Older extension versions ignore the new version-2 resume entries after a downgrade. - Initialize `CURSOR_RIPGREP_PATH` from the installed Cursor SDK platform package before local agent creation, including nested npm dependency layouts, so Cursor-native Grep/Glob can use the bundled executable. - Bound pending pi bridge `CallTool` waits to the effective MCP tool timeout, with a lower-only `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` override; expiry and cancellation remove stale calls and abort active pi execution when available. diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -309,14 +309,14 @@ This maps to the next actual `agent.send(..., { local: { force: true } })` only. SDK load, agent acquire, prompt preparation, or a pre-send abort does not consume it. A consumed CLI flag is not rearmed by session reload/tree lifecycle events; the environment override remains once per process. It is not a retry loop and does not cancel another live process's existing run handle; use it only when you know the persisted local run is wedged. -Branch-scoped local resume reattaches to recorded local SDK agents after a pi restart. It is on by default for local runtime and records agent IDs only in pi session custom entries, never user/project config. Disable it per run with CLI/env, or persist an opt-out in config: +Branch-scoped local resume reattaches to recorded local SDK agents after a pi restart. It is on by default for local runtime and records agent IDs plus their SDK store identity only in pi session custom entries, never user/project config. Disable it per run with CLI/env, or persist an opt-out in config: ```bash pi --model cursor/composer-2-5 --cursor-no-local-resume PI_CURSOR_LOCAL_RESUME=0 pi --model cursor/composer-2-5 ``` -Resume is strict: the current pi session file/id, branch path prefix, cwd/repo root, model/API/tool-surface pool key, and compaction generation must match. A trailing user message already present at process startup is crash-ambiguous and invalidates the old handle; only a user message appended in the current process may span a recorded handle, preventing restart from resending an already-submitted prompt. A successful process reattachment bootstraps the current pi transcript once while retaining the resumed Cursor agent's native state; later in-process turns remain incremental. If `Agent.resume()` fails, pi bootstraps a new local Cursor agent from the current transcript and streams one display-only continuity note. Superseded local agents can be cleaned up explicitly with `/cursor-local-resume-cleanup --dry-run` and `/cursor-local-resume-cleanup --yes`; cleanup only deletes exact recorded `agent-*` IDs. Cloud resume remains disabled; `/cursor-cloud list|archive|delete` only manages recorded cloud agents. +Resume is strict: the current pi session file/id, branch path prefix, cwd/repo root, model/API/tool-surface pool key, SDK store identity, and compaction generation must match. Each persisted pi session gets a SQLite store under `/pi-sessions//`, and that same store is used for create/resume, transcript reads, checkpoint lookup, and exact-ID cleanup so parallel pi sessions do not contend on one workspace `index.db`. Fileless sessions use a unique OS-temporary store per acquisition, remove it on graceful disposal, and start a fresh agent after invalidation instead of reopening a disposed temporary store. Legacy resume entries still try the SDK's default workspace store; if that resume fails or the agent is later replaced, the new agent moves to the per-session store. A trailing user message already present at process startup is crash-ambiguous and invalidates the old handle; only a user message appended in the current process may span a recorded handle, preventing restart from resending an already-submitted prompt. A successful process reattachment bootstraps the current pi transcript once while retaining the resumed Cursor agent's native state; later in-process turns remain incremental. If `Agent.resume()` fails, pi bootstraps a new local Cursor agent from the current transcript and streams one display-only continuity note. Superseded local agents can be cleaned up explicitly with `/cursor-local-resume-cleanup --dry-run` and `/cursor-local-resume-cleanup --yes`; cleanup only deletes exact recorded `agent-*` IDs from their recorded store. Cloud resume remains disabled; `/cursor-cloud list|archive|delete` only manages recorded cloud agents. Config can also set non-secret defaults in `~/.pi/agent/cursor-sdk.json` or trusted `.pi/cursor-sdk.json`. Project config activates only when Pi's project-trust flow reached this extension and approved the project, or the run started with explicit `--approve`; Pi's implicit trust for a project with no recognized resources is not enough. Because Pi 0.80.9 loads project-local package extensions after the trust event, `pi install -l` users must pass `--approve` on every run that reads or writes `.pi/cursor-sdk.json`. A trust resource added after trust resolution requires restarting pi. `/cursor-runtime ... --save-project` requires the same trust provenance and does not create Pi trust resources automatically. Explicit runtime, fast-default, and HTTP transport saves preserve unrecognized fields, reject malformed or non-object JSON without rewriting it, and serialize concurrent writers. A completed global preference write is retained if Pi's subsequent session-journal append fails, because Pi may already have mutated the in-memory branch; the command reports that partial journal failure and ignores the uncertain session entry until a later successful save or session restart. If a process is force-killed during the tiny update window, the next save reports the `.lock` path; remove it only after confirming no pi process is writing that config. @@ -434,7 +434,7 @@ /cursor-local-resume-cleanup --yes ``` -It only deletes superseded local `agent-*` IDs that this extension recorded as cleanup candidates, one exact ID at a time through the Cursor SDK, and protects agents still resumable from any session-tree branch. Before SDK deletion it verifies and fsyncs an exact intent in the Pi session JSONL, then verifies and fsyncs the result; a missing or non-durable intent prevents deletion, while a missing or non-durable result leaves the durable intent—and a conservative current-process marker—blocking automatic retry. It does not sweep the SDK store or call lower-level empty delete filters. +It only deletes superseded local `agent-*` IDs that this extension recorded as cleanup candidates, one exact ID at a time through the Cursor SDK using the candidate's recorded store identity (or the SDK default workspace store for legacy candidates without one), and protects agents still resumable from any session-tree branch. Before SDK deletion it verifies and fsyncs an exact intent in the Pi session JSONL, then verifies and fsyncs the result; a missing or non-durable intent prevents deletion, while a missing or non-durable result leaves the durable intent—and a conservative current-process marker—blocking automatic retry. A candidate with a recorded store identity that is invalid for the current session is durably marked non-retryable and excluded from later cleanup attempts. It does not sweep any SDK store or call lower-level empty delete filters. Removing a pi session file does not automatically remove its persisted store directory. After permanently retiring that session and confirming no pi process is using it, a recorded root may be removed manually only when it is the session-derived `/pi-sessions//` path. Never manually remove the SDK default workspace root, which legacy entries may record and other sessions may share. Only enabled local safety values are passed to `Agent.create({ local })`; false/default values are omitted to preserve the current local-agent behavior. Local force is one-shot/manual-only through CLI/env and is passed only to the next `Agent.send({ local: { force: true } })`. Local resume is enabled by default for local runtime; opt out with `local.resume: false`, `--cursor-no-local-resume`, or `PI_CURSOR_LOCAL_RESUME=0`. Changes take effect on the next turn without recreating a healthy pooled agent. diff --git a/docs/cursor-model-ux-spec.md b/docs/cursor-model-ux-spec.md --- a/docs/cursor-model-ux-spec.md +++ b/docs/cursor-model-ux-spec.md @@ -43,6 +43,7 @@ - Max Mode context windows are distinct from default/non-Max context windows. `@cursor/sdk` 1.0.23 documentation says the SDK may enable Max Mode automatically when a selected model requires it, but the public local-agent `ModelSelection` path still does not expose a manual Max Mode selector. Do not advertise Max Mode context windows unless the SDK catalog exposes an exact parameter/variant or the SDK public API adds a Max Mode selector that the extension actually sends. - The installed `@cursor/sdk` exposes latest-style `ModelListItem.aliases`. The extension registers only unambiguous aliases as pi model IDs (with the same context suffixes when applicable) and sends the alias back in `ModelSelection.id`. Cursor-only fast preferences are keyed by the selected SDK model ID/alias, with read fallback for older preferences keyed by the underlying catalog `id`. Aliases shared by multiple base models, such as generic family aliases, are skipped because the pi row metadata would otherwise imply one base model while Cursor may resolve the alias to another. - Local restart resume treats user entries already present at `session_start` or selected by tree navigation as crash-ambiguous: an older SDK handle cannot span them because the prior process may already have submitted that prompt. A user entry appended after startup in the current process may span the last completed handle for the normal next send. +- Persisted pi sessions use a session-scoped Cursor SDK SQLite store at `/pi-sessions//`; create/resume, transcript reads, checkpoint lookup, and exact-ID cleanup all receive that same store. Fileless acquisitions use unique OS-temporary stores that are removed on graceful disposal; invalidation starts a fresh agent instead of reopening a disposed temporary store. Resume entries version the store identity. Legacy entries still resume against the SDK default workspace store, then move to the per-session store after fallback or agent replacement. Removing a persisted pi session does not automatically remove its store directory; only a verified session-derived `pi-sessions/` root may be removed after no pi process uses it. The shared SDK default workspace root recorded by legacy entries must never be removed as session cleanup. Cloud agents are unchanged. - Session-scoped Cursor SDK agent pooling reuses one live `@cursor/sdk` agent across compatible follow-up turns within the same pi session scope. `planCursorSessionSend()` in `src/cursor-session-send-policy.ts` decides whether the next turn sends a full bootstrap prompt or an incremental follow-up, whether the SDK agent must be recreated, and why. `computeCursorContextFingerprint()` and `shouldBootstrapCursorContext()` remain the context-only bootstrap signal. The pool recreates the agent when context diverges, when branch or compaction summaries appear after `/tree` navigation or compaction, after 20 completed incremental sends, when the API key identity changes, after send errors, on `session_shutdown`, and when `session_before_tree` / `session_tree` invalidate the active branch. Incremental sends omit the full Cursor SDK tool boundary block because the session agent retains prior bootstrap context, but every send ends with a short tool tail guard placed after the latest user request (including an explicit shell `cd` hint). - Pi steering/follow-up delivery can arrive while a split live Cursor SDK run is still active. The provider resolves pending live runs by scanning trailing `toolResult` messages while skipping trailing `user` messages, tracks the active live run per session scope, and resumes the in-flight run instead of calling `Agent.send()` again. When the context ends with steering user text after tool results, the provider releases the prior live run and chains an incremental `Agent.send()` for the latest user message in the same provider turn; if the prior run emits more text or tool requests after steering arrives, that stale activity is cancelled instead of surfacing another old-run tool turn and losing the new user input. A pre-send guard waits for or resumes any still-active scoped live run before starting a fresh send so `@cursor/sdk` `AgentBusyError` (`already has active run`) does not surface to pi users. Pooled session agents mark busy as soon as live/direct `run.wait()` tracking starts (`trackRunCompletion` on the session lease), and `acquireSessionCursorAgent()` awaits that busy state before returning a lease so send planning, transcript offsets, and later `Agent.send()` do not race the prior turn's SDK run completion (for example pi auto-compaction summarization). `session_before_compact` calls `prepareCursorSessionForCompaction()` to release scoped live-run drain state and reset the pooled agent before summarization streams. Tracked completions and send commits are scoped to the pooled agent `instanceId` so disposal/replacement drops stale tracking and ignores late commits from disposed agents. diff --git a/scripts/local-resume-cleanup-smoke.mjs b/scripts/local-resume-cleanup-smoke.mjs --- a/scripts/local-resume-cleanup-smoke.mjs +++ b/scripts/local-resume-cleanup-smoke.mjs @@ -25,6 +25,7 @@ let oldAgentId; let newAgentId; let oldResumeEntryId; + let oldStoreIdentity; console.error(scrubSmokeText(`[local-resume-smoke] artifacts: ${artifactRoot}`)); try { await withRpc({ artifactDir: artifactRoot, sessionDir, sessionId }, async (rpc) => { @@ -37,8 +38,13 @@ }); assertTurnMetadata("cleanup baseline", baseline, { resumedAgent: false }); oldAgentId = baseline.metadata.run.agentId; - oldResumeEntryId = resumeEntries(await getEntries(rpc)).at(-1)?.id; + const baselineHandle = resumeEntries(await getEntries(rpc)).at(-1); + oldResumeEntryId = baselineHandle?.id; + oldStoreIdentity = baselineHandle?.data?.storeIdentity; if (!oldResumeEntryId) fail("cleanup baseline did not persist a resume entry"); + if (baselineHandle?.data?.version !== 2 || !oldStoreIdentity?.stateRoot?.includes("pi-sessions")) { + fail("cleanup baseline did not persist a per-session store identity", JSON.stringify(baselineHandle?.data, null, 2)); + } }); await withRpc({ artifactDir: artifactRoot, sessionDir, sessionId, bridge: true, exposeBuiltinTools: true }, async (rpc) => { @@ -52,7 +58,10 @@ assertNotResumedFrom("cleanup changed tool surface", changedSurface, oldAgentId); newAgentId = changedSurface.metadata.run.agentId; const changedHandle = latestResumeEntry(await getEntries(rpc)); - if (!changedHandle?.cleanupCandidateAgentIds?.includes(oldAgentId)) fail("changed tool surface did not record old agent cleanup candidate", JSON.stringify({ oldAgentId, changedHandle }, null, 2)); + const cleanupCandidate = changedHandle?.cleanupCandidates?.find((candidate) => candidate.agentId === oldAgentId); + if (!cleanupCandidate || JSON.stringify(cleanupCandidate.storeIdentity) !== JSON.stringify(oldStoreIdentity)) { + fail("changed tool surface did not record old agent cleanup candidate with its store identity", JSON.stringify({ oldAgentId, oldStoreIdentity, changedHandle }, null, 2)); + } await rpcData(rpc, "prompt", { message: "/cursor-local-resume-cleanup --dry-run" }, timeoutMs); let latestCleanup = (await waitForCleanupEntryCount(rpc, 1, timeoutMs)).at(-1)?.data; diff --git a/src/cursor-agent-message-web-tools.ts b/src/cursor-agent-message-web-tools.ts --- a/src/cursor-agent-message-web-tools.ts +++ b/src/cursor-agent-message-web-tools.ts @@ -1,4 +1,4 @@ -import type { AgentMessage } from "@cursor/sdk"; +import type { AgentMessage, LocalAgentStore } from "@cursor/sdk"; import { asRecord, getArray, getString } from "./cursor-record-utils.js"; import { stringifyUnknown } from "./cursor-transcript-utils.js"; import { loadCursorSdk } from "./cursor-sdk-runtime.js"; @@ -22,22 +22,28 @@ return record[caseName]; } -async function hasCursorAgentMessageAt(agentId: string, cwd: string, offset: number): Promise { +async function hasCursorAgentMessageAt(agentId: string, cwd: string, offset: number, store?: LocalAgentStore): Promise { const { Agent } = await loadCursorSdk(); - const messages = await Agent.messages.list(agentId, { runtime: "local", cwd, limit: 1, offset }); + const messages = await Agent.messages.list(agentId, { + runtime: "local", + cwd, + ...(store ? { store } : {}), + limit: 1, + offset, + }); return messages.length > 0; } -export async function countCursorAgentMessages(agentId: string, cwd: string): Promise { +export async function countCursorAgentMessages(agentId: string, cwd: string, store?: LocalAgentStore): Promise { let high = 1; - while (await hasCursorAgentMessageAt(agentId, cwd, high)) { + while (await hasCursorAgentMessageAt(agentId, cwd, high, store)) { high *= 2; } let low = 0; while (low < high) { const mid = Math.floor((low + high) / 2); - if (await hasCursorAgentMessageAt(agentId, cwd, mid)) low = mid + 1; + if (await hasCursorAgentMessageAt(agentId, cwd, mid, store)) low = mid + 1; else high = mid; } return low; @@ -47,12 +53,14 @@ agentId: string; cwd: string; offset: number | undefined; + store?: LocalAgentStore; }): Promise { if (options.offset === undefined) return []; const { Agent } = await loadCursorSdk(); const messages = await Agent.messages.list(options.agentId, { runtime: "local", cwd: options.cwd, + ...(options.store ? { store: options.store } : {}), limit: CURSOR_AGENT_MESSAGE_PAGE_LIMIT, offset: options.offset, }); diff --git a/src/cursor-provider-turn-finalize.ts b/src/cursor-provider-turn-finalize.ts --- a/src/cursor-provider-turn-finalize.ts +++ b/src/cursor-provider-turn-finalize.ts @@ -1,4 +1,4 @@ -import type { RunError, SDKAgent } from "@cursor/sdk"; +import type { LocalAgentStore, RunError, SDKAgent } from "@cursor/sdk"; import { loadCursorTranscriptWebToolCallsAfterOffset } from "./cursor-agent-message-web-tools.js"; import { collectCursorCloudRunReport, @@ -18,11 +18,21 @@ import type { CursorProviderTurnPrepareResult } from "./cursor-provider-turn-types.js"; import { loadCursorSdk } from "./cursor-sdk-runtime.js"; -export async function cacheSdkContextWindow(agentId: string, modelId: string, cwd?: string): Promise { +export async function cacheSdkContextWindow( + agentId: string, + modelId: string, + cwd?: string, + store?: LocalAgentStore, +): Promise { try { const { createAgentPlatform } = await loadCursorSdk(); const platform = await createAgentPlatform( - cwd ? { workspaceRef: cwd, scopedWorkspaceRef: cwd } : undefined, + cwd || store + ? { + ...(cwd ? { workspaceRef: cwd, scopedWorkspaceRef: cwd } : {}), + ...(store ? { localStore: store } : {}), + } + : undefined, ); const checkpoint = await platform.checkpointStore.loadLatest(agentId); const contextWindow = getCheckpointContextWindow(checkpoint); @@ -65,6 +75,7 @@ agentId: string, cwd: string, messageOffset: number | undefined, + turnStore: LocalAgentStore, turnCoordinator: CursorSdkTurnCoordinator, sdkEventDebug: CursorSdkEventDebugSink | undefined, ): Promise { @@ -73,6 +84,7 @@ agentId, cwd, offset: messageOffset, + store: turnStore, }); if (transcriptToolCalls.length === 0) return; sdkEventDebug?.recordCoordinatorEvent("cursor-transcript-web-tools", { @@ -176,6 +188,7 @@ params.run.agentId, params.prepared.cwd, params.cursorAgentMessageOffset, + params.prepared.sessionAgentLease.store, params.prepared.runtime.turnCoordinator, params.sdkEventDebug, ); @@ -187,7 +200,12 @@ // Debug artifact failures must never affect provider execution. } if (params.prepared.runtimeTarget === "local" && params.cacheContextWindow !== false) { - await cacheSdkContextWindow(params.contextWindowAgentId ?? params.run.agentId, params.modelId, params.prepared.cwd); + await cacheSdkContextWindow( + params.contextWindowAgentId ?? params.run.agentId, + params.modelId, + params.prepared.cwd, + params.prepared.sessionAgentLease.store, + ); } return { outcome, displayOnlyTraceBlock }; } diff --git a/src/cursor-provider-turn-send.ts b/src/cursor-provider-turn-send.ts --- a/src/cursor-provider-turn-send.ts +++ b/src/cursor-provider-turn-send.ts @@ -76,7 +76,7 @@ let cursorAgentMessageOffset: number | undefined; if (prepared.runtimeTarget === "local") { try { - cursorAgentMessageOffset = await countCursorAgentMessages(agent.agentId, cwd); + cursorAgentMessageOffset = await countCursorAgentMessages(agent.agentId, cwd, prepared.sessionAgentLease.store); } catch (error) { recordDebug(() => sdkEventDebug?.recordError("cursor_agent_message_count", error)); } diff --git a/src/cursor-session-agent-cleanup.ts b/src/cursor-session-agent-cleanup.ts --- a/src/cursor-session-agent-cleanup.ts +++ b/src/cursor-session-agent-cleanup.ts @@ -1,3 +1,4 @@ +import type { LocalAgentStore } from "@cursor/sdk"; import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent"; import { asRecord, getString } from "./cursor-record-utils.js"; import { fsyncExistingRegularFile } from "./cursor-durable-fs.js"; @@ -10,9 +11,15 @@ parseCursorSessionAgentResumeEntryData, readResumableCursorSessionAgentIds, resolveCursorSessionRepoRoot, + type CursorSessionAgentCleanupCandidate, type CursorSessionAgentResumeEntryData, type CursorSessionAgentResumeScope, } from "./cursor-session-agent-resume.js"; +import { + cursorSessionStoreIdentitiesEqual, + getCursorSessionStoreIdentities, + openCursorSessionStore, +} from "./cursor-session-store.js"; export const CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE = "cursor-sdk-agent-cleanup"; @@ -22,6 +29,7 @@ export interface CursorSessionAgentCleanupFailure { agentId: string; error: string; + retryable?: boolean; } export interface CursorSessionAgentCleanupEntryData { @@ -48,8 +56,10 @@ ui: Pick; }; type LocalResumeCleanupSdkOperations = { - delete(agentId: string, options?: { cwd?: string }): Promise; + delete(agentId: string, options?: { cwd?: string; store?: LocalAgentStore }): Promise; }; + +class InvalidCursorSessionStoreIdentityError extends Error {} // ponytail: grows for the process lifetime, but its ceiling is the exact agent IDs this process // attempted to delete (never global) — it only fills the gap until this process exits; the durable @@ -106,7 +116,9 @@ const failedAgentIds = Array.isArray(record.failedAgentIds) ? record.failedAgentIds.flatMap((item): CursorSessionAgentCleanupFailure[] => { const failure = asRecord(item); - return isCursorLocalAgentId(failure?.agentId) && typeof failure.error === "string" ? [{ agentId: failure.agentId, error: failure.error }] : []; + return isCursorLocalAgentId(failure?.agentId) && typeof failure.error === "string" + ? [{ agentId: failure.agentId, error: failure.error, ...(failure.retryable === false ? { retryable: false } : {}) }] + : []; }) : undefined; return { @@ -124,6 +136,7 @@ function readUnavailableAgentIds(entries: readonly SessionEntry[]): Set { const deleted = new Set(); const pending = new Set(); + const permanentlyFailed = new Set(); for (const entry of entries) { if (entry.type !== "custom" || entry.customType !== CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE) continue; const data = parseCleanupEntryData(entry.data); @@ -139,14 +152,46 @@ pending.delete(agentId); } if (data.phase === "result") { - for (const { agentId } of data.failedAgentIds ?? []) pending.delete(agentId); + for (const failure of data.failedAgentIds ?? []) { + pending.delete(failure.agentId); + if (failure.retryable === false) permanentlyFailed.add(failure.agentId); + } } } - return new Set([...deleted, ...pending, ...nondurableCleanupResultAgentIds]); + return new Set([...deleted, ...pending, ...permanentlyFailed, ...nondurableCleanupResultAgentIds]); } function readLatestBranchAgentId(branch: readonly SessionEntry[], scope: CursorSessionAgentCleanupScope): string | undefined { return readResumeEntries(branch).filter((entry) => resumeEntryMatchesCleanupScope(entry, scope)).at(-1)?.agentId; +} + +function readCursorSessionAgentCleanupPlanDetails( + entries: readonly SessionEntry[], + branch: readonly SessionEntry[], + scope: CursorSessionAgentCleanupScope, +): CursorSessionAgentCleanupPlan & { candidates: CursorSessionAgentCleanupCandidate[] } { + const unavailable = readUnavailableAgentIds(entries); + const latestBranchAgentId = readLatestBranchAgentId(branch, scope); + const protectedAgentIds = new Set(readResumableCursorSessionAgentIds(entries, scope)); + if (latestBranchAgentId && isCursorLocalAgentId(latestBranchAgentId)) protectedAgentIds.add(latestBranchAgentId); + const candidates = new Map(); + for (const resume of readResumeEntries(entries)) { + if (!resumeEntryMatchesCleanupScope(resume, scope)) continue; + const recordedCandidates: CursorSessionAgentCleanupCandidate[] = [ + ...(resume.cleanupCandidateAgentIds ?? []).map((agentId) => ({ agentId })), + ...(resume.cleanupCandidates ?? []), + ]; + for (const candidate of recordedCandidates) { + if (!isCursorLocalAgentId(candidate.agentId) || protectedAgentIds.has(candidate.agentId) || unavailable.has(candidate.agentId)) continue; + const existing = candidates.get(candidate.agentId); + if (!existing?.storeIdentity || candidate.storeIdentity) candidates.set(candidate.agentId, candidate); + } + } + return { + candidates: [...candidates.values()].sort((left, right) => left.agentId.localeCompare(right.agentId)), + candidateAgentIds: uniqueSorted([...candidates.values()].map((candidate) => candidate.agentId)), + protectedAgentIds: uniqueSorted(protectedAgentIds), + }; } export function readCursorSessionAgentCleanupPlan( @@ -154,22 +199,8 @@ branch: readonly SessionEntry[], scope: CursorSessionAgentCleanupScope, ): CursorSessionAgentCleanupPlan { - const unavailable = readUnavailableAgentIds(entries); - const latestBranchAgentId = readLatestBranchAgentId(branch, scope); - const protectedAgentIds = new Set(readResumableCursorSessionAgentIds(entries, scope)); - if (latestBranchAgentId && isCursorLocalAgentId(latestBranchAgentId)) protectedAgentIds.add(latestBranchAgentId); - const candidates = new Set(); - for (const resume of readResumeEntries(entries)) { - if (!resumeEntryMatchesCleanupScope(resume, scope)) continue; - for (const agentId of resume.cleanupCandidateAgentIds ?? []) { - if (!isCursorLocalAgentId(agentId) || protectedAgentIds.has(agentId) || unavailable.has(agentId)) continue; - candidates.add(agentId); - } - } - return { - candidateAgentIds: uniqueSorted(candidates), - protectedAgentIds: uniqueSorted(protectedAgentIds), - }; + const { candidates: _, ...plan } = readCursorSessionAgentCleanupPlanDetails(entries, branch, scope); + return plan; } function formatCleanupPlan(plan: CursorSessionAgentCleanupPlan): string { @@ -245,7 +276,8 @@ const entries = ctx.sessionManager.getEntries(); const branch = ctx.sessionManager.getBranch(); - const plan = readCursorSessionAgentCleanupPlan(entries, branch, getCurrentCleanupScope(ctx)); + const scope = getCurrentCleanupScope(ctx); + const plan = readCursorSessionAgentCleanupPlanDetails(entries, branch, scope); const baseEntry = { runtime: "local" as const, timestamp: new Date().toISOString(), @@ -274,19 +306,38 @@ const deletedAgentIds: string[] = []; const failedAgentIds: CursorSessionAgentCleanupFailure[] = []; + const openedStores = new Map>>(); try { const operations = await getSdkOperations(); - for (const agentId of plan.candidateAgentIds) { + const identities = await getCursorSessionStoreIdentities(ctx.cwd, scope.scopeKey, scope.sessionFile !== undefined); + for (const candidate of plan.candidates) { + const { agentId } = candidate; try { - await operations.delete(agentId, { cwd: ctx.cwd }); + const identity = candidate.storeIdentity ?? identities.defaultStore; + if ( + !cursorSessionStoreIdentitiesEqual(identity, identities.defaultStore) && + !cursorSessionStoreIdentitiesEqual(identity, identities.sessionStore) + ) throw new InvalidCursorSessionStoreIdentityError("Recorded Cursor local store identity is not valid for this pi session"); + let openedStore = openedStores.get(identity.stateRoot); + if (!openedStore) { + openedStore = await openCursorSessionStore(ctx.cwd, identity); + openedStores.set(identity.stateRoot, openedStore); + } + await operations.delete(agentId, { cwd: ctx.cwd, store: openedStore.store }); deletedAgentIds.push(agentId); } catch (error) { - failedAgentIds.push({ agentId, error: scrubSensitiveText(getString(asRecord(error), "message") ?? String(error)) }); + failedAgentIds.push({ + agentId, + error: scrubSensitiveText(getString(asRecord(error), "message") ?? String(error)), + ...(error instanceof InvalidCursorSessionStoreIdentityError ? { retryable: false } : {}), + }); } } } catch (error) { const message = scrubSensitiveText(getString(asRecord(error), "message") ?? String(error)); failedAgentIds.push(...plan.candidateAgentIds.map((agentId) => ({ agentId, error: message }))); + } finally { + await Promise.all([...openedStores.values()].map((store) => store.dispose().catch(() => undefined))); } if (!appendDurableCleanupEntry(pi, ctx, { action: "delete", diff --git a/src/cursor-session-agent-resume.ts b/src/cursor-session-agent-resume.ts --- a/src/cursor-session-agent-resume.ts +++ b/src/cursor-session-agent-resume.ts @@ -4,10 +4,12 @@ import type { SessionCursorAgentSendState } from "./cursor-session-agent.js"; import { asRecord } from "./cursor-record-utils.js"; import { getCursorSessionScopeKey } from "./cursor-session-scope.js"; +import type { CursorSessionStoreIdentity } from "./cursor-session-store.js"; export const CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE = "cursor-sdk-agent-resume"; -const RESUME_ENTRY_VERSION = 1; +const LEGACY_RESUME_ENTRY_VERSION = 1; +const RESUME_ENTRY_VERSION = 2; const MAX_LOCAL_AGENT_ID_LENGTH = 256; const EMPTY_BRANCH_HASH = hashParts(["cursor-sdk-agent-resume-branch", "v1"]); @@ -24,8 +26,13 @@ repoRoot?: string; } +export interface CursorSessionAgentCleanupCandidate { + agentId: string; + storeIdentity?: CursorSessionStoreIdentity; +} + export interface CursorSessionAgentResumeEntryData { - version: 1; + version: 1 | 2; runtime: "local"; agentId: string; scopeKey: string; @@ -38,7 +45,9 @@ compactionGeneration: number; sendState: SessionCursorAgentSendState; createdAt: string; + storeIdentity?: CursorSessionStoreIdentity; cleanupCandidateAgentIds?: string[]; + cleanupCandidates?: CursorSessionAgentCleanupCandidate[]; } interface PendingCursorSessionAgentResumeHandle { @@ -46,6 +55,7 @@ agentId: string; poolKey: string; sendState: SessionCursorAgentSendState; + storeIdentity: CursorSessionStoreIdentity; } interface CursorSessionResumeState { @@ -109,10 +119,31 @@ typeof record.incrementalSendCount === "number"; } +function parseStoreIdentity(value: unknown): CursorSessionStoreIdentity | undefined { + const record = asRecord(value); + if (record?.version !== 1 || typeof record.stateRoot !== "string" || !record.stateRoot) return undefined; + return { version: 1, stateRoot: record.stateRoot }; +} + +function parseCleanupCandidates(value: unknown): CursorSessionAgentCleanupCandidate[] | undefined { + if (!Array.isArray(value)) return undefined; + const candidates = value.flatMap((item): CursorSessionAgentCleanupCandidate[] => { + const record = asRecord(item); + if (!isCursorLocalAgentId(record?.agentId)) return []; + const storeIdentity = record.storeIdentity === undefined ? undefined : parseStoreIdentity(record.storeIdentity); + if (record.storeIdentity !== undefined && !storeIdentity) return []; + return [{ agentId: record.agentId, ...(storeIdentity ? { storeIdentity } : {}) }]; + }); + return candidates.length ? candidates : undefined; +} + export function parseCursorSessionAgentResumeEntryData(value: unknown): CursorSessionAgentResumeEntryData | undefined { const record = asRecord(value); if (!record) return undefined; - if (record.version !== RESUME_ENTRY_VERSION || record.runtime !== "local") return undefined; + if ( + (record.version !== LEGACY_RESUME_ENTRY_VERSION && record.version !== RESUME_ENTRY_VERSION) || + record.runtime !== "local" + ) return undefined; if ( !isCursorLocalAgentId(record.agentId) || typeof record.scopeKey !== "string" || @@ -126,11 +157,14 @@ if (record.sessionFile !== undefined && typeof record.sessionFile !== "string") return undefined; if (record.sessionId !== undefined && typeof record.sessionId !== "string") return undefined; if (record.repoRoot !== undefined && typeof record.repoRoot !== "string") return undefined; + const storeIdentity = parseStoreIdentity(record.storeIdentity); + if (record.version === RESUME_ENTRY_VERSION && !storeIdentity) return undefined; const cleanupCandidateAgentIds = Array.isArray(record.cleanupCandidateAgentIds) ? record.cleanupCandidateAgentIds.filter(isCursorLocalAgentId) : undefined; + const cleanupCandidates = parseCleanupCandidates(record.cleanupCandidates); return { - version: RESUME_ENTRY_VERSION, + version: record.version, runtime: "local", agentId: record.agentId, scopeKey: record.scopeKey, @@ -147,7 +181,9 @@ incrementalSendCount: record.sendState.incrementalSendCount, }, createdAt: record.createdAt, + ...(storeIdentity ? { storeIdentity } : {}), ...(cleanupCandidateAgentIds?.length ? { cleanupCandidateAgentIds: [...new Set(cleanupCandidateAgentIds)] } : {}), + ...(cleanupCandidates ? { cleanupCandidates } : {}), }; } @@ -330,6 +366,7 @@ agentId: input.agentId, poolKey: input.poolKey, sendState: { ...input.sendState }, + storeIdentity: { ...input.storeIdentity }, }; } @@ -338,8 +375,13 @@ const pending = state.pendingHandle; state.pendingHandle = undefined; if (!pending || !state.appendEntry) return; - const previousAgentId = state.activeHandle?.agentId ?? state.lastBranchHandle?.agentId; - const cleanupCandidateAgentIds = previousAgentId && previousAgentId !== pending.agentId ? [previousAgentId] : undefined; + const previousHandle = state.activeHandle ?? state.lastBranchHandle; + const cleanupCandidates = state.sessionFile && previousHandle && previousHandle.agentId !== pending.agentId + ? [{ + agentId: previousHandle.agentId, + ...(previousHandle.storeIdentity ? { storeIdentity: { ...previousHandle.storeIdentity } } : {}), + }] + : undefined; const data: CursorSessionAgentResumeEntryData = { version: RESUME_ENTRY_VERSION, runtime: pending.runtime, @@ -354,7 +396,8 @@ compactionGeneration: state.compactionGeneration, sendState: { ...pending.sendState }, createdAt: new Date().toISOString(), - ...(cleanupCandidateAgentIds ? { cleanupCandidateAgentIds } : {}), + storeIdentity: { ...pending.storeIdentity }, + ...(cleanupCandidates ? { cleanupCandidates } : {}), }; try { state.appendEntry(CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE, data); diff --git a/src/cursor-session-agent.ts b/src/cursor-session-agent.ts --- a/src/cursor-session-agent.ts +++ b/src/cursor-session-agent.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { AgentModeOption, LocalAgentOptions, ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk"; +import type { AgentModeOption, LocalAgentOptions, LocalAgentStore, ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk"; import type { Context } from "@earendil-works/pi-ai/compat"; import { getRegisteredCursorPiToolBridge, @@ -7,13 +7,21 @@ type CursorPiToolBridgeRun, } from "./cursor-pi-tool-bridge.js"; import { computeCursorContextFingerprint } from "./context.js"; -import { getCursorSessionScopeGeneration, getCursorSessionScopeKey } from "./cursor-session-scope.js"; +import { getCursorSessionFile, getCursorSessionScopeGeneration, getCursorSessionScopeKey } from "./cursor-session-scope.js"; import { getMatchingCursorSessionAgentResumeHandle, persistCursorSessionAgentResumeHandle, } from "./cursor-session-agent-resume.js"; import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js"; import { loadCursorSdk, type CursorSdkModule } from "./cursor-sdk-runtime.js"; +import { + claimCursorTemporarySessionStore, + cursorSessionStoreIdentitiesEqual, + getCursorSessionStoreIdentities, + openCursorSessionStore, + type CursorSessionStoreIdentity, + type OpenCursorSessionStore, +} from "./cursor-session-store.js"; export interface SessionCursorAgentSendState { bootstrapped: boolean; @@ -27,6 +35,8 @@ instanceId: number; agent: SDKAgent; bridgeRun?: CursorPiToolBridgeRun; + store: LocalAgentStore; + storeIdentity: CursorSessionStoreIdentity; sendState: SessionCursorAgentSendState; created: boolean; resumed?: boolean; @@ -52,6 +62,7 @@ status: "ready"; agent: SDKAgent; bridgeRun?: CursorPiToolBridgeRun; + sessionStore: OpenCursorSessionStore; resumeEnabled: boolean; resumed: boolean; resumeNotice?: string; @@ -61,6 +72,7 @@ status: "busy"; agent: SDKAgent; bridgeRun?: CursorPiToolBridgeRun; + sessionStore: OpenCursorSessionStore; resumeEnabled: boolean; resumed: boolean; resumeNotice?: string; @@ -132,6 +144,7 @@ const terminalDisposedScopeGenerations = new Map(); const scopeCreationGenerations = new Map(); const EMPTY_POOL_STATE: SessionCursorAgentPoolState = { status: "empty" }; +const LOCAL_RESUME_FALLBACK_NOTICE = "Could not resume prior Cursor agent; continuing from current pi transcript in a new Cursor agent."; let nextSessionAgentInstanceId = 1; export interface CursorLocalSafetyOptions { @@ -143,9 +156,11 @@ cwd: string; settingSources?: SettingSource[]; localSafety?: CursorLocalSafetyOptions; + store?: LocalAgentStore; }): LocalAgentOptions { return { cwd: options.cwd, + ...(options.store ? { store: options.store } : {}), ...(options.settingSources ? { settingSources: options.settingSources } : {}), ...(options.localSafety?.autoReview === true ? { autoReview: true } : {}), ...(options.localSafety?.sandboxEnabled === true ? { sandboxOptions: { enabled: true } } : {}), @@ -235,6 +250,7 @@ } catch { // disposal failure should not block session replacement } + await entry.sessionStore.dispose().catch(() => undefined); } async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?: boolean }): Promise { @@ -293,6 +309,7 @@ agentId: entry.agent.agentId, poolKey: entry.poolKey, sendState: entry.sendState, + storeIdentity: entry.sessionStore.identity, }); } } @@ -377,6 +394,8 @@ instanceId: entry.instanceId, agent: entry.agent, bridgeRun: entry.bridgeRun, + store: entry.sessionStore.store, + storeIdentity: entry.sessionStore.identity, sendState: entry.sendState, created, resumed: entry.resumed, @@ -419,84 +438,112 @@ async function createSessionAgentEntry( scopeKey: string, + persistentStore: boolean, instanceId: number, sendState: SessionCursorAgentSendState, params: SessionCursorAgentCreateParams, ): Promise { - const registeredBridge = getRegisteredCursorPiToolBridge(); let bridgeRun: CursorPiToolBridgeRun | undefined; - if (registeredBridge) { - bridgeRun = await registeredBridge.createRun({ - onToolRequest: params.onBridgeToolRequest, - debugRecorder: params.debugRecorder, - }); - if (!bridgeRun.enabled || !bridgeRun.mcpServers) { - await bridgeRun.dispose(); - bridgeRun = undefined; - } - } - - const resolvedPoolKey = buildSessionAgentPoolKey(scopeKey, params); - const resumeEligible = params.localResume === true && !params.forceCreate; - let createAgent = params.createAgent; - let resumeAgent = params.resumeAgent; - if (!createAgent || (resumeEligible && !resumeAgent)) { - const sdk = await loadCursorSdk(); - createAgent ??= sdk.Agent.create; - resumeAgent ??= sdk.Agent.resume; - } - const agentOptions = { - apiKey: params.apiKey, - model: params.modelSelection, - mode: params.agentMode, - local: buildCursorLocalAgentOptions({ - cwd: params.cwd, - settingSources: params.settingSources, - localSafety: params.localSafety, - }), - ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}), - }; - let agent: SDKAgent | undefined; - let effectiveSendState = sendState; - let resumed = false; - let resumeNotice: string | undefined; - const resumeHandle = resumeEligible ? getMatchingCursorSessionAgentResumeHandle(resolvedPoolKey) : undefined; - if (resumeHandle && resumeAgent) { - try { - agent = await resumeAgent(resumeHandle.agentId, agentOptions); - effectiveSendState = { ...resumeHandle.sendState }; - resumed = true; - } catch { - resumeNotice = "Could not resume prior Cursor agent; continuing from current pi transcript in a new Cursor agent."; - } - } + let sessionStore: OpenCursorSessionStore | undefined; try { - agent ??= await createAgent(agentOptions); - } catch (error) { - if (bridgeRun) { - bridgeRun.cancel("Cursor session agent create failed"); - try { + const registeredBridge = getRegisteredCursorPiToolBridge(); + if (registeredBridge) { + bridgeRun = await registeredBridge.createRun({ + onToolRequest: params.onBridgeToolRequest, + debugRecorder: params.debugRecorder, + }); + if (!bridgeRun.enabled || !bridgeRun.mcpServers) { await bridgeRun.dispose(); - } catch { - // bridge disposal failure should not mask agent create failure + bridgeRun = undefined; } } + + const resolvedPoolKey = buildSessionAgentPoolKey(scopeKey, params); + const resumeEligible = params.localResume === true && !params.forceCreate; + let createAgent = params.createAgent; + let resumeAgent = params.resumeAgent; + if (!createAgent || (resumeEligible && !resumeAgent)) { + const sdk = await loadCursorSdk(); + createAgent ??= sdk.Agent.create; + resumeAgent ??= sdk.Agent.resume; + } + const identities = await getCursorSessionStoreIdentities(params.cwd, scopeKey, persistentStore); + const openSessionStore = (identity: CursorSessionStoreIdentity) => { + if (!persistentStore) claimCursorTemporarySessionStore(identity); + return openCursorSessionStore(params.cwd, identity, !persistentStore); + }; + const resumeHandle = resumeEligible ? getMatchingCursorSessionAgentResumeHandle(resolvedPoolKey) : undefined; + const recordedStoreIdentity = resumeHandle?.storeIdentity; + const resumableStoreIdentities = persistentStore + ? [identities.defaultStore, identities.sessionStore] + : [identities.sessionStore]; + const resumeStoreIdentity = resumeHandle + ? recordedStoreIdentity === undefined + ? persistentStore ? identities.defaultStore : undefined + : resumableStoreIdentities.find((identity) => + cursorSessionStoreIdentitiesEqual(identity, recordedStoreIdentity), + ) + : undefined; + let resumeAttemptAllowed = resumeHandle !== undefined && resumeStoreIdentity !== undefined; + let resumeNotice = persistentStore && resumeHandle && !resumeStoreIdentity ? LOCAL_RESUME_FALLBACK_NOTICE : undefined; + try { + sessionStore = await openSessionStore(resumeStoreIdentity ?? identities.sessionStore); + } catch (error) { + if (!resumeStoreIdentity || cursorSessionStoreIdentitiesEqual(resumeStoreIdentity, identities.sessionStore)) throw error; + resumeAttemptAllowed = false; + if (persistentStore) resumeNotice = LOCAL_RESUME_FALLBACK_NOTICE; + sessionStore = await openSessionStore(identities.sessionStore); + } + const buildAgentOptions = () => ({ + apiKey: params.apiKey, + model: params.modelSelection, + mode: params.agentMode, + local: buildCursorLocalAgentOptions({ + cwd: params.cwd, + settingSources: params.settingSources, + localSafety: params.localSafety, + store: sessionStore!.store, + }), + ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}), + }); + let agent: SDKAgent | undefined; + let effectiveSendState = sendState; + let resumed = false; + if (resumeHandle && resumeAttemptAllowed && resumeAgent) { + try { + agent = await resumeAgent(resumeHandle.agentId, buildAgentOptions()); + effectiveSendState = { ...resumeHandle.sendState }; + resumed = true; + } catch { + if (persistentStore) resumeNotice = LOCAL_RESUME_FALLBACK_NOTICE; + if (!cursorSessionStoreIdentitiesEqual(sessionStore.identity, identities.sessionStore)) { + await sessionStore.dispose().catch(() => undefined); + sessionStore = await openSessionStore(identities.sessionStore); + } + } + } + agent ??= await createAgent(buildAgentOptions()); + if (!agent) throw new Error("Cursor SDK agent creation returned no agent"); + + return { + status: "ready", + poolKey: resolvedPoolKey, + instanceId, + scopeKey, + agent, + bridgeRun, + sessionStore, + sendState: effectiveSendState, + resumeEnabled: params.localResume === true, + resumed, + ...(resumeNotice ? { resumeNotice } : {}), + }; + } catch (error) { + bridgeRun?.cancel("Cursor session agent create failed"); + await bridgeRun?.dispose().catch(() => undefined); + await sessionStore?.dispose().catch(() => undefined); throw error; } - if (!agent) throw new Error("Cursor SDK agent creation returned no agent"); - - return { - status: "ready", - poolKey: resolvedPoolKey, - instanceId, - scopeKey, - agent, - bridgeRun, - sendState: effectiveSendState, - resumeEnabled: params.localResume === true, - resumed, - ...(resumeNotice ? { resumeNotice } : {}), - }; } export { @@ -516,6 +563,7 @@ export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise { const scopeKey = getCursorSessionScopeKey(); + const persistentStore = getCursorSessionFile() !== undefined; while (true) { assertScopeAcceptsAcquire(scopeKey); @@ -565,7 +613,7 @@ const instanceId = allocateSessionAgentInstanceId(); const sendState = createInitialSendState(); let placeholder: SessionCursorAgentCreatingEntry; - const creating = createSessionAgentEntry(scopeKey, instanceId, sendState, params).then(async (createdEntry) => { + const creating = createSessionAgentEntry(scopeKey, persistentStore, instanceId, sendState, params).then(async (createdEntry) => { const stillCurrent = sessionAgentsByScope.get(scopeKey) === placeholder && getScopeCreationGeneration(scopeKey) === placeholder.creationGeneration; diff --git a/src/cursor-session-store.ts b/src/cursor-session-store.ts new file mode 100644 --- /dev/null +++ b/src/cursor-session-store.ts @@ -0,0 +1,114 @@ +import { createHash, randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, toNamespacedPath } from "node:path"; +import type { LocalAgentStore } from "@cursor/sdk"; +import { loadCursorSdk } from "./cursor-sdk-runtime.js"; + +export interface CursorSessionStoreIdentity { + readonly version: 1; + readonly stateRoot: string; +} + +export interface OpenCursorSessionStore { + identity: CursorSessionStoreIdentity; + store: LocalAgentStore; + dispose(): Promise; +} + +interface CursorSessionStoreSdkOperations { + getDefaultStateRoot(cwd: string): string | Promise; + openSqliteStore(options: { workspaceRef: string; stateRoot: string }): Promise }>; +} + +const removableTemporaryStateRoots = new Map(); +let sdkOperationsForTests: CursorSessionStoreSdkOperations | undefined; + +export function hashCursorSessionStoreScope(scopeKey: string): string { + return createHash("sha256") + .update("pi-cursor-sdk-session-store\0") + .update(scopeKey) + .digest("hex") + .slice(0, 32); +} + +export function buildCursorSessionStateRoot(defaultStateRoot: string, scopeKey: string, persistent: boolean): string { + const baseRoot = persistent ? defaultStateRoot : join(tmpdir(), `pi-cursor-sdk-${randomUUID()}`); + return join(baseRoot, "pi-sessions", hashCursorSessionStoreScope(scopeKey)); +} + +async function getSdkOperations(): Promise { + if (sdkOperationsForTests) return sdkOperationsForTests; + const [{ getDefaultSdkStateRoot }, { SqliteLocalAgentStore }] = await Promise.all([ + loadCursorSdk(), + import("@cursor/sdk/sqlite"), + ]); + return { + getDefaultStateRoot: getDefaultSdkStateRoot, + openSqliteStore: (options) => SqliteLocalAgentStore.open(options), + }; +} + +export async function getCursorSessionStoreIdentities( + cwd: string, + scopeKey: string, + persistent: boolean, +): Promise<{ defaultStore: CursorSessionStoreIdentity; sessionStore: CursorSessionStoreIdentity }> { + const defaultStateRoot = await (await getSdkOperations()).getDefaultStateRoot(cwd); + return { + defaultStore: { version: 1, stateRoot: defaultStateRoot }, + sessionStore: { + version: 1, + stateRoot: buildCursorSessionStateRoot(defaultStateRoot, scopeKey, persistent), + }, + }; +} + +export function cursorSessionStoreIdentitiesEqual( + left: CursorSessionStoreIdentity, + right: CursorSessionStoreIdentity, +): boolean { + return left.version === right.version && left.stateRoot === right.stateRoot; +} + +export function claimCursorTemporarySessionStore(identity: CursorSessionStoreIdentity): void { + removableTemporaryStateRoots.set(identity.stateRoot, dirname(dirname(identity.stateRoot))); +} + +export async function openCursorSessionStore( + cwd: string, + identity: CursorSessionStoreIdentity, + removeOnDispose = false, +): Promise { + const openedIdentity = Object.freeze({ ...identity }); + const stateRoot = openedIdentity.stateRoot; + const removalRoot = removeOnDispose ? removableTemporaryStateRoots.get(stateRoot) : undefined; + if (removeOnDispose && !removalRoot) { + throw new Error("Refusing to remove a Cursor SDK store without temporary-store ownership"); + } + if (removeOnDispose) removableTemporaryStateRoots.delete(stateRoot); + let store: LocalAgentStore & { dispose(): Promise }; + try { + store = await (await getSdkOperations()).openSqliteStore({ workspaceRef: cwd, stateRoot: toNamespacedPath(stateRoot) }); + } catch (error) { + if (removalRoot) await rm(removalRoot, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + return { + identity: openedIdentity, + store, + dispose: async () => { + try { + await store.dispose(); + } finally { + if (removalRoot) await rm(removalRoot, { recursive: true, force: true }); + } + }, + }; +} + +export const __testUtils = { + setSdkOperations(operations: CursorSessionStoreSdkOperations | undefined): void { + sdkOperationsForTests = operations; + }, +}; diff --git a/test/cursor-provider-bridge-mcp.test.ts b/test/cursor-provider-bridge-mcp.test.ts --- a/test/cursor-provider-bridge-mcp.test.ts +++ b/test/cursor-provider-bridge-mcp.test.ts @@ -222,7 +222,11 @@ await collectEvents(streamCursor(makeModel("composer-2"), makeContext(), { apiKey: "test-key" })); const createOptions = getCreatedAgentOptions(); - expect(createOptions.local).toEqual({ cwd: process.cwd(), settingSources: ["all"] }); + expect(createOptions.local).toMatchObject({ + cwd: process.cwd(), + settingSources: ["all"], + store: expect.any(Object), + }); expect(createOptions.mcpServers?.pi_tools?.type).toBe("http"); const url = new URL(getPiToolsMcpUrlFromAgentCreateOptions(createOptions)); expect(url.hostname).toBe("127.0.0.1"); diff --git a/test/cursor-provider-bridge-settings.test.ts b/test/cursor-provider-bridge-settings.test.ts --- a/test/cursor-provider-bridge-settings.test.ts +++ b/test/cursor-provider-bridge-settings.test.ts @@ -68,7 +68,7 @@ expect(mockedCreate).toHaveBeenCalledWith( expect.objectContaining({ - local: { cwd: process.cwd(), settingSources: ["all"] }, + local: expect.objectContaining({ cwd: process.cwd(), settingSources: ["all"], store: expect.any(Object) }), }), ); }); @@ -94,7 +94,7 @@ expect(mockedCreate).toHaveBeenCalledWith( expect.objectContaining({ - local: { cwd: process.cwd() }, + local: expect.objectContaining({ cwd: process.cwd(), store: expect.any(Object) }), }), ); }); @@ -120,7 +120,7 @@ expect(mockedCreate).toHaveBeenCalledWith( expect.objectContaining({ - local: { cwd: process.cwd(), settingSources: ["all"] }, + local: expect.objectContaining({ cwd: process.cwd(), settingSources: ["all"], store: expect.any(Object) }), }), ); }); @@ -242,7 +242,7 @@ expect(mockedCreate).toHaveBeenCalledWith( expect.objectContaining({ - local: { cwd: process.cwd(), settingSources: ["project", "user"] }, + local: expect.objectContaining({ cwd: process.cwd(), settingSources: ["project", "user"], store: expect.any(Object) }), }), ); }); diff --git a/test/cursor-provider-http1.test.ts b/test/cursor-provider-http1.test.ts --- a/test/cursor-provider-http1.test.ts +++ b/test/cursor-provider-http1.test.ts @@ -55,9 +55,10 @@ await collectEvents(streamCursor(makeModel("gpt-5.5@1m"), makeContext(), { apiKey: "test-key" })); expect(mockedConfigureCursor).not.toHaveBeenCalled(); - expect(mockedCreate.mock.calls[0][0].local).toEqual({ + expect(mockedCreate.mock.calls[0][0].local).toMatchObject({ cwd: process.cwd(), settingSources: ["all"], + store: expect.any(Object), }); }); diff --git a/test/cursor-provider-run-finalizer.test.ts b/test/cursor-provider-run-finalizer.test.ts --- a/test/cursor-provider-run-finalizer.test.ts +++ b/test/cursor-provider-run-finalizer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat"; -import type { SDKAgent } from "@cursor/sdk"; +import type { LocalAgentStore, SDKAgent } from "@cursor/sdk"; import { buildIncompleteCursorToolRunOutcome } from "../src/cursor-incomplete-tool-visibility.js"; import { CursorRunFinalizer } from "../src/cursor-provider-run-finalizer.js"; import { CursorSdkTurnCoordinator } from "../src/cursor-provider-turn-coordinator.js"; @@ -51,6 +51,8 @@ poolKey: "pool-1", instanceId: 1, agent: { agentId: "agent-1" } as SDKAgent, + store: {} as LocalAgentStore, + storeIdentity: { version: 1, stateRoot: "/tmp/store" }, sendState: { bootstrapped: false, contextFingerprint: "", incrementalSendCount: 0 }, created: false, commitSend: () => {}, @@ -172,6 +174,8 @@ poolKey: "pool-1", instanceId: 1, agent: { agentId: "agent-1" } as SDKAgent, + store: {} as LocalAgentStore, + storeIdentity: { version: 1, stateRoot: "/tmp/store" }, sendState: { bootstrapped: false, contextFingerprint: "", incrementalSendCount: 0 }, created: true, commitSend: () => { @@ -278,6 +282,8 @@ poolKey: "pool-1", instanceId: 1, agent: { agentId: "agent-1" } as SDKAgent, + store: {} as LocalAgentStore, + storeIdentity: { version: 1, stateRoot: "/tmp/store" }, sendState: { bootstrapped: false, contextFingerprint: "", incrementalSendCount: 0 }, created: true, commitSend: () => {}, diff --git a/test/cursor-provider-session-store.test.ts b/test/cursor-provider-session-store.test.ts new file mode 100644 --- /dev/null +++ b/test/cursor-provider-session-store.test.ts @@ -0,0 +1,48 @@ +import { toNamespacedPath } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamCursor } from "../src/cursor-provider.js"; +import { __testUtils as cursorSessionScopeTestUtils } from "../src/cursor-session-scope.js"; +import { buildCursorSessionStateRoot } from "../src/cursor-session-store.js"; +import { + collectEvents, + makeContext, + makeModel, + mockCreatedAgent, + mockedCreate, + mockedCreateAgentPlatform, + mockedMessagesList, + resetCursorProviderTestState, +} from "./helpers/cursor-provider-harness.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; + +describe("streamCursor session store", () => { + beforeEach(resetCursorProviderTestState); + + it("threads one per-session store through create, message reads, and checkpoint lookup", async () => { + const storeMock = installCursorSessionStoreMock(); + const scopeKey = "/tmp/provider-store-session.jsonl"; + cursorSessionScopeTestUtils.set(process.cwd(), scopeKey); + mockCreatedAgent({ + send: vi.fn().mockResolvedValue({ + id: "run-store", + agentId: "agent-1", + status: "finished", + wait: vi.fn().mockResolvedValue({ id: "run-store", status: "finished" }), + cancel: vi.fn(), + supports: () => true, + unsupportedReason: () => undefined, + }), + }); + + await collectEvents(streamCursor(makeModel("gpt-5.5@1m"), makeContext(), { apiKey: "test-key" })); + + const store = storeMock.stores[0]; + expect(storeMock.openSqliteStore).toHaveBeenCalledWith({ + workspaceRef: process.cwd(), + stateRoot: toNamespacedPath(buildCursorSessionStateRoot("/tmp/cursor-sdk-state", scopeKey, true)), + }); + expect(mockedCreate.mock.calls[0][0].local?.store).toBe(store); + expect(mockedMessagesList).toHaveBeenCalledWith("agent-1", expect.objectContaining({ store })); + expect(mockedCreateAgentPlatform).toHaveBeenCalledWith(expect.objectContaining({ localStore: store })); + }); +}); diff --git a/test/cursor-provider-stream-config.test.ts b/test/cursor-provider-stream-config.test.ts --- a/test/cursor-provider-stream-config.test.ts +++ b/test/cursor-provider-stream-config.test.ts @@ -55,7 +55,11 @@ await collectEvents(streamCursor(makeModel("gpt-5.5@1m"), makeContext(), { apiKey: "test-key" })); - expect(mockedCreate.mock.calls[0][0].local).toEqual({ cwd: process.cwd(), settingSources: ["all"] }); + expect(mockedCreate.mock.calls[0][0].local).toMatchObject({ + cwd: process.cwd(), + settingSources: ["all"], + store: expect.any(Object), + }); }); it("sets absolute CURSOR_RIPGREP_PATH before local Agent.create", async () => { diff --git a/test/cursor-session-agent-cleanup.test.ts b/test/cursor-session-agent-cleanup.test.ts --- a/test/cursor-session-agent-cleanup.test.ts +++ b/test/cursor-session-agent-cleanup.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, toNamespacedPath } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent"; import { @@ -17,6 +17,8 @@ } from "../src/cursor-session-agent-resume.js"; import { makeAssistantMessage } from "./helpers/pi-harness.js"; import { __testUtils as scopeTestUtils } from "../src/cursor-session-scope.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; +import { buildCursorSessionStateRoot } from "../src/cursor-session-store.js"; function resumeData(agentId: string, extra: Partial = {}): CursorSessionAgentResumeEntryData { return { @@ -100,6 +102,7 @@ describe("cursor-session-agent-cleanup", () => { beforeEach(() => { + installCursorSessionStoreMock(); cleanupTestUtils.reset(); cleanupTestUtils.setAppendDurability(() => true); scopeTestUtils.set("/tmp/project", "/tmp/session.jsonl", "session-1"); @@ -205,7 +208,7 @@ it("reconciles durable intents, successful results, failed results, and legacy delete entries", () => { const oldEntry = resumeEntry("r1", resumeData("agent-old")); const activeEntry = resumeEntry("r2", resumeData("agent-active", { - cleanupCandidateAgentIds: ["agent-old", "agent-pending", "agent-deleted", "agent-failed"], + cleanupCandidateAgentIds: ["agent-old", "agent-pending", "agent-deleted", "agent-failed", "agent-invalid"], })); const legacyDeleted = cleanupEntry("c1", { action: "delete", @@ -219,16 +222,19 @@ phase: "intent", runtime: "local", timestamp: "2026-07-08T00:02:00.000Z", - candidateAgentIds: ["agent-pending", "agent-deleted", "agent-failed"], + candidateAgentIds: ["agent-pending", "agent-deleted", "agent-failed", "agent-invalid"], }); const result = cleanupEntry("c3", { action: "delete", phase: "result", runtime: "local", timestamp: "2026-07-08T00:03:00.000Z", - candidateAgentIds: ["agent-deleted", "agent-failed"], + candidateAgentIds: ["agent-deleted", "agent-failed", "agent-invalid"], deletedAgentIds: ["agent-deleted"], - failedAgentIds: [{ agentId: "agent-failed", error: "failed" }], + failedAgentIds: [ + { agentId: "agent-failed", error: "failed" }, + { agentId: "agent-invalid", error: "invalid store", retryable: false }, + ], }); const entries = linearEntries([oldEntry, activeEntry, legacyDeleted, intent, result]); @@ -290,7 +296,10 @@ await runCursorSessionAgentCleanupCommand({ appendEntry }, "--yes", makeContext(entries)); expect(callOrder).toEqual(["append:intent", "delete:agent-old", "append:result"]); - expect(deleteAgent).toHaveBeenCalledWith("agent-old", { cwd: "/tmp/project" }); + expect(deleteAgent).toHaveBeenCalledWith("agent-old", { + cwd: "/tmp/project", + store: expect.any(Object), + }); expect(appendEntry).toHaveBeenNthCalledWith(1, CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE, expect.objectContaining({ action: "delete", phase: "intent", @@ -302,6 +311,57 @@ candidateAgentIds: ["agent-old"], deletedAgentIds: ["agent-old"], })); + }); + + it("deletes a versioned cleanup candidate from its recorded per-session store", async () => { + const storeMock = installCursorSessionStoreMock(); + const stateRoot = buildCursorSessionStateRoot("/tmp/cursor-sdk-state", cleanupScope.scopeKey, true); + const storeIdentity = { version: 1 as const, stateRoot }; + const entries = linearEntries([ + resumeEntry("r1", resumeData("agent-old", { version: 2, storeIdentity })), + resumeEntry("r2", resumeData("agent-active", { + version: 2, + storeIdentity, + cleanupCandidates: [{ agentId: "agent-old", storeIdentity }], + })), + ]); + const deleteAgent = vi.fn().mockResolvedValue(undefined); + cleanupTestUtils.setSdkOperations({ delete: deleteAgent }); + + await runCursorSessionAgentCleanupCommand({ appendEntry: vi.fn() }, "--yes", makeContext(entries)); + + expect(storeMock.openSqliteStore).toHaveBeenCalledWith({ workspaceRef: "/tmp/project", stateRoot: toNamespacedPath(stateRoot) }); + expect(deleteAgent).toHaveBeenCalledWith("agent-old", { + cwd: "/tmp/project", + store: storeMock.stores[0], + }); + expect(storeMock.stores[0].dispose).toHaveBeenCalledTimes(1); + }); + + it("rejects a recorded store root outside the current session identities before SDK delete", async () => { + const entries = linearEntries([ + resumeEntry("r1", resumeData("agent-old")), + resumeEntry("r2", resumeData("agent-active", { + version: 2, + storeIdentity: { version: 1, stateRoot: buildCursorSessionStateRoot("/tmp/cursor-sdk-state", cleanupScope.scopeKey, true) }, + cleanupCandidates: [{ + agentId: "agent-old", + storeIdentity: { version: 1, stateRoot: "/tmp/untrusted-store" }, + }], + })), + ]); + const deleteAgent = vi.fn(); + const appendEntry = vi.fn(); + const ctx = makeContext(entries); + cleanupTestUtils.setSdkOperations({ delete: deleteAgent }); + + await runCursorSessionAgentCleanupCommand({ appendEntry }, "--yes", ctx); + + expect(deleteAgent).not.toHaveBeenCalled(); + expect(appendEntry).toHaveBeenLastCalledWith(CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE, expect.objectContaining({ + failedAgentIds: [expect.objectContaining({ agentId: "agent-old", retryable: false })], + })); + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("1 failed"), "error"); }); it("persists and fsyncs real SessionManager intent before delete and result afterward", async () => { @@ -448,7 +508,10 @@ await runCursorSessionAgentCleanupCommand({ appendEntry }, "--yes", ctx); - expect(deleteAgent).toHaveBeenCalledWith("agent-old", { cwd: "/tmp/project" }); + expect(deleteAgent).toHaveBeenCalledWith("agent-old", { + cwd: "/tmp/project", + store: expect.any(Object), + }); expect(readCursorSessionAgentCleanupPlan(entries, entries.slice(0, 2), cleanupScope).candidateAgentIds).toEqual([]); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("durable intent blocks automatic retries"), "error"); }); diff --git a/test/cursor-session-agent-dead-transport.test.ts b/test/cursor-session-agent-dead-transport.test.ts --- a/test/cursor-session-agent-dead-transport.test.ts +++ b/test/cursor-session-agent-dead-transport.test.ts @@ -7,9 +7,11 @@ } from "../src/cursor-session-agent.js"; import { __testUtils as cursorSessionScopeTestUtils } from "../src/cursor-session-scope.js"; import { makeNodeClosedPipeWriteError } from "./helpers/cursor-sdk-process-error-fixtures.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; describe("cursor-session-agent dead transport", () => { beforeEach(async () => { + installCursorSessionStoreMock(); cursorSessionScopeTestUtils.reset(); resumeTestUtils.reset(); await sessionAgentTestUtils.disposeAllSessionCursorAgents(); diff --git a/test/cursor-session-agent-http1.test.ts b/test/cursor-session-agent-http1.test.ts --- a/test/cursor-session-agent-http1.test.ts +++ b/test/cursor-session-agent-http1.test.ts @@ -12,9 +12,11 @@ } from "../src/cursor-http1.js"; import { registerCursorSessionAgentLifecycle } from "../src/cursor-session-agent-lifecycle.js"; import { createEventHarness } from "./helpers/pi-harness.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; describe("Cursor session agent HTTP/1.1 pooling", () => { beforeEach(async () => { + installCursorSessionStoreMock(); cursorSessionScopeTestUtils.reset(); resumeTestUtils.reset(); await sessionAgentTestUtils.disposeAllSessionCursorAgents(); diff --git a/test/cursor-session-agent-local-resume.test.ts b/test/cursor-session-agent-local-resume.test.ts --- a/test/cursor-session-agent-local-resume.test.ts +++ b/test/cursor-session-agent-local-resume.test.ts @@ -1,3 +1,4 @@ +import { toNamespacedPath } from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { computeCursorContextFingerprint } from "../src/context.js"; import { __testUtils as cursorSessionScopeTestUtils } from "../src/cursor-session-scope.js"; @@ -7,17 +8,22 @@ __testUtils as sessionAgentTestUtils, } from "../src/cursor-session-agent.js"; import { makeContext } from "./helpers/pi-harness.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; +import { buildCursorSessionStateRoot } from "../src/cursor-session-store.js"; describe("cursor-session-agent local resume", () => { beforeEach(async () => { + installCursorSessionStoreMock(); cursorSessionScopeTestUtils.reset(); resumeTestUtils.reset(); await sessionAgentTestUtils.disposeAllSessionCursorAgents(); vi.clearAllMocks(); }); - it("resumes a recorded local SDK agent when branch identity and pool key match", async () => { + it("resumes a recorded local SDK agent from its versioned session store", async () => { + const storeMock = installCursorSessionStoreMock(); const scopeKey = "/tmp/sessions/test.jsonl"; + const stateRoot = buildCursorSessionStateRoot("/tmp/cursor-sdk-state", scopeKey, true); const sendState = { bootstrapped: true, contextFingerprint: computeCursorContextFingerprint(makeContext()), @@ -45,7 +51,7 @@ branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, compactionGeneration: 0, activeHandle: { - version: 1, + version: 2, runtime: "local", agentId: "agent-recorded", scopeKey, @@ -56,6 +62,7 @@ compactionGeneration: 0, sendState, createdAt: "2026-07-07T00:00:00.000Z", + storeIdentity: { version: 1, stateRoot }, }, }); @@ -65,19 +72,21 @@ expect(lease.resumed).toBe(true); expect(lease.agent).toBe(resumedAgent); expect(lease.sendState).toEqual(sendState); + expect(storeMock.openSqliteStore).toHaveBeenCalledWith({ workspaceRef: "/tmp/project", stateRoot: toNamespacedPath(stateRoot) }); expect(resumeAgent).toHaveBeenCalledWith( "agent-recorded", expect.objectContaining({ apiKey: "test-key", model: { id: "composer-2.5" }, mode: "agent", - local: expect.objectContaining({ cwd: "/tmp/project" }), + local: expect.objectContaining({ cwd: "/tmp/project", store: storeMock.stores[0] }), }), ); expect(createAgent).not.toHaveBeenCalled(); }); - it("force-creates once while keeping replacement resume persistence enabled", async () => { + it("resumes a legacy default-store agent before force-creating its session-store replacement", async () => { + const storeMock = installCursorSessionStoreMock(); const scopeKey = "/tmp/sessions/test.jsonl"; const context = makeContext([{ role: "user", content: "Replacement", timestamp: 1 }]); const createAgent = vi.fn().mockResolvedValue({ agentId: "agent-new", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); @@ -113,11 +122,24 @@ }, }); + const legacyLease = await acquireSessionCursorAgent(params); + expect(legacyLease.resumed).toBe(true); + expect(legacyLease.storeIdentity).toEqual({ version: 1, stateRoot: "/tmp/cursor-sdk-state" }); + expect(resumeAgent.mock.calls[0][1]?.local?.store).toBe(storeMock.stores[0]); + + sessionAgentTestUtils.invalidateSessionAgent(scopeKey); const lease = await acquireSessionCursorAgent({ ...params, forceCreate: true }); lease.commitSend(context, true); - expect(resumeAgent).not.toHaveBeenCalled(); expect(createAgent).toHaveBeenCalledTimes(1); + expect(createAgent.mock.calls[0][0].local?.store).toBe(storeMock.stores[1]); + expect(storeMock.openedOptions).toEqual([ + { workspaceRef: "/tmp/project", stateRoot: toNamespacedPath("/tmp/cursor-sdk-state") }, + { + workspaceRef: "/tmp/project", + stateRoot: toNamespacedPath(buildCursorSessionStateRoot("/tmp/cursor-sdk-state", scopeKey, true)), + }, + ]); expect(lease.resumed).toBe(false); expect(lease.sendState).toMatchObject({ bootstrapped: true, incrementalSendCount: 0 }); expect(resumeTestUtils.state.pendingHandle).toMatchObject({ @@ -168,10 +190,120 @@ expect(createAgent).toHaveBeenCalledTimes(1); }); - it("falls back to create and bootstrap when Agent.resume fails", async () => { + it.each(["store open", "Agent.resume"] as const)( + "falls back from a legacy default store to the per-session store when %s fails", + async (failure) => { + const storeMock = installCursorSessionStoreMock(); + if (failure === "store open") storeMock.openSqliteStore.mockRejectedValueOnce(new Error("legacy index.db is locked")); + const scopeKey = "/tmp/sessions/test.jsonl"; + const createAgent = vi.fn().mockResolvedValue({ agentId: "agent-new", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); + const resumeAgent = vi.fn().mockRejectedValue(new Error("Agent agent-recorded not found")); + cursorSessionScopeTestUtils.set("/tmp/project", scopeKey); + const params = { + apiKey: "test-key", + agentMode: "agent" as const, + cwd: "/tmp/project", + modelSelection: { id: "composer-2.5" }, + localResume: true, + createAgent, + resumeAgent, + }; + resumeTestUtils.set({ + scopeKey, + sessionFile: scopeKey, + cwd: "/tmp/project", + branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, + compactionGeneration: 0, + activeHandle: { + version: 1, + runtime: "local", + agentId: "agent-recorded", + scopeKey, + sessionFile: scopeKey, + cwd: "/tmp/project", + poolKey: sessionAgentTestUtils.buildSessionAgentPoolKey(scopeKey, params), + branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, + compactionGeneration: 0, + sendState: { bootstrapped: true, contextFingerprint: computeCursorContextFingerprint(makeContext()), incrementalSendCount: 0 }, + createdAt: "2026-07-07T00:00:00.000Z", + }, + }); + + const lease = await acquireSessionCursorAgent(params); + + expect(storeMock.openSqliteStore).toHaveBeenNthCalledWith(1, { + workspaceRef: "/tmp/project", + stateRoot: toNamespacedPath("/tmp/cursor-sdk-state"), + }); + expect(storeMock.openSqliteStore).toHaveBeenNthCalledWith(2, { + workspaceRef: "/tmp/project", + stateRoot: toNamespacedPath(buildCursorSessionStateRoot("/tmp/cursor-sdk-state", scopeKey, true)), + }); + if (failure === "Agent.resume") { + expect(resumeAgent.mock.calls[0][1]?.local?.store).toBe(storeMock.stores[0]); + } else { + expect(resumeAgent).not.toHaveBeenCalled(); + } + const createdStore = storeMock.stores[failure === "Agent.resume" ? 1 : 0]; + expect(createAgent.mock.calls[0][0].local?.store).toBe(createdStore); + expect(lease.store).toBe(createdStore); + expect(lease.resumed).toBe(false); + expect(lease.resumeNotice).toContain("Could not resume prior Cursor agent"); + expect(lease.sendState.bootstrapped).toBe(false); + }, + ); + + it("never opens a legacy shared store with fileless removal ownership", async () => { + const storeMock = installCursorSessionStoreMock(); + const sessionId = "ephemeral"; + const scopeKey = `${cursorSessionScopeTestUtils.EPHEMERAL_SESSION_SCOPE_PREFIX}${sessionId}`; + const createAgent = vi.fn().mockResolvedValue({ agentId: "agent-new", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); + const resumeAgent = vi.fn(); + cursorSessionScopeTestUtils.set("/tmp/project", undefined, sessionId); + const params = { + apiKey: "test-key", + agentMode: "agent" as const, + cwd: "/tmp/project", + modelSelection: { id: "composer-2.5" }, + localResume: true, + createAgent, + resumeAgent, + }; + resumeTestUtils.set({ + scopeKey, + sessionId, + cwd: "/tmp/project", + branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, + compactionGeneration: 0, + activeHandle: { + version: 1, + runtime: "local", + agentId: "agent-recorded", + scopeKey, + sessionId, + cwd: "/tmp/project", + poolKey: sessionAgentTestUtils.buildSessionAgentPoolKey(scopeKey, params), + branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, + compactionGeneration: 0, + sendState: { bootstrapped: true, contextFingerprint: "old", incrementalSendCount: 1 }, + createdAt: "2026-07-07T00:00:00.000Z", + }, + }); + + const lease = await acquireSessionCursorAgent(params); + + expect(storeMock.openSqliteStore).toHaveBeenCalledTimes(1); + expect(storeMock.openedOptions[0].stateRoot).toContain("pi-sessions"); + expect(storeMock.openedOptions[0].stateRoot).not.toBe(toNamespacedPath("/tmp/cursor-sdk-state")); + expect(resumeAgent).not.toHaveBeenCalled(); + expect(createAgent.mock.calls[0][0].local?.store).toBe(storeMock.stores[0]); + expect(lease.resumeNotice).toBeUndefined(); + }); + + it("creates in the current session store and reports continuity when a recorded store identity is stale", async () => { const scopeKey = "/tmp/sessions/test.jsonl"; const createAgent = vi.fn().mockResolvedValue({ agentId: "agent-new", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); - const resumeAgent = vi.fn().mockRejectedValue(new Error("Agent agent-recorded not found")); + const resumeAgent = vi.fn().mockResolvedValue({ agentId: "agent-recorded", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); cursorSessionScopeTestUtils.set("/tmp/project", scopeKey); const params = { apiKey: "test-key", @@ -189,7 +321,7 @@ branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, compactionGeneration: 0, activeHandle: { - version: 1, + version: 2, runtime: "local", agentId: "agent-recorded", scopeKey, @@ -200,56 +332,14 @@ compactionGeneration: 0, sendState: { bootstrapped: true, contextFingerprint: computeCursorContextFingerprint(makeContext()), incrementalSendCount: 0 }, createdAt: "2026-07-07T00:00:00.000Z", + storeIdentity: { version: 1, stateRoot: "/tmp/stale-sdk-root" }, }, }); const lease = await acquireSessionCursorAgent(params); - expect(resumeAgent).toHaveBeenCalledTimes(1); - expect(createAgent).toHaveBeenCalledTimes(1); expect(lease.resumed).toBe(false); expect(lease.resumeNotice).toContain("Could not resume prior Cursor agent"); - expect(lease.sendState.bootstrapped).toBe(false); - }); - - it("does not resume when the recorded identity no longer matches", async () => { - const scopeKey = "/tmp/sessions/test.jsonl"; - const createAgent = vi.fn().mockResolvedValue({ agentId: "agent-new", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); - const resumeAgent = vi.fn().mockResolvedValue({ agentId: "agent-recorded", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); - cursorSessionScopeTestUtils.set("/tmp/project", scopeKey); - const params = { - apiKey: "new-key", - agentMode: "agent" as const, - cwd: "/tmp/project", - modelSelection: { id: "composer-2.5" }, - localResume: true, - createAgent, - resumeAgent, - }; - resumeTestUtils.set({ - scopeKey, - sessionFile: scopeKey, - cwd: "/tmp/project", - branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, - compactionGeneration: 1, - activeHandle: { - version: 1, - runtime: "local", - agentId: "agent-recorded", - scopeKey, - sessionFile: scopeKey, - cwd: "/tmp/project", - poolKey: "old-pool-key", - branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, - compactionGeneration: 0, - sendState: { bootstrapped: true, contextFingerprint: computeCursorContextFingerprint(makeContext()), incrementalSendCount: 0 }, - createdAt: "2026-07-07T00:00:00.000Z", - }, - }); - - const lease = await acquireSessionCursorAgent(params); - - expect(lease.resumed).toBe(false); expect(resumeAgent).not.toHaveBeenCalled(); expect(createAgent).toHaveBeenCalledTimes(1); }); diff --git a/test/cursor-session-agent-resume.test.ts b/test/cursor-session-agent-resume.test.ts --- a/test/cursor-session-agent-resume.test.ts +++ b/test/cursor-session-agent-resume.test.ts @@ -61,6 +61,16 @@ }; expect(parseCursorSessionAgentResumeEntryData(valid)?.agentId).toBe("agent-local-1"); + const current = { + ...valid, + version: 2, + storeIdentity: { version: 1, stateRoot: "/tmp/session-store" }, + }; + expect(parseCursorSessionAgentResumeEntryData(current)).toMatchObject({ + version: 2, + storeIdentity: { version: 1, stateRoot: "/tmp/session-store" }, + }); + expect(parseCursorSessionAgentResumeEntryData({ ...current, storeIdentity: undefined })).toBeUndefined(); expect(parseCursorSessionAgentResumeEntryData({ ...valid, agentId: `agent-${"a".repeat(250)}` })?.agentId).toHaveLength(256); for (const agentId of [ "bc-cloud-1", @@ -201,7 +211,7 @@ } finally { rmSync(tempDir, { recursive: true, force: true }); } - }); + }, process.platform === "win32" ? 20_000 : 5_000); it("defers resume handle persistence until turn end so it records the completed assistant path", async () => { const pi = createPiHarness(); @@ -233,6 +243,7 @@ agentId: "agent-1", poolKey: "pool-1", sendState: { bootstrapped: true, contextFingerprint: "fp", incrementalSendCount: 0 }, + storeIdentity: { version: 1, stateRoot: "/tmp/store" }, }); expect(pi.appendEntry).not.toHaveBeenCalled(); @@ -291,7 +302,7 @@ expect(getMatchingCursorSessionAgentResumeHandle("pool-1")).toBeUndefined(); }); - it("uses append order to reject a superseded handle when the newer handle has an earlier timestamp", async () => { + it("uses append order to supersede a legacy handle with its versioned successor", async () => { const pi = createPiHarness(); registerCursorSessionScope(pi); registerCursorSessionAgentResume(pi); @@ -321,9 +332,11 @@ const futureHash = resumeTestUtils.hashBranchStep(resumeTestUtils.hashBranchStep(baseHash, futureUser), futureAssistant); const newerHandle: CursorSessionAgentResumeEntryData = { ...oldHandle, + version: 2, branchPathHash: futureHash, sendState: { bootstrapped: true, contextFingerprint: "fp-new", incrementalSendCount: 1 }, createdAt: "2026-07-07T00:00:00.000Z", + storeIdentity: { version: 1, stateRoot: "/tmp/cursor-sdk-state" }, }; const newerResume = resumeEntry("r2", "a2", newerHandle); const treeUser = messageEntry("u3", "r1"); @@ -436,13 +449,16 @@ agentId: "agent-new", poolKey: "pool-new", sendState: { bootstrapped: true, contextFingerprint: "fp-new", incrementalSendCount: 0 }, + storeIdentity: { version: 1, stateRoot: "/tmp/store-new" }, }); await pi.runTurnEnd({}, { sessionManager: { getBranch: vi.fn(() => branch) } }); expect(pi.appendEntry).toHaveBeenCalledWith(CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE, expect.objectContaining({ + version: 2, agentId: "agent-new", - cleanupCandidateAgentIds: ["agent-old"], + storeIdentity: { version: 1, stateRoot: "/tmp/store-new" }, + cleanupCandidates: [{ agentId: "agent-old" }], })); }); diff --git a/test/cursor-session-agent.test.ts b/test/cursor-session-agent.test.ts --- a/test/cursor-session-agent.test.ts +++ b/test/cursor-session-agent.test.ts @@ -1,3 +1,4 @@ +import { toNamespacedPath } from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { computeCursorContextFingerprint, shouldBootstrapCursorContext } from "../src/context.js"; import { createEventHarness, createExtensionTestContext, makeContext } from "./helpers/pi-harness.js"; @@ -8,9 +9,12 @@ __testUtils as sessionAgentTestUtils, } from "../src/cursor-session-agent.js"; import { registerCursorSessionAgentLifecycle } from "../src/cursor-session-agent-lifecycle.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; +import { buildCursorSessionStateRoot } from "../src/cursor-session-store.js"; describe("cursor-session-agent", () => { beforeEach(async () => { + installCursorSessionStoreMock(); cursorSessionScopeTestUtils.reset(); resumeTestUtils.reset(); await sessionAgentTestUtils.disposeAllSessionCursorAgents(); @@ -42,6 +46,34 @@ expect(createAgent).toHaveBeenCalledTimes(1); expect(createAgent).toHaveBeenCalledWith(expect.objectContaining({ mode: "agent" })); expect(mockDispose).not.toHaveBeenCalled(); + }); + + it("passes one session-scoped store through Agent.create and disposes it with the pooled agent", async () => { + const storeMock = installCursorSessionStoreMock(); + const scopeKey = "/tmp/sessions/store-session.jsonl"; + const createAgent = vi.fn().mockResolvedValue({ + agentId: "agent-store", + [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), + }); + cursorSessionScopeTestUtils.set("/tmp/project", scopeKey); + + const lease = await acquireSessionCursorAgent({ + apiKey: "test-key", + agentMode: "agent", + cwd: "/tmp/project", + modelSelection: { id: "composer-2.5" }, + createAgent, + }); + + expect(storeMock.openSqliteStore).toHaveBeenCalledWith({ + workspaceRef: "/tmp/project", + stateRoot: toNamespacedPath(buildCursorSessionStateRoot("/tmp/cursor-sdk-state", scopeKey, true)), + }); + expect(createAgent.mock.calls[0][0].local?.store).toBe(storeMock.stores[0]); + expect(lease.store).toBe(storeMock.stores[0]); + + await sessionAgentTestUtils.resetSessionCursorAgent(scopeKey); + expect(storeMock.stores[0].dispose).toHaveBeenCalledTimes(1); }); it("passes the desired Cursor SDK mode to Agent.create", async () => { @@ -413,7 +445,8 @@ await expect(acquireSessionCursorAgent(params)).rejects.toBeInstanceOf(sessionAgentTestUtils.SessionCursorAgentScopeClosedError); }); - it("does not retry a superseded in-flight acquire when replaced by a different pool key", async () => { + it("does not retry a superseded fileless acquire or remove its replacement store", async () => { + const storeMock = installCursorSessionStoreMock(); const mockDisposeLate = vi.fn().mockResolvedValue(undefined); const mockDisposeReplacement = vi.fn().mockResolvedValue(undefined); let resolveLateCreate: (agent: unknown) => void = () => {}; @@ -431,7 +464,7 @@ }; }); - cursorSessionScopeTestUtils.set("/tmp/project", "/tmp/sessions/test.jsonl"); + cursorSessionScopeTestUtils.set("/tmp/project", undefined, "ephemeral"); const baseParams = { agentMode: "agent" as const, cwd: "/tmp/project", @@ -455,6 +488,37 @@ expect(mockDisposeReplacement).not.toHaveBeenCalled(); expect(secondLease.agent).toMatchObject({ agentId: "agent-replacement" }); expect(createAgent).toHaveBeenCalledTimes(2); + expect(storeMock.openedOptions[0].stateRoot).not.toBe(storeMock.openedOptions[1].stateRoot); + expect(storeMock.stores[0].dispose).toHaveBeenCalledTimes(1); + expect(storeMock.stores[1].dispose).not.toHaveBeenCalled(); + }); + + it("keeps a delayed fileless acquisition temporary after session scope becomes persisted", async () => { + let resolveDefaultStateRoot: (stateRoot: string) => void = () => {}; + const getDefaultStateRoot = vi.fn(() => new Promise((resolve) => { + resolveDefaultStateRoot = resolve; + })); + const storeMock = installCursorSessionStoreMock(getDefaultStateRoot); + const createAgent = vi.fn().mockResolvedValue({ + agentId: "agent-fileless", + [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), + }); + cursorSessionScopeTestUtils.set("/tmp/project", undefined, "ephemeral"); + + const acquire = acquireSessionCursorAgent({ + apiKey: "test-key", + agentMode: "agent", + cwd: "/tmp/project", + modelSelection: { id: "composer-2.5" }, + createAgent, + }); + await vi.waitFor(() => expect(getDefaultStateRoot).toHaveBeenCalledTimes(1)); + cursorSessionScopeTestUtils.set("/tmp/project", "/tmp/sessions/persisted.jsonl", "persisted"); + resolveDefaultStateRoot("/tmp/cursor-sdk-state"); + + await acquire; + expect(storeMock.openedOptions[0].stateRoot).toContain("pi-cursor-sdk"); + expect(storeMock.openedOptions[0].stateRoot).not.toContain("cursor-sdk-state"); }); it("clears invalidation before the first agent is created", async () => { @@ -838,7 +902,13 @@ }); expect(createAgent).toHaveBeenCalledWith(expect.objectContaining({ - local: { cwd: "/tmp/project", settingSources: ["all"], autoReview: true, sandboxOptions: { enabled: true } }, + local: expect.objectContaining({ + cwd: "/tmp/project", + settingSources: ["all"], + autoReview: true, + sandboxOptions: { enabled: true }, + store: expect.any(Object), + }), })); }); @@ -858,7 +928,9 @@ createAgent, }); - expect(createAgent).toHaveBeenCalledWith(expect.objectContaining({ local: { cwd: "/tmp/project" } })); + expect(createAgent).toHaveBeenCalledWith(expect.objectContaining({ + local: expect.objectContaining({ cwd: "/tmp/project", store: expect.any(Object) }), + })); expect(createAgent.mock.calls[0][0].local).not.toHaveProperty("autoReview"); expect(createAgent.mock.calls[0][0].local).not.toHaveProperty("sandboxOptions"); }); diff --git a/test/cursor-session-store.test.ts b/test/cursor-session-store.test.ts new file mode 100644 --- /dev/null +++ b/test/cursor-session-store.test.ts @@ -0,0 +1,155 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent, createAgentPlatform, type LocalAgentStore } from "@cursor/sdk"; +import { describe, expect, it, vi } from "vitest"; +import { + buildCursorSessionStateRoot, + claimCursorTemporarySessionStore, + hashCursorSessionStoreScope, + openCursorSessionStore, + __testUtils as storeTestUtils, +} from "../src/cursor-session-store.js"; + +describe("cursor session store identity", () => { + it("derives a stable session root below the SDK workspace state root", () => { + const scopeKey = "/tmp/sessions/example.jsonl"; + expect(hashCursorSessionStoreScope(scopeKey)).toBe("9983782212ce97faa33c17445f21670d"); + expect(buildCursorSessionStateRoot("/sdk/workspace", scopeKey, true)).toBe( + join("/sdk/workspace", "pi-sessions", "9983782212ce97faa33c17445f21670d"), + ); + }); + + it("separates persisted pi sessions and gives every fileless open a temporary root", () => { + const first = buildCursorSessionStateRoot("/sdk/workspace", "session-a", true); + const second = buildCursorSessionStateRoot("/sdk/workspace", "session-b", true); + const anonymous = buildCursorSessionStateRoot("/sdk/workspace", "__anonymous__", false); + + expect(first).not.toBe(second); + expect(anonymous).not.toBe(buildCursorSessionStateRoot("/sdk/workspace", "__anonymous__", false)); + expect(anonymous).toContain(join(tmpdir(), "pi-cursor-sdk-")); + expect(anonymous).toContain("pi-sessions"); + }); + + it("removes an extension-owned temporary store after graceful disposal", async () => { + storeTestUtils.setSdkOperations(undefined); + const root = mkdtempSync(join(tmpdir(), "pi-cursor-ephemeral-store-")); + const stateRoot = buildCursorSessionStateRoot(root, "ephemeral", false); + claimCursorTemporarySessionStore({ version: 1, stateRoot }); + const store = await openCursorSessionStore(root, { version: 1, stateRoot }, true); + expect(existsSync(stateRoot)).toBe(true); + + await store.dispose(); + + expect(existsSync(stateRoot)).toBe(false); + rmSync(root, { recursive: true, force: true }); + }); + + it("refuses to remove a shared store even when its path resembles a session root", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-shared-store-")); + const stateRoot = join(workspaceRoot, "pi-sessions", "a".repeat(32)); + const marker = join(stateRoot, "keep.txt"); + mkdirSync(stateRoot, { recursive: true }); + writeFileSync(marker, "keep"); + + await expect(openCursorSessionStore(workspaceRoot, { version: 1, stateRoot }, true)).rejects.toThrow("Refusing to remove"); + expect(existsSync(marker)).toBe(true); + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + it("binds temporary removal to the authorized identity before SQLite opens", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-store-mutation-")); + const stateRoot = buildCursorSessionStateRoot(workspaceRoot, "ephemeral", false); + const identity = { version: 1 as const, stateRoot }; + const fakeStore = { dispose: vi.fn(async () => {}) } as unknown as LocalAgentStore & { dispose(): Promise }; + let resolveOpen: () => void = () => {}; + const openSqliteStore = vi.fn(() => new Promise((resolve) => { + resolveOpen = () => resolve(fakeStore); + })); + storeTestUtils.setSdkOperations({ getDefaultStateRoot: () => workspaceRoot, openSqliteStore }); + mkdirSync(stateRoot, { recursive: true }); + claimCursorTemporarySessionStore(identity); + const opening = openCursorSessionStore(workspaceRoot, identity, true); + const sharedRoot = join(workspaceRoot, "shared"); + const marker = join(sharedRoot, "keep.txt"); + mkdirSync(sharedRoot, { recursive: true }); + writeFileSync(marker, "keep"); + identity.stateRoot = sharedRoot; + await vi.waitFor(() => expect(openSqliteStore).toHaveBeenCalledTimes(1)); + resolveOpen(); + + try { + const store = await opening; + await store.dispose(); + expect(store.identity.stateRoot).toBe(stateRoot); + expect(existsSync(stateRoot)).toBe(false); + expect(existsSync(marker)).toBe(true); + } finally { + storeTestUtils.setSdkOperations(undefined); + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("removes a temporary root even when SQLite disposal fails", async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-cursor-store-dispose-failure-")); + const stateRoot = buildCursorSessionStateRoot(workspaceRoot, "ephemeral", false); + mkdirSync(stateRoot, { recursive: true }); + claimCursorTemporarySessionStore({ version: 1, stateRoot }); + storeTestUtils.setSdkOperations({ + getDefaultStateRoot: () => workspaceRoot, + openSqliteStore: async () => ({ + dispose: async () => { throw new Error("dispose failed"); }, + }) as unknown as LocalAgentStore & { dispose(): Promise }, + }); + try { + const store = await openCursorSessionStore(workspaceRoot, { version: 1, stateRoot }, true); + await expect(store.dispose()).rejects.toThrow("dispose failed"); + expect(existsSync(stateRoot)).toBe(false); + } finally { + storeTestUtils.setSdkOperations(undefined); + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("opens isolated SQLite stores that can write concurrently", async () => { + storeTestUtils.setSdkOperations(undefined); + const root = mkdtempSync(join(tmpdir(), "pi-cursor-session-stores-")); + const [first, second] = await Promise.all([ + openCursorSessionStore(root, { version: 1, stateRoot: join(root, "first") }), + openCursorSessionStore(root, { version: 1, stateRoot: join(root, "second") }), + ]); + try { + await Promise.all([ + first.store.agents.create({ agent: { + agentId: "agent-first", + cwd: root, + status: "idle", + createdAt: 1, + updatedAt: 1, + } }), + second.store.agents.create({ agent: { + agentId: "agent-second", + cwd: root, + status: "idle", + createdAt: 1, + updatedAt: 1, + } }), + ]); + expect(await first.store.agents.get({ agentId: "agent-first" })).toMatchObject({ agentId: "agent-first" }); + expect(await first.store.agents.get({ agentId: "agent-second" })).toBeNull(); + expect(await Agent.messages.list("agent-first", { runtime: "local", cwd: root, store: first.store })).toEqual([]); + const platform = await createAgentPlatform({ + localStore: second.store, + workspaceRef: root, + scopedWorkspaceRef: root, + }); + expect(await platform.getAgent("agent-second")).toMatchObject({ agentId: "agent-second" }); + await Agent.delete("agent-first", { cwd: root, store: first.store }); + expect(await first.store.agents.get({ agentId: "agent-first" })).toBeNull(); + expect(await second.store.agents.get({ agentId: "agent-second" })).toMatchObject({ agentId: "agent-second" }); + } finally { + await Promise.all([first.dispose(), second.dispose()]); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/index-session-cwd-integration.test.ts b/test/index-session-cwd-integration.test.ts --- a/test/index-session-cwd-integration.test.ts +++ b/test/index-session-cwd-integration.test.ts @@ -57,6 +57,7 @@ import { __testUtils as cursorSessionScopeTestUtils } from "../src/cursor-session-scope.js"; import { __testUtils as cursorPiToolBridgeTestUtils } from "../src/cursor-pi-tool-bridge.js"; import { __testUtils as cursorHttp1TestUtils } from "../src/cursor-http1.js"; +import { installCursorSessionStoreMock } from "./helpers/cursor-session-store.js"; import { collectEvents, createExtensionRegistrationPi, @@ -71,6 +72,7 @@ describe("extension session cwd integration", () => { beforeEach(async () => { + installCursorSessionStoreMock(); await cursorPiToolBridgeTestUtils.resetRegisteredBridgeForTests(); vi.clearAllMocks(); delete process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY; @@ -106,7 +108,11 @@ expect(mockedAgentCreate).toHaveBeenCalledWith( expect.objectContaining({ - local: { cwd: sessionDir, settingSources: ["all"] }, + local: expect.objectContaining({ + cwd: sessionDir, + settingSources: ["all"], + store: expect.any(Object), + }), }), ); expect(mockedCursorConfigure).not.toHaveBeenCalled(); diff --git a/test/helpers/cursor-provider-harness.ts b/test/helpers/cursor-provider-harness.ts --- a/test/helpers/cursor-provider-harness.ts +++ b/test/helpers/cursor-provider-harness.ts @@ -58,6 +58,7 @@ import type { ModelListItem, Run, SDKAgent, SendOptions } from "@cursor/sdk"; import type { AssistantMessage, AssistantMessageEvent, TextContent, ImageContent, ToolCall } from "@earendil-works/pi-ai/compat"; import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent"; +import { installCursorSessionStoreMock } from "./cursor-session-store.js"; import { collectAssistantEvents, createBridgePiHarness, @@ -357,6 +358,7 @@ export async function resetCursorProviderTestState(): Promise { vi.useRealTimers(); + installCursorSessionStoreMock(); cloudLifecycleTestUtils.reset(); cloudLifecycleTestUtils.setDurableWriter(() => true); registerCursorCloudLifecycleLedger(createPiHarness()); diff --git a/test/helpers/cursor-session-store.ts b/test/helpers/cursor-session-store.ts new file mode 100644 --- /dev/null +++ b/test/helpers/cursor-session-store.ts @@ -0,0 +1,27 @@ +import type { LocalAgentStore } from "@cursor/sdk"; +import { vi } from "vitest"; +import { __testUtils as cursorSessionStoreTestUtils } from "../../src/cursor-session-store.js"; + +export function installCursorSessionStoreMock( + getDefaultStateRoot: () => string | Promise = () => "/tmp/cursor-sdk-state", +) { + const stores: Array }> = []; + const openedOptions: Array<{ workspaceRef: string; stateRoot: string }> = []; + const openSqliteStore = vi.fn(async (options: { workspaceRef: string; stateRoot: string }) => { + openedOptions.push(options); + const store = { + agents: {}, + checkpoints: {}, + runs: {}, + runEvents: {}, + dispose: vi.fn(async () => {}), + } as unknown as LocalAgentStore & { dispose(): Promise }; + stores.push(store); + return store; + }); + cursorSessionStoreTestUtils.setSdkOperations({ + getDefaultStateRoot, + openSqliteStore, + }); + return { openSqliteStore, openedOptions, stores }; +} diff --git a/test/helpers/index-extension-test-kit.ts b/test/helpers/index-extension-test-kit.ts --- a/test/helpers/index-extension-test-kit.ts +++ b/test/helpers/index-extension-test-kit.ts @@ -10,6 +10,7 @@ import { __testUtils as cursorSessionScopeTestUtils } from "../../src/cursor-session-scope.js"; import { __testUtils as cursorSessionResumeTestUtils } from "../../src/cursor-session-agent-resume.js"; import { __testUtils as cursorSdkProcessErrorGuardTestUtils } from "../../src/cursor-sdk-process-error-guard.js"; +import { installCursorSessionStoreMock } from "./cursor-session-store.js"; export { nativeToolDisplayTestUtils, @@ -26,6 +27,7 @@ export async function resetIndexExtensionTestState(): Promise { vi.clearAllMocks(); + installCursorSessionStoreMock(); delete process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY; delete process.env.PI_CURSOR_REGISTER_NATIVE_TOOLS; delete process.env.PI_CURSOR_PI_TOOL_BRIDGE;