From da8d33acd20bb0554d1b6b137935b44c19a147a2 Mon Sep 17 00:00:00 2001 From: Mitch Fultz <57411549+fitchmultz@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:53:41 -0600 Subject: [PATCH] fix: isolate Cursor SDK state per pi session --- AGENTS.md | 1 + CHANGELOG.md | 1 + README.md | 6 +- docs/cursor-model-ux-spec.md | 1 + scripts/local-resume-cleanup-smoke.mjs | 13 +- src/cursor-agent-message-web-tools.ts | 20 +- src/cursor-provider-turn-finalize.ts | 26 ++- src/cursor-provider-turn-send.ts | 2 +- src/cursor-session-agent-cleanup.ts | 81 ++++++-- src/cursor-session-agent-resume.ts | 57 +++++- src/cursor-session-agent.ts | 180 +++++++++++------- src/cursor-session-store.ts | 114 +++++++++++ test/cursor-provider-bridge-mcp.test.ts | 6 +- test/cursor-provider-bridge-settings.test.ts | 8 +- test/cursor-provider-http1.test.ts | 3 +- test/cursor-provider-run-finalizer.test.ts | 8 +- test/cursor-provider-session-store.test.ts | 48 +++++ test/cursor-provider-stream-config.test.ts | 6 +- test/cursor-session-agent-cleanup.test.ts | 77 +++++++- ...ursor-session-agent-dead-transport.test.ts | 2 + test/cursor-session-agent-http1.test.ts | 2 + .../cursor-session-agent-local-resume.test.ts | 134 ++++++++++--- test/cursor-session-agent-resume.test.ts | 22 ++- test/cursor-session-agent.test.ts | 80 +++++++- test/cursor-session-store.test.ts | 155 +++++++++++++++ test/helpers/cursor-provider-harness.ts | 2 + test/helpers/cursor-session-store.ts | 27 +++ test/helpers/index-extension-test-kit.ts | 2 + test/index-session-cwd-integration.test.ts | 8 +- 29 files changed, 943 insertions(+), 149 deletions(-) create mode 100644 src/cursor-session-store.ts create mode 100644 test/cursor-provider-session-store.test.ts create mode 100644 test/cursor-session-store.test.ts create mode 100644 test/helpers/cursor-session-store.ts diff --git a/AGENTS.md b/AGENTS.md index 2a47c56..2c05167 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ This repository is a pi provider extension that registers Cursor SDK-backed mode - `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 index c0526b6..0be5138 100644 --- 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 index 34485f9..e6500e0 100644 --- a/README.md +++ b/README.md @@ -309,14 +309,14 @@ pi --model cursor/composer-2-5 --cursor-local-force 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 @@ Local resume cleanup is explicit and session-ledger scoped: /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 index 8034d1e..4a8d252 100644 --- a/docs/cursor-model-ux-spec.md +++ b/docs/cursor-model-ux-spec.md @@ -43,6 +43,7 @@ Current implementation notes: - 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 index c29c2eb..903baff 100644 --- a/scripts/local-resume-cleanup-smoke.mjs +++ b/scripts/local-resume-cleanup-smoke.mjs @@ -25,6 +25,7 @@ export async function runCleanupSmoke() { 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 @@ export async function runCleanupSmoke() { }); 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 @@ export async function runCleanupSmoke() { 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 index 9918958..00ec60a 100644 --- 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 @@ function getOneofCaseValue(value: unknown, caseName: string): unknown { 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 @@ export async function loadCursorTranscriptWebToolCallsAfterOffset(options: { 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 index 835b188..f790f1f 100644 --- 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 { 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 @@ async function replayCursorTranscriptWebToolCalls( agentId: string, cwd: string, messageOffset: number | undefined, + turnStore: LocalAgentStore, turnCoordinator: CursorSdkTurnCoordinator, sdkEventDebug: CursorSdkEventDebugSink | undefined, ): Promise { @@ -73,6 +84,7 @@ async function replayCursorTranscriptWebToolCalls( agentId, cwd, offset: messageOffset, + store: turnStore, }); if (transcriptToolCalls.length === 0) return; sdkEventDebug?.recordCoordinatorEvent("cursor-transcript-web-tools", { @@ -176,6 +188,7 @@ export async function awaitFinalizeCursorRunOutcome(params: AwaitFinalizeCursorR params.run.agentId, params.prepared.cwd, params.cursorAgentMessageOffset, + params.prepared.sessionAgentLease.store, params.prepared.runtime.turnCoordinator, params.sdkEventDebug, ); @@ -187,7 +200,12 @@ export async function awaitFinalizeCursorRunOutcome(params: AwaitFinalizeCursorR // 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 index 4acfc1c..3b9a907 100644 --- a/src/cursor-provider-turn-send.ts +++ b/src/cursor-provider-turn-send.ts @@ -76,7 +76,7 @@ export async function sendCursorProviderTurn(sendParams: SendCursorProviderTurnP 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 index 0e8cda1..2cf9010 100644 --- 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 @@ import { 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 @@ type CleanupPhase = "intent" | "result"; export interface CursorSessionAgentCleanupFailure { agentId: string; error: string; + retryable?: boolean; } export interface CursorSessionAgentCleanupEntryData { @@ -48,9 +56,11 @@ type LocalResumeCleanupCommandContext = Pick & { 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 // intent entry, not this Set, is the authority a restarted process relies on to block retries. @@ -106,7 +116,9 @@ function parseCleanupEntryData(value: unknown): CursorSessionAgentCleanupEntryDa 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 parseCleanupEntryData(value: unknown): CursorSessionAgentCleanupEntryDa 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,39 +152,57 @@ function readUnavailableAgentIds(entries: readonly SessionEntry[]): Set 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; } -export function readCursorSessionAgentCleanupPlan( +function readCursorSessionAgentCleanupPlanDetails( entries: readonly SessionEntry[], branch: readonly SessionEntry[], scope: CursorSessionAgentCleanupScope, -): CursorSessionAgentCleanupPlan { +): 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 Set(); + const candidates = new Map(); 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); + 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 { - candidateAgentIds: uniqueSorted(candidates), + 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( + entries: readonly SessionEntry[], + branch: readonly SessionEntry[], + scope: CursorSessionAgentCleanupScope, +): CursorSessionAgentCleanupPlan { + const { candidates: _, ...plan } = readCursorSessionAgentCleanupPlanDetails(entries, branch, scope); + return plan; +} + function formatCleanupPlan(plan: CursorSessionAgentCleanupPlan): string { if (plan.candidateAgentIds.length === 0) return "No recorded superseded local Cursor SDK agents are cleanup-eligible."; return [ @@ -245,7 +276,8 @@ export async function runCursorSessionAgentCleanupCommand(pi: LocalResumeCleanup 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 @@ export async function runCursorSessionAgentCleanupCommand(pi: LocalResumeCleanup 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 index b664432..03a5aa5 100644 --- a/src/cursor-session-agent-resume.ts +++ b/src/cursor-session-agent-resume.ts @@ -4,10 +4,12 @@ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent 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 @@ export interface CursorSessionAgentResumeScope { 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 @@ export interface CursorSessionAgentResumeEntryData { compactionGeneration: number; sendState: SessionCursorAgentSendState; createdAt: string; + storeIdentity?: CursorSessionStoreIdentity; cleanupCandidateAgentIds?: string[]; + cleanupCandidates?: CursorSessionAgentCleanupCandidate[]; } interface PendingCursorSessionAgentResumeHandle { @@ -46,6 +55,7 @@ interface PendingCursorSessionAgentResumeHandle { agentId: string; poolKey: string; sendState: SessionCursorAgentSendState; + storeIdentity: CursorSessionStoreIdentity; } interface CursorSessionResumeState { @@ -109,10 +119,31 @@ function isSendState(value: unknown): value is SessionCursorAgentSendState { 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 @@ export function parseCursorSessionAgentResumeEntryData(value: unknown): CursorSe 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 @@ export function parseCursorSessionAgentResumeEntryData(value: unknown): CursorSe incrementalSendCount: record.sendState.incrementalSendCount, }, createdAt: record.createdAt, + ...(storeIdentity ? { storeIdentity } : {}), ...(cleanupCandidateAgentIds?.length ? { cleanupCandidateAgentIds: [...new Set(cleanupCandidateAgentIds)] } : {}), + ...(cleanupCandidates ? { cleanupCandidates } : {}), }; } @@ -330,6 +366,7 @@ export function persistCursorSessionAgentResumeHandle(input: PendingCursorSessio agentId: input.agentId, poolKey: input.poolKey, sendState: { ...input.sendState }, + storeIdentity: { ...input.storeIdentity }, }; } @@ -338,8 +375,13 @@ function flushPendingCursorSessionAgentResumeHandle(branch: readonly SessionEntr 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 @@ function flushPendingCursorSessionAgentResumeHandle(branch: readonly SessionEntr 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 index d3f455e..907074b 100644 --- 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 @@ import { 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 @@ export interface SessionCursorAgentLease { instanceId: number; agent: SDKAgent; bridgeRun?: CursorPiToolBridgeRun; + store: LocalAgentStore; + storeIdentity: CursorSessionStoreIdentity; sendState: SessionCursorAgentSendState; created: boolean; resumed?: boolean; @@ -52,6 +62,7 @@ interface SessionCursorAgentReadyEntry extends SessionCursorAgentPoolEntryBase { status: "ready"; agent: SDKAgent; bridgeRun?: CursorPiToolBridgeRun; + sessionStore: OpenCursorSessionStore; resumeEnabled: boolean; resumed: boolean; resumeNotice?: string; @@ -61,6 +72,7 @@ interface SessionCursorAgentBusyEntry extends SessionCursorAgentPoolEntryBase { status: "busy"; agent: SDKAgent; bridgeRun?: CursorPiToolBridgeRun; + sessionStore: OpenCursorSessionStore; resumeEnabled: boolean; resumed: boolean; resumeNotice?: string; @@ -132,6 +144,7 @@ let deadTransportAgentDisposeTimeoutMs = 3000; 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 @@ export function buildCursorLocalAgentOptions(options: { 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 @@ async function disposePoolEntry(entry: SessionCursorAgentPoolEntry, options?: { } 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 @@ function commitSessionAgentSendForLease( agentId: entry.agent.agentId, poolKey: entry.poolKey, sendState: entry.sendState, + storeIdentity: entry.sessionStore.identity, }); } } @@ -377,6 +394,8 @@ function leaseFromEntry( 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 tryLeaseReadyEntry( 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; + let sessionStore: OpenCursorSessionStore | undefined; + try { + const registeredBridge = getRegisteredCursorPiToolBridge(); + 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) { + 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 { - 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."; + 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); } - } - try { - agent ??= await createAgent(agentOptions); - } catch (error) { - if (bridgeRun) { - bridgeRun.cancel("Cursor session agent create failed"); + 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 { - await bridgeRun.dispose(); + agent = await resumeAgent(resumeHandle.agentId, buildAgentOptions()); + effectiveSendState = { ...resumeHandle.sendState }; + resumed = true; } catch { - // bridge disposal failure should not mask agent create failure + 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 function invalidateSessionAgent( export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise { const scopeKey = getCursorSessionScopeKey(); + const persistentStore = getCursorSessionFile() !== undefined; while (true) { assertScopeAcceptsAcquire(scopeKey); @@ -565,7 +613,7 @@ export async function acquireSessionCursorAgent(params: SessionCursorAgentCreate 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 index 0000000..1df2d3e --- /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 index 0759ae2..d54c6cf 100644 --- a/test/cursor-provider-bridge-mcp.test.ts +++ b/test/cursor-provider-bridge-mcp.test.ts @@ -222,7 +222,11 @@ describe("streamCursor bridge MCP", () => { 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 index 0243717..926e323 100644 --- a/test/cursor-provider-bridge-settings.test.ts +++ b/test/cursor-provider-bridge-settings.test.ts @@ -68,7 +68,7 @@ describe("streamCursor bridge settings", () => { 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 @@ describe("streamCursor bridge settings", () => { expect(mockedCreate).toHaveBeenCalledWith( expect.objectContaining({ - local: { cwd: process.cwd() }, + local: expect.objectContaining({ cwd: process.cwd(), store: expect.any(Object) }), }), ); }); @@ -120,7 +120,7 @@ describe("streamCursor bridge settings", () => { 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 @@ describe("streamCursor bridge settings", () => { 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 index df65697..c02c55f 100644 --- a/test/cursor-provider-http1.test.ts +++ b/test/cursor-provider-http1.test.ts @@ -55,9 +55,10 @@ describe("Cursor provider HTTP/1.1 transport", () => { 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 index 9a1b3a6..9230030 100644 --- 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 @@ describe("CursorRunFinalizer", () => { 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 @@ describe("CursorRunFinalizer", () => { 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 @@ describe("CursorRunFinalizer", () => { 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 index 0000000..bcf5d5c --- /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 index 7d2012e..e5e7d1a 100644 --- a/test/cursor-provider-stream-config.test.ts +++ b/test/cursor-provider-stream-config.test.ts @@ -55,7 +55,11 @@ describe("streamCursor prompt and model config", () => { 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 index 4d7d875..72c458b 100644 --- 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 @@ import { } 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 @@ function makeContext(entries: SessionEntry[], branch: SessionEntry[] = entries) 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 @@ describe("cursor-session-agent-cleanup", () => { 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 @@ describe("cursor-session-agent-cleanup", () => { 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 @@ describe("cursor-session-agent-cleanup", () => { 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", @@ -304,6 +313,57 @@ describe("cursor-session-agent-cleanup", () => { })); }); + 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 () => { cleanupTestUtils.setAppendDurability(undefined); const tempDir = mkdtempSync(join(tmpdir(), "cursor-local-cleanup-durable-")); @@ -448,7 +508,10 @@ describe("cursor-session-agent-cleanup", () => { 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 index f0825f4..058cdc9 100644 --- a/test/cursor-session-agent-dead-transport.test.ts +++ b/test/cursor-session-agent-dead-transport.test.ts @@ -7,9 +7,11 @@ import { } 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 index d10bc1a..e01ef7e 100644 --- a/test/cursor-session-agent-http1.test.ts +++ b/test/cursor-session-agent-http1.test.ts @@ -12,9 +12,11 @@ import { } 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 index b60b022..dbb27a0 100644 --- 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 @@ import { __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 @@ describe("cursor-session-agent local resume", () => { branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, compactionGeneration: 0, activeHandle: { - version: 1, + version: 2, runtime: "local", agentId: "agent-recorded", scopeKey, @@ -56,6 +62,7 @@ describe("cursor-session-agent local resume", () => { compactionGeneration: 0, sendState, createdAt: "2026-07-07T00:00:00.000Z", + storeIdentity: { version: 1, stateRoot }, }, }); @@ -65,19 +72,21 @@ describe("cursor-session-agent local resume", () => { 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 @@ describe("cursor-session-agent local resume", () => { }, }); + 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,11 +190,76 @@ describe("cursor-session-agent local resume", () => { expect(createAgent).toHaveBeenCalledTimes(1); }); - it("falls back to create and bootstrap when Agent.resume fails", async () => { - const scopeKey = "/tmp/sessions/test.jsonl"; + 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().mockRejectedValue(new Error("Agent agent-recorded not found")); - cursorSessionScopeTestUtils.set("/tmp/project", scopeKey); + const resumeAgent = vi.fn(); + cursorSessionScopeTestUtils.set("/tmp/project", undefined, sessionId); const params = { apiKey: "test-key", agentMode: "agent" as const, @@ -184,7 +271,7 @@ describe("cursor-session-agent local resume", () => { }; resumeTestUtils.set({ scopeKey, - sessionFile: scopeKey, + sessionId, cwd: "/tmp/project", branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, compactionGeneration: 0, @@ -193,32 +280,33 @@ describe("cursor-session-agent local resume", () => { runtime: "local", agentId: "agent-recorded", scopeKey, - sessionFile: scopeKey, + sessionId, cwd: "/tmp/project", poolKey: sessionAgentTestUtils.buildSessionAgentPoolKey(scopeKey, params), branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, compactionGeneration: 0, - sendState: { bootstrapped: true, contextFingerprint: computeCursorContextFingerprint(makeContext()), incrementalSendCount: 0 }, + sendState: { bootstrapped: true, contextFingerprint: "old", incrementalSendCount: 1 }, createdAt: "2026-07-07T00:00:00.000Z", }, }); 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); + 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("does not resume when the recorded identity no longer matches", async () => { + 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().mockResolvedValue({ agentId: "agent-recorded", [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined) }); cursorSessionScopeTestUtils.set("/tmp/project", scopeKey); const params = { - apiKey: "new-key", + apiKey: "test-key", agentMode: "agent" as const, cwd: "/tmp/project", modelSelection: { id: "composer-2.5" }, @@ -231,25 +319,27 @@ describe("cursor-session-agent local resume", () => { sessionFile: scopeKey, cwd: "/tmp/project", branchPathHash: resumeTestUtils.EMPTY_BRANCH_HASH, - compactionGeneration: 1, + compactionGeneration: 0, activeHandle: { - version: 1, + version: 2, runtime: "local", agentId: "agent-recorded", scopeKey, sessionFile: scopeKey, cwd: "/tmp/project", - poolKey: "old-pool-key", + 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", + storeIdentity: { version: 1, stateRoot: "/tmp/stale-sdk-root" }, }, }); const lease = await acquireSessionCursorAgent(params); expect(lease.resumed).toBe(false); + expect(lease.resumeNotice).toContain("Could not resume prior Cursor agent"); 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 index 5953d10..63ae256 100644 --- a/test/cursor-session-agent-resume.test.ts +++ b/test/cursor-session-agent-resume.test.ts @@ -61,6 +61,16 @@ describe("cursor-session-agent-resume", () => { }; 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 @@ describe("cursor-session-agent-resume", () => { } 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 @@ describe("cursor-session-agent-resume", () => { 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 @@ describe("cursor-session-agent-resume", () => { 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 @@ describe("cursor-session-agent-resume", () => { 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 @@ describe("cursor-session-agent-resume", () => { 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 index 6994f07..b45ec2a 100644 --- 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 @@ import { __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(); @@ -44,6 +48,34 @@ describe("cursor-session-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 () => { const createAgent = vi.fn().mockResolvedValue({ agentId: "agent-plan", @@ -413,7 +445,8 @@ describe("cursor-session-agent", () => { 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 @@ describe("cursor-session-agent", () => { }; }); - 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 @@ describe("cursor-session-agent", () => { 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 @@ describe("cursor-session-agent", () => { }); 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 @@ describe("cursor-session-agent", () => { 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 index 0000000..c5aa352 --- /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/helpers/cursor-provider-harness.ts b/test/helpers/cursor-provider-harness.ts index 22d37a1..015742c 100644 --- a/test/helpers/cursor-provider-harness.ts +++ b/test/helpers/cursor-provider-harness.ts @@ -58,6 +58,7 @@ import type { CursorNativeToolDisplayExtensionApi } from "../../src/cursor-nativ 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 const cursorModelItems: ModelListItem[] = [ 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 index 0000000..90acd0d --- /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 index c3532ef..a74708f 100644 --- a/test/helpers/index-extension-test-kit.ts +++ b/test/helpers/index-extension-test-kit.ts @@ -10,6 +10,7 @@ import { __testUtils as cursorPiToolBridgeTestUtils } from "../../src/cursor-pi- 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 function createExtensionPi( 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; diff --git a/test/index-session-cwd-integration.test.ts b/test/index-session-cwd-integration.test.ts index a707ad7..a54e89c 100644 --- a/test/index-session-cwd-integration.test.ts +++ b/test/index-session-cwd-integration.test.ts @@ -57,6 +57,7 @@ import { streamCursorLazy } from "../src/cursor-provider-lazy.js"; 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 @@ const mockedCursorConfigure = vi.mocked(Cursor.configure); 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 @@ describe("extension session cwd integration", () => { 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(); -- 2.51.2