diff --git a/CHANGELOG.md b/CHANGELOG.md index 37820fa..5aa7fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Fingerprint-guarded session reuse, now the default (`session: "auto"`).** + Previously the provider created a fresh Cursor agent every turn and re-sent + the whole transcript (robust but cache-hostile and increasingly costly as a + conversation grows), while opt-in `session: true` resumed one agent per + session but could drift from opencode's history (edits/reverts/compaction) and + was disturbed by non-chat side calls. `session: "auto"` (the new default) + hashes only the parts opencode replays verbatim — the system prompt and the + user-message sequence — and classifies each turn: a clean **continuation** + resumes the pooled agent and sends only the new message (maximizing prefix + cache hits); a **side-call** (system prompt differs, e.g. title generation) + runs a fresh ephemeral agent without touching the pool; a **divergence** + (edit/revert/compaction/queued messages) or a failed resume falls back to a + fresh agent + full transcript and re-pools. Worst case is one self-healing + full replay — never worse than the old default. `session: true` is now an + alias for `"auto"`; `session: false` keeps the always-fresh behavior. + Set `OPENCODE_CURSOR_DEBUG=1` to log per-turn classification and cache usage. +- **Session reuse survives opencode restarts.** The pool's fingerprint records + persist (best-effort) to `~/.cache/opencode-cursor/session-pool.json` (7-day + TTL, 200-entry LRU cap), so the first turn after a restart resumes the + session's Cursor agent — whose conversation lives in Cursor's own checkpoint + store — instead of paying a cache-cold full-transcript replay. +- **MCP servers are re-forwarded live, per turn, with OAuth mapping.** The + `config` hook's startup snapshot meant mid-session MCP enable/disable never + reached the Cursor agent. The `chat.params` hook now forwards the live set + each turn (`client.mcp.status()` for runtime truth, `client.config.get()` for + launch specs). Because a resumed agent keeps its original servers, a changed + set forces a fresh agent (full-transcript replay, re-pooled) so the new + servers take effect — the session fingerprint carries an `mcpHash` for this. + Remote servers with a registered OAuth client are forwarded with a Cursor + `auth` block so the agent runs its own OAuth flow; servers needing OAuth + without a shareable `clientId` (dynamic registration) are skipped with a + one-time toast instead of forwarding a spec that would 401. +- **Fixed: text/reasoning streamed after a tool call rendered above the tool + block.** The earlier ordering fix closed parts on text↔reasoning transitions, + but blocks-mode tool parts were emitted while the narration part stayed open + — and hosts position a part where it started. Open text/reasoning parts are + now closed before tool parts are emitted (except for buffered edit calls, + which emit nothing until their result arrives, so narration isn't split + needlessly). +- **Tool outputs are included (truncated) in flattened transcripts.** The + fresh/divergence/`session: false` replay paths previously dropped Cursor tool + results to bare `[result of X]` placeholders, so a fresh agent re-read a + transcript with prior tool outputs missing. Outputs are now inlined and capped + (2,000 chars per result, 500 per tool-call args) so context stays faithful + without unbounded bloat. + ## [0.2.0] — 2026-06-11 - **More Cursor tools map onto opencode's native tool renderers (blocks mode).** @@ -47,7 +93,8 @@ and a permission-gated delegation tool surface. - **Session reuse** (`session: true`) — keeps one Cursor agent per opencode session via `Agent.resume()` across turns, with automatic fallback to a fresh agent. A run wedged by a crashed/duplicate process is recovered by retrying - the send once with the SDK's `local.force` escape hatch. + the send once with the SDK's `local.force` escape hatch. (Superseded by the + fingerprint-guarded `session: "auto"` default; see Unreleased.) - **Native diff viewer for Cursor edits (blocks mode).** A Cursor `edit` tool call is now surfaced under opencode's registered `edit` tool with its real unified diff in `metadata.diff`, so opencode renders its built-in diff viewer diff --git a/README.md b/README.md index 1aa91a1..52d473d 100644 --- a/README.md +++ b/README.md @@ -152,20 +152,53 @@ This plugin also registers two **delegation tools** that complement the provider | `settingSources` | — | Cursor settings layers to load from disk: `["project","user","all",...]` — pulls in your Cursor **skills**, rules, and `.cursor/mcp.json` | | `sandbox` | — | Run the agent's tools inside Cursor's sandbox (`true`/`false`) | | `agents` | — | Cursor subagent definitions (`{ : { description, prompt, model?, mcpServers? } }`) | -| `session` | `false` | Reuse one Cursor agent per opencode session (resume across turns; see below) | +| `session` | `"auto"` | Session reuse strategy: `"auto"` (fingerprint-guarded resume), `true` (alias for `"auto"`), or `false` (always fresh). See below | | `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent | | `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones | | `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown: `"blocks"` (structured provider-executed tool blocks; default, requires opencode 1.16+) or `"reasoning"` (compact lines, the fallback for older/non-V3 hosts). See [Tool display](#tool-display) | ### Session reuse (`session`) -By default each opencode turn spins up a **fresh** Cursor agent and re-sends the full conversation -transcript — robust, and correct even for opencode's non-chat calls (e.g. title generation). Set -`session: true` to instead keep **one Cursor agent per opencode session**: the provider names the -agent after the session, `Agent.resume()`s it on later turns, and sends only the new message so -Cursor uses its native conversation memory and checkpoints (the agent is visible in Cursor's -dashboard). The opencode session id reaches the provider via the plugin's `chat.params` hook -(`providerOptions.cursor.sessionID`); a failed resume falls back to a fresh turn automatically. +opencode re-sends the **entire** conversation transcript on every turn. Replaying that into a fresh +Cursor agent each turn is robust but costs more input tokens as the conversation grows (and pays +opencode's system prompt on top of Cursor's own). Reusing one Cursor agent and sending only the new +message is the cache-friendly, native-CLI-like path — but a blindly resumed agent can drift from +opencode's view of history (message edits, reverts, opencode-side compaction) and must not be +disturbed by opencode's non-chat side calls (e.g. title generation). + +**`session: "auto"` (the default) resolves this with a per-turn fingerprint.** The provider hashes +only the parts opencode replays verbatim — the system prompt and the user-message sequence — and +classifies each turn: + +| Situation | Classification | What the provider does | +| --- | --- | --- | +| First turn of the session | **new** | fresh agent, full transcript, pool it | +| System prompt differs (title gen and other side calls) | **side-call** | fresh ephemeral agent; the pooled agent is left untouched | +| Prior user sequence is an exact prefix + exactly one new user message | **continuation** | `Agent.resume` the pooled agent, send **only** the new message | +| Continuation, but the forwarded MCP server set changed | **continuation** (fresh agent) | fresh agent + full transcript, re-pool — a resumed agent keeps its original MCP servers, so a fresh one is needed for the new set | +| Earlier message edited/reverted, conversation compacted, or several messages queued | **divergence** | fresh agent, full transcript, re-pool | + +The worst case on any misclassification is a single full-transcript replay that self-heals on the +next turn — never worse than `session: false`. A failed resume also degrades to a fresh replay. The +resumed agent is named after the session and visible in Cursor's dashboard; the opencode session id +reaches the provider via the plugin's `chat.params` hook (`providerOptions.cursor.sessionID`). +Fingerprint records persist (best-effort) to `~/.cache/opencode-cursor/session-pool.json`, so +session reuse survives opencode restarts — the conversation itself lives in Cursor's own local +checkpoint store, and the next turn resumes it instead of replaying the transcript. + +- `session: true` is an alias for `"auto"`. +- `session: false` restores the original behavior: always a fresh agent + full transcript, every + turn. Use it if you want each turn fully independent. + +**Cache implications.** Cursor builds prompts cache-friendly and the model provider's own prefix +cache (Anthropic uses a ~5-minute sliding TTL) decides hits. `"auto"` keeps the prompt prefix stable +across turns, which is what lands cache reads instead of expensive re-seeds. Things that re-seed the +cache even mid-window: switching model/variant, changing the thinking level, toggling agent/plan +mode, editing an earlier message, or changing the forwarded MCP server set (tool definitions sit at +the top of the provider's cache-prefix hierarchy, so they invalidate everything after them). Tool outputs from earlier +turns are included (truncated) in the replay paths so a fresh/diverged agent still sees what prior +tools produced. Set `OPENCODE_CURSOR_DEBUG=1` to log the per-turn classification and the +`cacheReadTokens`/`cacheWriteTokens` reported by Cursor. ### Per-request controls (`mode`, thinking level) @@ -205,20 +238,36 @@ To disable MCP forwarding, set `provider.cursor.options.forwardMcp: false` in yo ## MCP servers -The Cursor agent can use the **same MCP servers you've configured in opencode**. The plugin's -`config` hook reads opencode's `config.mcp`, translates each entry into the Cursor SDK's -`McpServerConfig` shape, and hands them to the agent via `Agent.create({ mcpServers })`: +The Cursor agent can use the **same MCP servers you've configured in opencode**. Forwarding is +**live, per turn**: the plugin's `chat.params` hook reads opencode's current MCP state +(`client.mcp.status()` for what's actually enabled right now, `client.config.get()` for the launch +specs), translates each entry into the Cursor SDK's `McpServerConfig` shape, and hands the set to +the agent — so enabling or disabling an MCP server mid-session takes effect on the next turn, not +the next restart. A startup snapshot from the `config` hook remains as the fallback when the live +read is unavailable. | opencode `config.mcp` | → Cursor | | --- | --- | | `{ type: "local", command: [cmd, ...args], environment }` | `{ type: "stdio", command: cmd, args, env }` | | `{ type: "remote", url, headers }` | `{ type: "http", url, headers }` | +| remote with registered OAuth client (`clientId`, optional secret/scopes) | `{ type: "http", url, auth: { CLIENT_ID, … } }` — the agent runs its own OAuth flow | So whatever MCP servers your `opencode.json` defines, your Cursor agent connects to those same servers — MCP servers are independent processes, so opencode and the agent each connect to them directly. Disabled entries (`enabled: false`) are skipped. Turn this off with `forwardMcp: false`. +> **OAuth caveat.** opencode's own access tokens never land in `config.mcp`, so a remote server +> that needs OAuth **without** a shareable `clientId` (dynamic client registration / `needs_auth`) +> can't be forwarded — forwarding its spec would just 401. Such servers are skipped and a one-time +> toast tells you which ones; they keep working inside opencode itself. +> +> **Session-reuse interaction.** A resumed Cursor agent keeps the MCP servers it was created with, +> so when the forwarded set changes between turns the provider creates a fresh agent (full +> transcript replay, re-pooled) instead of resuming — see +> [Session reuse](#session-reuse-session). Tool definitions sit at the top of the provider's +> cache-prefix hierarchy, so an MCP change also re-seeds the prompt cache. + > Scope note: this forwards **MCP servers**. opencode's *loop-internal* features — its own skills > and subagents — are not exposed to the Cursor agent (they run inside opencode's agent loop, which > this provider bypasses). The Cursor agent's *own* skills/rules can be loaded with the @@ -297,9 +346,10 @@ This plugin runs Cursor as a **local agent** (`Agent.create({ local: { cwd } })` directory. How that activity is shown is controlled by the [`toolDisplay`](#tool-display) option. Either way it is **not** routed through opencode's tool/permission system — Cursor runs the tools itself. -- By default each turn creates a fresh local agent and sends the full conversation transcript, so - context is always complete. Enable `session: true` to reuse Cursor's native per-agent memory - across turns (see [Session reuse](#session-reuse-session)). +- By default (`session: "auto"`) the provider resumes one Cursor agent per session and sends only + the new message on a clean continuation, falling back to a fresh agent + full transcript on + edits/reverts/compaction/side calls (see [Session reuse](#session-reuse-session)). Set + `session: false` to always create a fresh agent and re-send the full transcript every turn. - Token usage is reported from Cursor's `turn-ended` event; cost is shown as `0` because Cursor bills your account separately. - **Provider path is local.** The `cursor/*` models you chat with run as a **local** agent. Cursor's diff --git a/scripts/session-reuse-smoke.mjs b/scripts/session-reuse-smoke.mjs new file mode 100644 index 0000000..1960f79 --- /dev/null +++ b/scripts/session-reuse-smoke.mjs @@ -0,0 +1,110 @@ +// Live smoke test for fingerprint-guarded session reuse (`session: "auto"`). +// +// Simulates how opencode drives the provider across turns: it re-sends the +// whole transcript each call with a stable providerOptions.cursor.sessionID. +// This script asserts the classification + cache behavior empirically: +// +// Turn 1 (new) -> fresh agent, full transcript +// Turn 2,3 (continuation)-> RESUME pooled agent, send only the new message; +// inputTokens stays flat, cacheRead dominates +// Turn 4 (divergence) -> edit an earlier user message -> fresh replay, +// re-pool. Demonstrates the safety fallback. +// +// Classification is logged to stderr (OPENCODE_CURSOR_DEBUG=1, set below). +// Skips cleanly (exit 0) when CURSOR_API_KEY is absent. +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.OPENCODE_CURSOR_DEBUG = "1"; + +const apiKey = process.env.CURSOR_API_KEY?.trim(); +if (!apiKey) { + console.log("[session-smoke] No CURSOR_API_KEY; skipping."); + process.exit(0); +} + +const modelId = process.env.CURSOR_SMOKE_MODEL?.trim() || "composer-2.5"; +const providerUrl = new URL("../dist/provider/index.js", import.meta.url).href; +const { createCursor } = await import(providerUrl); + +const cwd = mkdtempSync(join(tmpdir(), "cursor-session-")); +const model = createCursor({ apiKey, cwd, session: "auto" }).languageModel( + modelId, +); +const sessionID = `smoke-${Date.now()}`; + +const sys = { + role: "system", + content: "You are terse. Answer in one short sentence.", +}; +const user = (text) => ({ role: "user", content: [{ type: "text", text }] }); +const assistant = (text) => ({ + role: "assistant", + content: [{ type: "text", text }], +}); + +async function turn(label, prompt) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 180_000); + let text = ""; + let usage; + try { + const { stream } = await model.doStream({ + prompt, + abortSignal: controller.signal, + providerOptions: { cursor: { sessionID } }, + }); + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value.type === "text-delta") text += value.delta; + else if (value.type === "finish") usage = value.usage; + } + } finally { + clearTimeout(timer); + } + const inp = usage?.inputTokens ?? {}; + console.log( + `[session-smoke:${label}] reply=${JSON.stringify(text.trim().slice(0, 60))} ` + + `input=${inp.total ?? "?"} cacheRead=${inp.cacheRead ?? "?"} cacheWrite=${inp.cacheWrite ?? "?"}`, + ); + return text.trim(); +} + +// Turn 1 — new session. +const r1 = await turn("t1-new", [sys, user("Name a primary color.")]); +// Turn 2 — continuation (one new user message appended). +const r2 = await turn("t2-cont", [ + sys, + user("Name a primary color."), + assistant(r1), + user("Name another one."), +]); +// Turn 3 — continuation again. +const r3 = await turn("t3-cont", [ + sys, + user("Name a primary color."), + assistant(r1), + user("Name another one."), + assistant(r2), + user("And a third?"), +]); +// Turn 4 — divergence: edit the FIRST user message -> must fall back to replay. +await turn("t4-diverge", [ + sys, + user("Name a primary color. (edited)"), + assistant(r1), + user("Name another one."), + assistant(r2), + user("And a third?"), + assistant(r3), + user("One more?"), +]); + +console.log( + "[session-smoke] Done. Expect stderr classifications: " + + "fresh:new, resume, resume, fresh:divergence. " + + "On t2/t3 inputTokens should stay flat with cacheRead dominating.", +); diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 8459666..644ce51 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,9 +1,14 @@ -import type { Plugin } from "@opencode-ai/plugin"; +import type { Config, Plugin } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk/v2"; +import type { McpServerConfig } from "@cursor/sdk"; import { resolveCursorApiKey } from "../api-key.js"; import { discoverModels, toOpencodeModels } from "../model-discovery.js"; import { buildModelV2Map, PROVIDER_ID, providerNpm } from "./model-v2.js"; -import { translateMcpServers } from "./mcp-config.js"; +import { + findUnshareableOAuthServers, + type McpStatusMap, + translateMcpServers, +} from "./mcp-config.js"; import { buildCursorTools } from "./cursor-tools.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { @@ -28,6 +33,17 @@ export const CursorPlugin: Plugin = async (input) => { // back to the CURSOR_API_KEY env var when the loader hasn't run. let capturedApiKey: string | undefined; + // opencode client + MCP-forwarding settings captured at config time so the + // per-turn chat.params hook can re-forward the *live* MCP server set + // (reflecting mid-session enable/disable) rather than the startup snapshot. + const client = input?.client; + const directory = input?.directory; + let forwardMcp = true; + let userMcp: Record = {}; + // OAuth servers we've already warned about, so the toast fires once per + // server rather than on every turn. + const warnedOAuth = new Set(); + return { auth: { provider: PROVIDER_ID, @@ -68,10 +84,10 @@ export const CursorPlugin: Plugin = async (input) => { // Forward opencode's configured MCP servers to the Cursor // agent so it can use the same servers. Opt out via // `provider.cursor.options.forwardMcp: false`. - const forwardMcp = existingOptions["forwardMcp"] !== false; - const userMcp = (existingOptions["mcpServers"] ?? {}) as Record< + forwardMcp = existingOptions["forwardMcp"] !== false; + userMcp = (existingOptions["mcpServers"] ?? {}) as Record< string, - unknown + McpServerConfig >; const mcpServers = forwardMcp ? { ...userMcp, ...translateMcpServers(config.mcp) } @@ -115,6 +131,54 @@ export const CursorPlugin: Plugin = async (input) => { if (input.agent === "plan" && output.options["mode"] === undefined) { output.options["mode"] = "plan"; } + + // Dynamically re-forward MCP servers from opencode's *live* state so + // mid-session enable/disable reaches the Cursor agent (the config hook + // only snapshots the set once, at startup). `client.mcp.status()` is the + // runtime truth (connected/disabled/...) and `client.config.get()` + // supplies the launch specs. On any failure we leave the static snapshot + // (already baked into the provider options) in place. + if (forwardMcp && client) { + try { + const query = directory ? { query: { directory } } : undefined; + const [cfgRes, statusRes] = await Promise.all([ + client.config.get(), + client.mcp.status(query), + ]); + const liveMcp = (cfgRes?.data as Config | undefined)?.mcp; + const status = statusRes?.data as McpStatusMap | undefined; + if (status) { + output.options["mcpServers"] = { + ...userMcp, + ...translateMcpServers(liveMcp, status), + }; + // Notify (once) about OAuth servers we can't forward: opencode + // holds their token and it never reaches config.mcp, so the + // Cursor agent can't connect. Only those without a shareable + // client registration are skipped; ones with a clientId are + // forwarded with an `auth` block for the agent's own OAuth flow. + const unshareable = findUnshareableOAuthServers( + liveMcp, + status, + ).filter((name) => !warnedOAuth.has(name)); + if (unshareable.length > 0) { + for (const name of unshareable) warnedOAuth.add(name); + const plural = unshareable.length > 1; + void client.tui + .showToast({ + body: { + title: "Cursor MCP", + message: `Skipped OAuth MCP server${plural ? "s" : ""}: ${unshareable.join(", ")}. opencode's token can't be shared with the Cursor agent; configure an OAuth clientId to forward ${plural ? "them" : "it"}.`, + variant: "warning", + }, + }) + .catch(() => {}); + } + } + } catch { + // Keep the static snapshot; live forwarding is best-effort. + } + } }, tool: { diff --git a/src/plugin/mcp-config.ts b/src/plugin/mcp-config.ts index 97a4f06..cccf2e5 100644 --- a/src/plugin/mcp-config.ts +++ b/src/plugin/mcp-config.ts @@ -5,6 +5,77 @@ import type { McpServerConfig } from "@cursor/sdk"; type OpencodeMcp = NonNullable; type OpencodeMcpEntry = OpencodeMcp[string]; +/** + * Live MCP server status, keyed by server name, as reported by opencode's + * `client.mcp.status()`. Only the `status` field is consumed; `"connected"` + * means the server is currently usable. Mirrors the SDK's `McpStatus` union + * without importing it (keeps this module dependency-light). + */ +export type McpStatusMap = Record; + +/** opencode runtime statuses that mean a server still needs OAuth to connect. */ +const NEEDS_AUTH_STATUS = new Set(["needs_auth", "needs_client_registration"]); + +/** The OAuth client registration on a remote entry, or undefined when none. */ +function oauthConfig( + entry: OpencodeMcpEntry, +): { clientId?: string; clientSecret?: string; scope?: string } | undefined { + if (entry.type !== "remote") return undefined; + // `oauth` is `McpOAuthConfig | false | undefined`; both false and undefined + // are falsy, so a truthy value is the client-registration object. + return entry.oauth ? entry.oauth : undefined; +} + +/** + * Map opencode's OAuth client registration to the Cursor SDK's `auth` block so + * the Cursor agent can run its own OAuth flow. Returns undefined when there is + * no `clientId` to share (e.g. RFC 7591 dynamic registration) — opencode's + * access token itself never reaches `config.mcp`, so a bare URL would fail. + */ +function toCursorAuth( + oauth: + | { clientId?: string; clientSecret?: string; scope?: string } + | undefined, +): + | { CLIENT_ID: string; CLIENT_SECRET?: string; scopes?: string[] } + | undefined { + if (!oauth?.clientId) return undefined; + const scopes = oauth.scope?.split(/\s+/).filter(Boolean); + return { + CLIENT_ID: oauth.clientId, + ...(oauth.clientSecret ? { CLIENT_SECRET: oauth.clientSecret } : {}), + ...(scopes && scopes.length > 0 ? { scopes } : {}), + }; +} + +/** + * Names of remote servers that require OAuth but cannot be forwarded to the + * Cursor agent because no shareable client registration exists (dynamic + * registration, or a `needs_auth` runtime status with no configured + * `clientId`). The plugin surfaces these to the user instead of silently + * forwarding a spec that would 401. + */ +export function findUnshareableOAuthServers( + mcp: Config["mcp"], + status?: McpStatusMap, +): string[] { + const names: string[] = []; + if (!mcp) return names; + for (const [name, entry] of Object.entries(mcp) as Array< + [string, OpencodeMcpEntry] + >) { + if (!entry || entry.type !== "remote") continue; + if (!status && entry.enabled === false) continue; + const s = status?.[name]?.status; + if (status && s !== "connected" && !NEEDS_AUTH_STATUS.has(s ?? "")) + continue; + const oauth = oauthConfig(entry); + const needsOAuth = Boolean(oauth) || NEEDS_AUTH_STATUS.has(s ?? ""); + if (needsOAuth && !toCursorAuth(oauth)) names.push(name); + } + return names; +} + /** * Translate opencode's configured MCP servers (`config.mcp`) into the Cursor * SDK's `McpServerConfig` shape so the same servers can be handed @@ -12,11 +83,15 @@ type OpencodeMcpEntry = OpencodeMcp[string]; * * MCP servers are independent processes addressed by a launch spec, so opencode * and the Cursor agent can each connect to the same server. Disabled entries - * (`enabled: false`) are skipped. opencode-only fields with no Cursor - * equivalent (timeout, oauth) are dropped. + * (`enabled: false`) are skipped. The `timeout` field is dropped (no Cursor + * equivalent). OAuth is mapped where possible: a remote server's `oauth` client + * registration becomes Cursor's `auth` block so the agent runs its own OAuth + * flow; servers needing OAuth with no shareable `clientId` are skipped (the + * plugin reports them via {@link findUnshareableOAuthServers}). */ export function translateMcpServers( mcp: Config["mcp"], + status?: McpStatusMap, ): Record { const out: Record = {}; if (!mcp) return out; @@ -24,7 +99,18 @@ export function translateMcpServers( for (const [name, entry] of Object.entries(mcp) as Array< [string, OpencodeMcpEntry] >) { - if (!entry || entry.enabled === false) continue; + if (!entry) continue; + + // When a live status map is supplied (per-turn dynamic forwarding), it is + // the source of truth: forward only servers opencode has currently + // connected, so mid-session enable/disable propagates to the Cursor agent. + // Without it (the startup config snapshot), fall back to the static + // `enabled` flag. + if (status) { + if (status[name]?.status !== "connected") continue; + } else if (entry.enabled === false) { + continue; + } if (entry.type === "local") { const [command, ...args] = entry.command ?? []; @@ -39,12 +125,20 @@ export function translateMcpServers( }; } else if (entry.type === "remote") { if (!entry.url) continue; + const oauth = oauthConfig(entry); + const auth = toCursorAuth(oauth); + // OAuth server with no shareable client registration: opencode holds the + // token and it never lands in config.mcp, so skip rather than forward a + // bare URL that would 401. The plugin notifies the user (see + // findUnshareableOAuthServers). + if (oauth && !auth) continue; out[name] = { type: "http", url: entry.url, ...(entry.headers && Object.keys(entry.headers).length > 0 ? { headers: entry.headers } : {}), + ...(auth ? { auth } : {}), }; } } diff --git a/src/provider/delegate.ts b/src/provider/delegate.ts index de30d4b..5e61c30 100644 --- a/src/provider/delegate.ts +++ b/src/provider/delegate.ts @@ -5,36 +5,36 @@ import { resolveControls } from "./controls.js"; import { acquireAgent } from "./session-pool.js"; export interface DelegateParams { - apiKey: string; - /** The subtask to delegate to the Cursor agent. */ - prompt: string; - /** Cursor model id to run the delegation on. */ - model: string; - /** Conversation mode; defaults to "agent". */ - mode?: AgentModeOption; - /** Convenience for the Cursor `thinking` model param (e.g. "high"). */ - thinking?: string; - /** Working directory the local agent operates in. */ - cwd: string; - /** Run the agent's tools inside Cursor's sandbox. */ - sandbox?: boolean; - /** Resume a specific Cursor agent by id instead of creating a fresh one. */ - agentId?: string; - /** Cancels the run when aborted (wired to the tool's abort signal). */ - abortSignal?: AbortSignal; + apiKey: string; + /** The subtask to delegate to the Cursor agent. */ + prompt: string; + /** Cursor model id to run the delegation on. */ + model: string; + /** Conversation mode; defaults to "agent". */ + mode?: AgentModeOption; + /** Convenience for the Cursor `thinking` model param (e.g. "high"). */ + thinking?: string; + /** Working directory the local agent operates in. */ + cwd: string; + /** Run the agent's tools inside Cursor's sandbox. */ + sandbox?: boolean; + /** Resume a specific Cursor agent by id instead of creating a fresh one. */ + agentId?: string; + /** Cancels the run when aborted (wired to the tool's abort signal). */ + abortSignal?: AbortSignal; } export interface DelegateToolActivity { - name: string; - isError: boolean; + name: string; + isError: boolean; } export interface DelegateResult { - agentId: string; - text: string; - reasoning: string; - toolActivity: DelegateToolActivity[]; - usage?: CursorUsage; + agentId: string; + text: string; + reasoning: string; + toolActivity: DelegateToolActivity[]; + usage?: CursorUsage; } /** @@ -47,68 +47,73 @@ export interface DelegateResult { * is consumed eagerly here because a tool returns a single result rather than a * live stream. */ -export async function runDelegate(params: DelegateParams): Promise { - const { mode, modelSelection } = resolveControls( - params.model, - { - mode: params.mode ?? "agent", - ...(params.thinking ? { params: { thinking: params.thinking } } : {}), - }, - undefined, - ); +export async function runDelegate( + params: DelegateParams, +): Promise { + const { mode, modelSelection } = resolveControls( + params.model, + { + mode: params.mode ?? "agent", + ...(params.thinking ? { params: { thinking: params.thinking } } : {}), + }, + undefined, + ); - const acquired = await acquireAgent({ - apiKey: params.apiKey, - modelSelection, - mode, - cwd: params.cwd, - ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}), - ...(params.agentId ? { agentId: params.agentId } : {}), - session: false, - }); + const acquired = await acquireAgent({ + apiKey: params.apiKey, + modelSelection, + mode, + cwd: params.cwd, + ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}), + ...(params.agentId ? { resumeAgentId: params.agentId } : {}), + }); - const text: string[] = []; - const reasoning: string[] = []; - const toolActivity: DelegateToolActivity[] = []; - let usage: CursorUsage | undefined; + const text: string[] = []; + const reasoning: string[] = []; + const toolActivity: DelegateToolActivity[] = []; + let usage: CursorUsage | undefined; - try { - for await (const event of streamAgentTurn( - acquired.agent, - { text: params.prompt }, - { mode, ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}) }, - )) { - switch (event.type) { - case "text-delta": - text.push(event.text); - break; - case "reasoning-delta": - reasoning.push(event.text); - break; - case "tool-call": - toolActivity.push({ name: event.name, isError: false }); - break; - case "tool-result": - if (event.isError) toolActivity.push({ name: event.name, isError: true }); - break; - case "usage": - usage = event.usage; - break; - case "finish": - // The aggregated result text; prefer it when deltas were absent. - if (event.text && text.length === 0) text.push(event.text); - break; - } - } - } finally { - acquired.release(); - } + try { + for await (const event of streamAgentTurn( + acquired.agent, + { text: params.prompt }, + { + mode, + ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), + }, + )) { + switch (event.type) { + case "text-delta": + text.push(event.text); + break; + case "reasoning-delta": + reasoning.push(event.text); + break; + case "tool-call": + toolActivity.push({ name: event.name, isError: false }); + break; + case "tool-result": + if (event.isError) + toolActivity.push({ name: event.name, isError: true }); + break; + case "usage": + usage = event.usage; + break; + case "finish": + // The aggregated result text; prefer it when deltas were absent. + if (event.text && text.length === 0) text.push(event.text); + break; + } + } + } finally { + acquired.release(); + } - return { - agentId: acquired.agent.agentId, - text: text.join(""), - reasoning: reasoning.join(""), - toolActivity, - ...(usage ? { usage } : {}), - }; + return { + agentId: acquired.agent.agentId, + text: text.join(""), + reasoning: reasoning.join(""), + toolActivity, + ...(usage ? { usage } : {}), + }; } diff --git a/src/provider/index.ts b/src/provider/index.ts index d3f9f58..a9a49ea 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -50,10 +50,12 @@ export interface CursorProviderOptions { /** Cursor subagent definitions (`{ description, prompt, model?, mcpServers? }`). */ agents?: Record; /** - * Reuse one Cursor agent per opencode session (resume across turns instead of - * creating a fresh agent each turn). Off by default. + * Session reuse strategy: `"auto"` (default) resumes the pooled Cursor agent + * and sends only the new message on a clean continuation, falling back to a + * fresh agent + full transcript on edits/reverts/compaction/side calls; `true` + * is an alias for `"auto"`; `false` always creates a fresh agent per turn. */ - session?: boolean; + session?: boolean | "auto"; /** * How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced: * - `"blocks"` (default): structured provider-executed `tool-call`/ @@ -90,7 +92,7 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 { : {}), ...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}), ...(options.agents ? { agents: options.agents } : {}), - ...(options.session !== undefined ? { session: options.session } : {}), + session: options.session ?? "auto", toolDisplay: options.toolDisplay ?? "blocks", }; diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index de5e40f..7cdcd62 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -22,7 +22,12 @@ import { type ToolDisplay, } from "./stream-map.js"; import { resolveControls } from "./controls.js"; -import { acquireAgent } from "./session-pool.js"; +import { acquireAgent, getSessionRecord } from "./session-pool.js"; +import { + classifyTurn, + fingerprint, + mcpServersFingerprint, +} from "./transcript-fingerprint.js"; export interface CursorModelConfig { /** Provider id used for logging and the providerOptions key (e.g. "cursor"). */ @@ -44,11 +49,16 @@ export interface CursorModelConfig { /** Cursor subagent definitions made available to the agent. */ agents?: Record; /** - * Reuse one Cursor agent per opencode session (resume across turns, sending - * only the new message). Off by default; the default per-turn-fresh path - * re-sends the full transcript and is robust to opencode's non-chat calls. + * Session reuse strategy: + * - `"auto"` (default): fingerprint-guarded reuse — resume the pooled Cursor + * agent and send only the new message on a clean continuation, otherwise + * fall back to a fresh agent + full transcript. Robust to opencode's + * non-chat side calls, message edits, reverts, and compaction. + * - `true`: alias for `"auto"`. + * - `false`: always create a fresh agent per turn and re-send the full + * transcript (the original behavior; the escape hatch). */ - session?: boolean; + session?: boolean | "auto"; /** * How Cursor's internal tool activity is surfaced (see {@link ToolDisplay}). * Defaults to `"blocks"`. @@ -106,13 +116,73 @@ export class CursorLanguageModel implements LanguageModelV3 { typeof providerOptions?.["sessionID"] === "string" ? (providerOptions["sessionID"] as string) : undefined; - const useSession = this.config.session === true && Boolean(sessionID); + // MCP servers may be re-forwarded per turn by the plugin's chat.params hook + // (reflecting live opencode enable/disable). When present, the dynamic set + // wins over the static startup snapshot baked into config.mcpServers. + const dynamicMcp = providerOptions?.["mcpServers"] as + | Record + | undefined; + const mcpServers = dynamicMcp ?? this.config.mcpServers; + const mcpHash = mcpServersFingerprint(mcpServers); + // `session` defaults to "auto" (fingerprint-guarded reuse); `true` is an + // alias for "auto"; `false` keeps the per-turn-fresh full-transcript path. + const sessionEnabled = (this.config.session ?? "auto") !== false; // Power users can resume a specific Cursor agent via // `providerOptions.cursor.agentId`; it takes precedence over session pooling. const explicitAgentId = typeof providerOptions?.["agentId"] === "string" ? (providerOptions["agentId"] as string) : undefined; + // The plugin may mark opencode's non-chat side calls (e.g. title + // generation) so they never resume or disturb the pooled agent. + const ephemeral = providerOptions?.["ephemeral"] === true; + + // Decide create-vs-resume and whether to pool, from the turn classification. + const usePool = sessionEnabled && Boolean(sessionID) && !explicitAgentId; + let resumeAgentId: string | undefined = explicitAgentId; + let poolKey: string | undefined; + let record: + | { systemHash: string; userHashes: string[]; mcpHash?: string } + | undefined; + if (usePool) { + const classification = ephemeral + ? { + kind: "side-call" as const, + fingerprint: fingerprint(options.prompt), + } + : classifyTurn(getSessionRecord(sessionID!), options.prompt); + switch (classification.kind) { + case "continuation": { + const prev = getSessionRecord(sessionID!); + // A resumed agent keeps its original MCP servers, so only resume + // when the live MCP set is unchanged; otherwise create fresh so the + // new server set takes effect (re-pooled under the same session). + if (prev?.mcpHash === mcpHash) { + resumeAgentId = prev?.agentId; + } + poolKey = sessionID; + record = { ...classification.fingerprint, mcpHash }; + break; + } + case "new": + case "divergence": + poolKey = sessionID; + record = { ...classification.fingerprint, mcpHash }; + break; + case "side-call": + // fresh ephemeral agent; pool left untouched. + break; + } + if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") { + const label = + classification.kind === "continuation" + ? "resume" + : `fresh:${classification.kind}`; + console.error( + `[cursor:debug] turn classification=${label} session=${sessionID}`, + ); + } + } const acquired = await acquireAgent({ apiKey: this.requireApiKey(), @@ -125,12 +195,12 @@ export class CursorLanguageModel implements LanguageModelV3 { ...(this.config.sandbox !== undefined ? { sandbox: this.config.sandbox } : {}), - ...(this.config.mcpServers ? { mcpServers: this.config.mcpServers } : {}), + ...(mcpServers ? { mcpServers } : {}), ...(this.config.agents ? { agents: this.config.agents } : {}), - ...(useSession ? { name: `opencode/${sessionID!.slice(-8)}` } : {}), - ...(explicitAgentId ? { agentId: explicitAgentId } : {}), - sessionID, - session: useSession, + ...(poolKey ? { name: `opencode/${sessionID!.slice(-8)}` } : {}), + ...(resumeAgentId ? { resumeAgentId } : {}), + ...(poolKey ? { poolKey } : {}), + ...(record ? { record } : {}), }); // A resumed agent already remembers the prior conversation, so send only the diff --git a/src/provider/message-map.ts b/src/provider/message-map.ts index 9785c24..c5e5d53 100644 --- a/src/provider/message-map.ts +++ b/src/provider/message-map.ts @@ -1,6 +1,30 @@ import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; import type { SDKImage, SDKUserMessage } from "@cursor/sdk"; +/** + * Caps on inlined tool payloads in the flattened transcript. Tool outputs are + * INCLUDED (truncated) rather than dropped so a fresh/diverged agent still sees + * what prior tools produced, while a huge file read or search dump can't bloat + * the replayed message unbounded. + */ +const TOOL_RESULT_CAP = 2000; +const TOOL_ARGS_CAP = 500; + +function stringify(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value ?? null); + } catch { + return String(value); + } +} + +function truncate(text: string, cap: number): string { + return text.length > cap + ? `${text.slice(0, cap)}…[+${text.length - cap} chars]` + : text; +} + /** * Convert an AI-SDK prompt (the full conversation opencode sends on every call) * into a single Cursor `SDKUserMessage`. @@ -11,72 +35,86 @@ import type { SDKImage, SDKUserMessage } from "@cursor/sdk"; * the entire prompt into one transcript message. Images from the final user * turn are attached natively so multimodal models can see them. */ -export function promptToCursorMessage(prompt: LanguageModelV3Prompt): SDKUserMessage { - const lines: string[] = []; - const images: SDKImage[] = []; +export function promptToCursorMessage( + prompt: LanguageModelV3Prompt, +): SDKUserMessage { + const lines: string[] = []; + const images: SDKImage[] = []; - prompt.forEach((message, index) => { - const isLast = index === prompt.length - 1; - switch (message.role) { - case "system": - lines.push(`# System\n${message.content}`); - break; - case "user": { - const text: string[] = []; - for (const part of message.content) { - if (part.type === "text") text.push(part.text); - else if (part.type === "file" && part.mediaType.startsWith("image/")) { - const image = fileToImage(part.data, part.mediaType); - // Only attach images natively for the final user turn; earlier ones - // are referenced by transcript order. - if (isLast && image) images.push(image); - text.push("[image attached]"); - } - } - lines.push(`# User\n${text.join("\n")}`); - break; - } - case "assistant": { - const text: string[] = []; - for (const part of message.content) { - if (part.type === "text") text.push(part.text); - else if (part.type === "reasoning") text.push(`(thinking) ${part.text}`); - else if (part.type === "tool-call") text.push(`[called ${part.toolName}(${part.input})]`); - else if (part.type === "tool-result") text.push(`[result of ${part.toolName}]`); - } - lines.push(`# Assistant\n${text.join("\n")}`); - break; - } - case "tool": { - for (const part of message.content) { - if (part.type === "tool-result") { - lines.push(`# Tool result (${part.toolName})\n${JSON.stringify(part.output)}`); - } - } - break; - } - } - }); + prompt.forEach((message, index) => { + const isLast = index === prompt.length - 1; + switch (message.role) { + case "system": + lines.push(`# System\n${message.content}`); + break; + case "user": { + const text: string[] = []; + for (const part of message.content) { + if (part.type === "text") text.push(part.text); + else if ( + part.type === "file" && + part.mediaType.startsWith("image/") + ) { + const image = fileToImage(part.data, part.mediaType); + // Only attach images natively for the final user turn; earlier ones + // are referenced by transcript order. + if (isLast && image) images.push(image); + text.push("[image attached]"); + } + } + lines.push(`# User\n${text.join("\n")}`); + break; + } + case "assistant": { + const text: string[] = []; + for (const part of message.content) { + if (part.type === "text") text.push(part.text); + else if (part.type === "reasoning") + text.push(`(thinking) ${part.text}`); + else if (part.type === "tool-call") + text.push( + `[called ${part.toolName}(${truncate(stringify(part.input), TOOL_ARGS_CAP)})]`, + ); + else if (part.type === "tool-result") + text.push( + `[result of ${part.toolName}: ${truncate(stringify(part.output), TOOL_RESULT_CAP)}]`, + ); + } + lines.push(`# Assistant\n${text.join("\n")}`); + break; + } + case "tool": { + for (const part of message.content) { + if (part.type === "tool-result") { + lines.push( + `# Tool result (${part.toolName})\n${truncate(stringify(part.output), TOOL_RESULT_CAP)}`, + ); + } + } + break; + } + } + }); - const out: SDKUserMessage = { text: lines.join("\n\n") }; - if (images.length > 0) out.images = images; - return out; + const out: SDKUserMessage = { text: lines.join("\n\n") }; + if (images.length > 0) out.images = images; + return out; } function fileToImage( - data: string | Uint8Array | URL, - mediaType: string, + data: string | Uint8Array | URL, + mediaType: string, ): SDKImage | undefined { - if (data instanceof URL) return { url: data.toString() }; - if (typeof data === "string") { - // Either a URL or already-base64 encoded data. - if (/^https?:\/\//i.test(data)) return { url: data }; - return { data, mimeType: mediaType }; - } - if (data instanceof Uint8Array) { - return { data: Buffer.from(data).toString("base64"), mimeType: mediaType }; - } - return undefined; + if (data instanceof URL) return { url: data.toString() }; + if (typeof data === "string") { + // Either a URL or already-base64 encoded data. + if (/^https?:\/\//i.test(data)) return { url: data }; + return { data, mimeType: mediaType }; + } + if (data instanceof Uint8Array) { + return { data: Buffer.from(data).toString("base64"), mimeType: mediaType }; + } + return undefined; } /** @@ -85,23 +123,24 @@ function fileToImage( * the new message instead of the whole transcript. Returns `undefined` if the * last message isn't a user turn (caller should fall back to the full transcript). */ -export function latestUserMessage(prompt: LanguageModelV3Prompt): SDKUserMessage | undefined { - const last = prompt[prompt.length - 1]; - if (!last || last.role !== "user") return undefined; +export function latestUserMessage( + prompt: LanguageModelV3Prompt, +): SDKUserMessage | undefined { + const last = prompt[prompt.length - 1]; + if (!last || last.role !== "user") return undefined; - const text: string[] = []; - const images: SDKImage[] = []; - for (const part of last.content) { - if (part.type === "text") text.push(part.text); - else if (part.type === "file" && part.mediaType.startsWith("image/")) { - const image = fileToImage(part.data, part.mediaType); - if (image) images.push(image); - text.push("[image attached]"); - } - } + const text: string[] = []; + const images: SDKImage[] = []; + for (const part of last.content) { + if (part.type === "text") text.push(part.text); + else if (part.type === "file" && part.mediaType.startsWith("image/")) { + const image = fileToImage(part.data, part.mediaType); + if (image) images.push(image); + text.push("[image attached]"); + } + } - const out: SDKUserMessage = { text: text.join("\n") }; - if (images.length > 0) out.images = images; - return out; + const out: SDKUserMessage = { text: text.join("\n") }; + if (images.length > 0) out.images = images; + return out; } - diff --git a/src/provider/session-pool.ts b/src/provider/session-pool.ts index acafaf3..6cc3e47 100644 --- a/src/provider/session-pool.ts +++ b/src/provider/session-pool.ts @@ -1,106 +1,165 @@ import type { - AgentDefinition, - AgentModeOption, - McpServerConfig, - ModelSelection, - SettingSource, + AgentDefinition, + AgentModeOption, + McpServerConfig, + ModelSelection, + SettingSource, } from "@cursor/sdk"; import { loadAgentBackend, type AgentLike } from "./agent-backend.js"; +import { + deleteSessionStore, + loadSessionRecords, + saveSessionRecords, + type StoredSessionRecord, +} from "./session-store.js"; +import type { TranscriptRecord } from "./transcript-fingerprint.js"; -/** sessionID -> Cursor agentId, so a session reuses one Cursor agent across turns. */ -const pool = new Map(); +/** sessionID -> fingerprint record, so a session reuses one Cursor agent across turns. */ +const pool = new Map(); + +/** + * Lazily merge disk-persisted records into the in-memory pool (memory wins), + * so `session: "auto"` resumes a session's Cursor agent even after an opencode + * restart. The agent's conversation itself lives in Cursor's checkpoint store; + * this only restores our agentId + fingerprint bookkeeping. + */ +let hydrated = false; +function hydrate(): void { + if (hydrated) return; + hydrated = true; + for (const [key, record] of loadSessionRecords()) { + if (!pool.has(key)) pool.set(key, record); + } +} + +/** Read the fingerprint record pooled for a session (undefined if none). */ +export function getSessionRecord( + sessionID: string, +): TranscriptRecord | undefined { + hydrate(); + return pool.get(sessionID); +} /** Test/diagnostic helpers. */ export function getPooledAgentId(sessionID: string): string | undefined { - return pool.get(sessionID); + hydrate(); + return pool.get(sessionID)?.agentId; } export function clearAgentPool(): void { - pool.clear(); + pool.clear(); + hydrated = true; // don't re-hydrate stale disk state into a cleared pool + deleteSessionStore(); +} +/** Test hook: drop in-memory state only, as if the process restarted. */ +export function resetSessionPoolMemory(): void { + pool.clear(); + hydrated = false; } export interface AcquireAgentParams { - apiKey: string; - modelSelection: ModelSelection; - mode: AgentModeOption; - cwd: string; - settingSources?: SettingSource[]; - sandbox?: boolean; - mcpServers?: Record; - agents?: Record; - name?: string; - /** opencode session id; required for pooling. */ - sessionID?: string; - /** When true (and sessionID present) reuse/resume one agent per session. */ - session: boolean; - /** - * Resume a specific Cursor agent by id. Takes precedence over session - * pooling; lets power users continue an explicit agent (e.g. one returned by - * a prior tool call) rather than the session's auto-managed one. - */ - agentId?: string; + apiKey: string; + modelSelection: ModelSelection; + mode: AgentModeOption; + cwd: string; + settingSources?: SettingSource[]; + sandbox?: boolean; + mcpServers?: Record; + agents?: Record; + name?: string; + /** + * Resume this Cursor agent before falling back to a fresh create. Set for a + * fingerprinted "continuation" (the pooled agentId) or an explicit + * `providerOptions.cursor.agentId`. A failed resume degrades to create. + */ + resumeAgentId?: string; + /** + * Pool the resulting agent under this opencode session id. When set, the + * agent persists across turns (release() does not close it) and `record` is + * stored for the next turn's classification. When undefined, no pooling and + * the agent is closed on release. + */ + poolKey?: string; + /** Fingerprint of the current prompt, stored when `poolKey` is set. */ + record?: { systemHash: string; userHashes: string[]; mcpHash?: string }; } export interface AcquiredAgent { - agent: AgentLike; - /** True when an existing pooled agent was resumed (send only the new turn). */ - resumed: boolean; - /** Close the agent unless it's pooled (pooled agents persist for the next turn). */ - release: () => void; + agent: AgentLike; + /** True when an existing agent was resumed (send only the new turn). */ + resumed: boolean; + /** Close the agent unless it's pooled (pooled agents persist for the next turn). */ + release: () => void; } /** - * Get an agent to run a turn: resume the session's pooled agent when possible, - * otherwise create a fresh one. Resume failures fall back to creation, so a - * stale/expired pool entry degrades to a correct fresh turn rather than an error. + * Get an agent to run a turn. Attempts a resume of `resumeAgentId` when given, + * otherwise creates a fresh agent; a failed resume degrades to a fresh create + * (so a stale/expired pool entry becomes a correct full-transcript turn rather + * than an error). When `poolKey` is set, the resulting agent + `record` are + * pooled for the session and survive `release()`. */ -export async function acquireAgent(params: AcquireAgentParams): Promise { - const backend = loadAgentBackend(); - - const createOptions = { - apiKey: params.apiKey, - model: params.modelSelection, - mode: params.mode, - local: { - cwd: params.cwd, - ...(params.settingSources ? { settingSources: params.settingSources } : {}), - ...(params.sandbox !== undefined ? { sandboxOptions: { enabled: params.sandbox } } : {}), - }, - ...(params.mcpServers ? { mcpServers: params.mcpServers } : {}), - ...(params.agents ? { agents: params.agents } : {}), - ...(params.name ? { name: params.name } : {}), - }; +export async function acquireAgent( + params: AcquireAgentParams, +): Promise { + const backend = loadAgentBackend(); - const pooling = params.session && Boolean(params.sessionID); - const pooledId = pooling ? pool.get(params.sessionID!) : undefined; - // An explicit agentId wins over the session's pooled agent. - const resumeId = params.agentId ?? pooledId; + const createOptions = { + apiKey: params.apiKey, + model: params.modelSelection, + mode: params.mode, + local: { + cwd: params.cwd, + ...(params.settingSources + ? { settingSources: params.settingSources } + : {}), + ...(params.sandbox !== undefined + ? { sandboxOptions: { enabled: params.sandbox } } + : {}), + }, + ...(params.mcpServers ? { mcpServers: params.mcpServers } : {}), + ...(params.agents ? { agents: params.agents } : {}), + ...(params.name ? { name: params.name } : {}), + }; - let agent: AgentLike | undefined; - let resumed = false; - if (resumeId) { - try { - agent = await backend.resumeAgent(resumeId, createOptions); - resumed = true; - } catch { - // A stale/expired id degrades to a fresh agent; drop a matching pool entry. - if (pooledId && resumeId === pooledId) pool.delete(params.sessionID!); - } - } - if (!agent) { - agent = await backend.createAgent(createOptions); - } + let agent: AgentLike | undefined; + let resumed = false; + if (params.resumeAgentId) { + try { + agent = await backend.resumeAgent(params.resumeAgentId, createOptions); + resumed = true; + } catch { + // Stale/expired id: fall through to a fresh create (full replay). + } + } + if (!agent) { + agent = await backend.createAgent(createOptions); + } - if (pooling) pool.set(params.sessionID!, agent.agentId); + const pooling = params.poolKey !== undefined; + if (pooling && params.record) { + hydrate(); + pool.set(params.poolKey!, { + agentId: agent.agentId, + systemHash: params.record.systemHash, + userHashes: params.record.userHashes, + ...(params.record.mcpHash !== undefined + ? { mcpHash: params.record.mcpHash } + : {}), + updatedAt: Date.now(), + }); + // Persist so session reuse survives opencode restarts (best-effort). + saveSessionRecords(pool); + } - const release = () => { - if (!pooling) { - try { - agent!.close(); - } catch { - // best effort - } - } - }; + const release = () => { + if (!pooling) { + try { + agent!.close(); + } catch { + // best effort + } + } + }; - return { agent, resumed, release }; + return { agent, resumed, release }; } diff --git a/src/provider/session-store.ts b/src/provider/session-store.ts new file mode 100644 index 0000000..dd06ed6 --- /dev/null +++ b/src/provider/session-store.ts @@ -0,0 +1,101 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TranscriptRecord } from "./transcript-fingerprint.js"; + +/** + * Best-effort disk persistence for the session pool's fingerprint records, so + * `session: "auto"` survives opencode restarts: the pool can re-resume a + * session's Cursor agent (whose conversation lives in Cursor's own checkpoint + * store) instead of paying a cache-cold full-transcript replay. + * + * Follows the model-cache pattern: JSON under `~/.cache/opencode-cursor/`, + * never throws, treats the file as an optimization only. Multiple opencode + * processes write last-wins on the whole file — a lost record costs exactly + * one self-healing full replay, which is the same as not having the store. + */ + +/** A record persists this long after its last turn before being pruned. */ +const ENTRY_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** Cap stored sessions (most recently used win) to bound file growth. */ +const MAX_ENTRIES = 200; + +export interface StoredSessionRecord extends TranscriptRecord { + updatedAt: number; +} + +interface StoreEnvelope { + sessions: Record; +} + +function storeDir(): string { + const base = + process.env.XDG_CACHE_HOME?.trim() || + (homedir() ? join(homedir(), ".cache") : tmpdir()); + return join(base, "opencode-cursor"); +} + +function storeFile(): string { + return join(storeDir(), "session-pool.json"); +} + +function isStoredRecord(value: unknown): value is StoredSessionRecord { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v["agentId"] === "string" && + typeof v["systemHash"] === "string" && + Array.isArray(v["userHashes"]) && + (v["userHashes"] as unknown[]).every((h) => typeof h === "string") && + typeof v["updatedAt"] === "number" + ); +} + +/** Load persisted records, dropping expired/corrupt entries. Never throws. */ +export function loadSessionRecords( + now = Date.now(), +): Map { + const out = new Map(); + try { + const parsed = JSON.parse( + readFileSync(storeFile(), "utf8"), + ) as StoreEnvelope; + if (typeof parsed?.sessions !== "object" || parsed.sessions === null) + return out; + for (const [key, value] of Object.entries(parsed.sessions)) { + if (!isStoredRecord(value)) continue; + if (now - value.updatedAt > ENTRY_TTL_MS) continue; + out.set(key, value); + } + } catch { + // Missing/corrupt store: start empty. + } + return out; +} + +/** Persist records (pruned to TTL + entry cap). Best-effort; never throws. */ +export function saveSessionRecords( + records: ReadonlyMap, + now = Date.now(), +): void { + try { + const live = [...records.entries()] + .filter(([, r]) => now - r.updatedAt <= ENTRY_TTL_MS) + .sort(([, a], [, b]) => b.updatedAt - a.updatedAt) + .slice(0, MAX_ENTRIES); + mkdirSync(storeDir(), { recursive: true }); + const envelope: StoreEnvelope = { sessions: Object.fromEntries(live) }; + writeFileSync(storeFile(), JSON.stringify(envelope), "utf8"); + } catch { + // Persistence is an optimization; ignore write failures. + } +} + +/** Delete the store file (test/diagnostic helper). Never throws. */ +export function deleteSessionStore(): void { + try { + rmSync(storeFile(), { force: true }); + } catch { + // best effort + } +} diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 2da15d6..1c7a9e5 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -967,12 +967,22 @@ export function cursorEventsToStream( break; case "tool-call": if (toolDisplay === "blocks") { - for (const part of blockToolCallParts( + const parts = blockToolCallParts( event.id, event.name, event.input, toolState, - )) { + ); + // Edit calls buffer until their result (no parts yet) — keep + // the open narration part alive across the gap. Every other + // tool emits immediately, so close open text/reasoning first + // so post-tool narration lands in a later part (hosts position + // parts where they START). + if (parts.length > 0) { + closeText(); + closeReasoning(); + } + for (const part of parts) { controller.enqueue(part); } } else { @@ -981,13 +991,18 @@ export function cursorEventsToStream( break; case "tool-result": if (toolDisplay === "blocks") { - for (const part of blockToolResultParts( + const parts = blockToolResultParts( event.id, event.name, event.result, event.isError, toolState, - )) { + ); + if (parts.length > 0) { + closeText(); + closeReasoning(); + } + for (const part of parts) { controller.enqueue(part); } } else if (event.isError) { diff --git a/src/provider/transcript-fingerprint.ts b/src/provider/transcript-fingerprint.ts new file mode 100644 index 0000000..c683c68 --- /dev/null +++ b/src/provider/transcript-fingerprint.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import type { McpServerConfig } from "@cursor/sdk"; + +/** + * Per-session bookkeeping that lets the provider decide, on each turn, whether + * it can safely resume the pooled Cursor agent (and send only the new message) + * or must start fresh and re-send the whole transcript. + * + * We hash ONLY the parts opencode replays verbatim — the system prompt and the + * user messages. We deliberately do NOT hash assistant output: opencode + * re-serializes our streamed reply (reasoning, tool blocks) back into the next + * prompt in a shape we can't predict byte-for-byte, so hashing it would + * spuriously mismatch every turn and silently collapse to always-full-replay. + * The system prompt + user-message sequence is the stable identity of a + * conversation. + */ +export interface TranscriptRecord { + /** Cursor agentId currently pooled for the session. */ + agentId: string; + /** Hash of the concatenated system message content. */ + systemHash: string; + /** Ordered hash per user message (text + a stable image token). */ + userHashes: string[]; + /** + * Hash of the MCP server set the pooled agent was created with. A resumed + * Cursor agent keeps its original MCP servers, so when this changes between + * turns the pool must create a fresh agent rather than resume. + */ + mcpHash?: string; +} + +/** What kind of turn this is relative to the session's last recorded state. */ +export type TurnKind = + /** No prior record: first turn of the session. */ + | "new" + /** System prompt differs (opencode's non-chat side call, e.g. title gen). */ + | "side-call" + /** Prior user sequence is a strict prefix + exactly one new trailing user msg. */ + | "continuation" + /** Edit / revert / compaction / multiple queued msgs — prior prefix no longer holds. */ + | "divergence"; + +export interface TurnClassification { + kind: TurnKind; + /** Fingerprint of the CURRENT prompt, to store when (re)pooling. */ + fingerprint: { systemHash: string; userHashes: string[] }; +} + +function sha(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} + +/** + * Stable hash of the MCP server set handed to `Agent.create`. Keys are sorted + * so map ordering never changes the result; empty/undefined sets hash to "". + */ +export function mcpServersFingerprint( + servers: Record | undefined, +): string { + if (!servers) return ""; + const keys = Object.keys(servers).sort(); + if (keys.length === 0) return ""; + return sha(JSON.stringify(keys.map((k) => [k, servers[k]]))); +} + +/** Stable key for one user message: its text plus a token per attached image. */ +function userMessageKey( + message: Extract, +): string { + const parts: string[] = []; + for (const part of message.content) { + if (part.type === "text") parts.push(`t:${part.text}`); + else if (part.type === "file") parts.push(`f:${part.mediaType}`); + } + return parts.join("\n"); +} + +/** Compute the system + user-message fingerprint of a prompt. */ +export function fingerprint(prompt: LanguageModelV3Prompt): { + systemHash: string; + userHashes: string[]; +} { + const systemParts: string[] = []; + const userHashes: string[] = []; + for (const message of prompt) { + if (message.role === "system") systemParts.push(message.content); + else if (message.role === "user") + userHashes.push(sha(userMessageKey(message))); + } + return { systemHash: sha(systemParts.join("\n")), userHashes }; +} + +/** True when `prefix` is a strict element-wise prefix of `full`. */ +function isStrictPrefix(prefix: string[], full: string[]): boolean { + if (prefix.length >= full.length) return false; + for (let i = 0; i < prefix.length; i++) { + if (prefix[i] !== full[i]) return false; + } + return true; +} + +/** + * Classify the current turn against the session's last recorded fingerprint. + * + * Order matters: + * 1. no record -> "new" + * 2. system prompt changed -> "side-call" (don't touch the pool) + * 3. prior user hashes are a strict prefix AND exactly one new trailing user + * message AND the last prompt message is a user turn -> "continuation" + * 4. otherwise -> "divergence" (edit/revert/compaction/queued) + * + * Worst case on any misclassification is a single wasted full replay that + * self-heals on the next turn — never worse than the `session: false` default. + */ +export function classifyTurn( + prev: TranscriptRecord | undefined, + prompt: LanguageModelV3Prompt, +): TurnClassification { + const fp = fingerprint(prompt); + if (!prev) return { kind: "new", fingerprint: fp }; + if (prev.systemHash !== fp.systemHash) + return { kind: "side-call", fingerprint: fp }; + + const lastIsUser = prompt[prompt.length - 1]?.role === "user"; + const exactlyOneNew = fp.userHashes.length === prev.userHashes.length + 1; + if ( + lastIsUser && + exactlyOneNew && + isStrictPrefix(prev.userHashes, fp.userHashes) + ) { + return { kind: "continuation", fingerprint: fp }; + } + return { kind: "divergence", fingerprint: fp }; +} diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts index 2999fd3..bac3c98 100644 --- a/test/mcp-config.test.ts +++ b/test/mcp-config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { Config } from "@opencode-ai/plugin"; -import { translateMcpServers } from "../src/plugin/mcp-config.js"; +import { + findUnshareableOAuthServers, + translateMcpServers, +} from "../src/plugin/mcp-config.js"; import plugin from "../src/plugin/index.js"; describe("translateMcpServers", () => { @@ -67,6 +70,93 @@ describe("translateMcpServers", () => { }); }); +describe("findUnshareableOAuthServers", () => { + it("flags OAuth remotes with no clientId, not ones with a clientId", () => { + const mcp: Config["mcp"] = { + dynamic: { type: "remote", url: "https://dyn", oauth: {} }, + configured: { + type: "remote", + url: "https://cfg", + oauth: { clientId: "cid" }, + }, + plain: { type: "remote", url: "https://plain" }, + local: { type: "local", command: ["node"] }, + }; + expect(findUnshareableOAuthServers(mcp)).toEqual(["dynamic"]); + }); + + it("flags needs_auth servers with no usable clientId from the live status", () => { + const mcp: Config["mcp"] = { + needsauth: { type: "remote", url: "https://na" }, + connected: { type: "remote", url: "https://ok" }, + }; + const status = { + needsauth: { status: "needs_auth" }, + connected: { status: "connected" }, + }; + expect(findUnshareableOAuthServers(mcp, status)).toEqual(["needsauth"]); + }); + + it("maps a remote server's OAuth clientId to Cursor's auth block", () => { + const mcp: Config["mcp"] = { + notion: { + type: "remote", + url: "https://notion", + oauth: { clientId: "cid", clientSecret: "sec", scope: "read write" }, + }, + }; + expect(translateMcpServers(mcp)).toEqual({ + notion: { + type: "http", + url: "https://notion", + auth: { + CLIENT_ID: "cid", + CLIENT_SECRET: "sec", + scopes: ["read", "write"], + }, + }, + }); + }); + + it("skips a remote OAuth server with no shareable clientId (dynamic registration)", () => { + const mcp: Config["mcp"] = { + notion: { type: "remote", url: "https://notion", oauth: {} }, + plain: { type: "remote", url: "https://plain" }, + }; + expect(translateMcpServers(mcp)).toEqual({ + plain: { type: "http", url: "https://plain" }, + }); + }); + + it("treats oauth:false as a plain http server", () => { + const mcp: Config["mcp"] = { + plain: { type: "remote", url: "https://plain", oauth: false }, + }; + expect(translateMcpServers(mcp)).toEqual({ + plain: { type: "http", url: "https://plain" }, + }); + }); + + it("with a live status map, forwards only connected servers (ignoring `enabled`)", () => { + const mcp: Config["mcp"] = { + // enabled:false in config, but opencode connected it mid-session + live: { type: "local", command: ["node", "s.js"], enabled: false }, + // enabled in config, but disconnected mid-session + gone: { type: "local", command: ["node", "g.js"] }, + // failed to connect -> not forwarded + broken: { type: "remote", url: "https://broken" }, + }; + const status = { + live: { status: "connected" }, + gone: { status: "disabled" }, + broken: { status: "failed" }, + }; + expect(translateMcpServers(mcp, status)).toEqual({ + live: { type: "stdio", command: "node", args: ["s.js"] }, + }); + }); +}); + describe("plugin config hook MCP forwarding", () => { it("forwards opencode's configured MCP servers into provider.cursor.options", async () => { const hooks = await plugin({} as never); @@ -91,3 +181,153 @@ describe("plugin config hook MCP forwarding", () => { expect(opts.mcpServers).toBeUndefined(); }); }); + +describe("chat.params dynamic MCP re-forwarding", () => { + // A mock opencode client returning live config + MCP status. + function fakeClient( + mcp: Config["mcp"], + status: Record, + ) { + const toasts: Array<{ message: string; variant: string }> = []; + return { + toasts, + config: { get: async () => ({ data: { mcp } }) }, + mcp: { status: async () => ({ data: status }) }, + tui: { + showToast: async (opts: { + body: { message: string; variant: string }; + }) => { + toasts.push(opts.body); + return { data: true }; + }, + }, + }; + } + + const chatInput = (over: Record = {}) => ({ + sessionID: "s1", + agent: "build", + model: { providerID: "cursor", modelID: "m" }, + ...over, + }); + + it("injects the live (connected-only) MCP set into output.options", async () => { + const client = fakeClient( + { + serena: { type: "local", command: ["serena", "start"] }, + notion: { type: "remote", url: "https://notion", enabled: false }, + }, + { serena: { status: "connected" }, notion: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + // config hook must run first to capture forwardMcp/userMcp + provider opts. + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + const opts = output.options as Record; + expect(opts.sessionID).toBe("s1"); + expect(opts.mcpServers).toEqual({ + serena: { type: "stdio", command: "serena", args: ["start"] }, + notion: { type: "http", url: "https://notion" }, + }); + }); + + it("drops servers opencode disconnected mid-session", async () => { + const client = fakeClient( + { + serena: { type: "local", command: ["serena"] }, + notion: { type: "remote", url: "https://notion" }, + }, + { serena: { status: "connected" }, notion: { status: "disabled" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + const opts = output.options as Record; + expect(opts.mcpServers).toEqual({ + serena: { type: "stdio", command: "serena" }, + }); + }); + + it("does not re-forward for non-cursor models", async () => { + const client = fakeClient( + { serena: { type: "local", command: ["serena"] } }, + { serena: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!( + chatInput({ model: { providerID: "anthropic", modelID: "x" } }) as never, + output as never, + ); + expect( + (output.options as Record).mcpServers, + ).toBeUndefined(); + }); + + it("skips re-forwarding when forwardMcp is false", async () => { + const client = fakeClient( + { serena: { type: "local", command: ["serena"] } }, + { serena: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ + mcp: {}, + provider: { cursor: { options: { forwardMcp: false } } }, + } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + expect( + (output.options as Record).mcpServers, + ).toBeUndefined(); + }); + + it("forwards an OAuth server with a clientId (as auth) and skips one without", async () => { + const client = fakeClient( + { + configured: { + type: "remote", + url: "https://cfg", + oauth: { clientId: "cid" }, + }, + dynamic: { type: "remote", url: "https://dyn", oauth: {} }, + }, + { configured: { status: "connected" }, dynamic: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + expect((output.options as Record).mcpServers).toEqual({ + configured: { + type: "http", + url: "https://cfg", + auth: { CLIENT_ID: "cid" }, + }, + }); + expect(client.toasts).toHaveLength(1); + expect(client.toasts[0]!.variant).toBe("warning"); + expect(client.toasts[0]!.message).toContain("dynamic"); + }); + + it("warns about an OAuth server only once across turns", async () => { + const client = fakeClient( + { dynamic: { type: "remote", url: "https://dyn", oauth: {} } }, + { dynamic: { status: "connected" } }, + ); + const hooks = await plugin({ client } as never); + await hooks.config!({ mcp: {} } as Config); + + const output: Record = { options: {} }; + await hooks["chat.params"]!(chatInput() as never, output as never); + await hooks["chat.params"]!(chatInput() as never, output as never); + expect(client.toasts).toHaveLength(1); + }); +}); diff --git a/test/message-map.test.ts b/test/message-map.test.ts index 236f9c3..02a7a2b 100644 --- a/test/message-map.test.ts +++ b/test/message-map.test.ts @@ -41,6 +41,55 @@ describe("promptToCursorMessage", () => { expect(msg.text).toContain("[image attached]"); }); + it("includes tool outputs (truncated) instead of dropping them", () => { + const bigOutput = "x".repeat(5000); + const prompt: LanguageModelV3Prompt = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "c1", + toolName: "read", + input: { path: "/a.ts" }, + } as never, + { + type: "tool-result", + toolCallId: "c1", + toolName: "read", + output: { type: "text", value: bigOutput }, + } as never, + ], + }, + ]; + const msg = promptToCursorMessage(prompt); + expect(msg.text).toContain('[called read({"path":"/a.ts"})]'); + expect(msg.text).toContain("[result of read:"); + // Output is present but capped well under its 5000-char size. + expect(msg.text).toContain("chars]"); + expect(msg.text.length).toBeLessThan(3000); + }); + + it("caps tool-role result JSON", () => { + const prompt: LanguageModelV3Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "c1", + toolName: "grep", + output: { type: "text", value: "y".repeat(5000) }, + } as never, + ], + }, + ]; + const msg = promptToCursorMessage(prompt); + expect(msg.text).toContain("# Tool result (grep)"); + expect(msg.text).toContain("chars]"); + expect(msg.text.length).toBeLessThan(3000); + }); + it("passes through image URLs", () => { const prompt: LanguageModelV3Prompt = [ { diff --git a/test/session-pool.test.ts b/test/session-pool.test.ts index 5d962e6..9c5b04f 100644 --- a/test/session-pool.test.ts +++ b/test/session-pool.test.ts @@ -1,102 +1,172 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +// Sandbox the on-disk session store away from the user's real cache dir. +process.env.XDG_CACHE_HOME = mkdtempSync(join(tmpdir(), "cursor-pool-test-")); + const create = vi.fn(); const resume = vi.fn(); vi.mock("../src/cursor-runtime.js", () => ({ - loadCursorSdk: async () => ({ Agent: { create, resume } }), + loadCursorSdk: async () => ({ Agent: { create, resume } }), })); -const { acquireAgent, clearAgentPool, getPooledAgentId } = await import( - "../src/provider/session-pool.js" -); +const { + acquireAgent, + clearAgentPool, + getPooledAgentId, + getSessionRecord, + resetSessionPoolMemory, +} = await import("../src/provider/session-pool.js"); function fakeAgent(agentId: string) { - return { agentId, close: vi.fn() }; + return { agentId, close: vi.fn() }; } const base = { - apiKey: "k", - modelSelection: { id: "m" }, - mode: "agent" as const, - cwd: "/tmp", + apiKey: "k", + modelSelection: { id: "m" }, + mode: "agent" as const, + cwd: "/tmp", }; +const rec = { systemHash: "sys", userHashes: ["u1"] }; + afterEach(() => { - create.mockReset(); - resume.mockReset(); - clearAgentPool(); + create.mockReset(); + resume.mockReset(); + clearAgentPool(); }); describe("acquireAgent", () => { - it("creates a fresh, non-pooled agent when session is disabled", async () => { - create.mockResolvedValue(fakeAgent("a1")); - const r = await acquireAgent({ ...base, session: false }); - expect(create).toHaveBeenCalledOnce(); - expect(r.resumed).toBe(false); - expect(getPooledAgentId("s1")).toBeUndefined(); - r.release(); - expect(r.agent.close).toHaveBeenCalled(); // non-pooled agents are closed - }); - - it("creates and pools an agent for a session, and does not close it on release", async () => { - create.mockResolvedValue(fakeAgent("a1")); - const r = await acquireAgent({ ...base, session: true, sessionID: "s1" }); - expect(r.resumed).toBe(false); - expect(getPooledAgentId("s1")).toBe("a1"); - r.release(); - expect(r.agent.close).not.toHaveBeenCalled(); // pooled agents persist - }); - - it("resumes the pooled agent on the next turn for the same session", async () => { - create.mockResolvedValue(fakeAgent("a1")); - await acquireAgent({ ...base, session: true, sessionID: "s1" }); - - resume.mockResolvedValue(fakeAgent("a1")); - const r2 = await acquireAgent({ ...base, session: true, sessionID: "s1" }); - expect(resume).toHaveBeenCalledWith("a1", expect.anything()); - expect(r2.resumed).toBe(true); - }); - - it("falls back to creating a fresh agent when resume fails", async () => { - create.mockResolvedValueOnce(fakeAgent("a1")); - await acquireAgent({ ...base, session: true, sessionID: "s1" }); - - resume.mockRejectedValue(new Error("agent expired")); - create.mockResolvedValueOnce(fakeAgent("a2")); - const r = await acquireAgent({ ...base, session: true, sessionID: "s1" }); - expect(r.resumed).toBe(false); - expect(getPooledAgentId("s1")).toBe("a2"); - }); - - it("resumes an explicit agentId without session pooling", async () => { - resume.mockResolvedValue(fakeAgent("explicit")); - const r = await acquireAgent({ ...base, session: false, agentId: "explicit" }); - expect(resume).toHaveBeenCalledWith("explicit", expect.anything()); - expect(create).not.toHaveBeenCalled(); - expect(r.resumed).toBe(true); - }); - - it("prefers an explicit agentId over the session's pooled agent", async () => { - create.mockResolvedValue(fakeAgent("pooled")); - await acquireAgent({ ...base, session: true, sessionID: "s1" }); - expect(getPooledAgentId("s1")).toBe("pooled"); - - resume.mockResolvedValue(fakeAgent("explicit")); - const r = await acquireAgent({ ...base, session: true, sessionID: "s1", agentId: "explicit" }); - expect(resume).toHaveBeenCalledWith("explicit", expect.anything()); - expect(r.resumed).toBe(true); - }); - - it("falls back to creating when an explicit agentId resume fails", async () => { - resume.mockRejectedValue(new Error("explicit agent gone")); - create.mockResolvedValue(fakeAgent("fresh")); - - const r = await acquireAgent({ ...base, session: false, agentId: "missing" }); - - expect(resume).toHaveBeenCalledWith("missing", expect.anything()); - expect(create).toHaveBeenCalledOnce(); - expect(r.resumed).toBe(false); - expect(r.agent.agentId).toBe("fresh"); - }); + it("creates a fresh, non-pooled agent when no poolKey is given", async () => { + create.mockResolvedValue(fakeAgent("a1")); + const r = await acquireAgent({ ...base }); + expect(create).toHaveBeenCalledOnce(); + expect(r.resumed).toBe(false); + expect(getPooledAgentId("s1")).toBeUndefined(); + r.release(); + expect(r.agent.close).toHaveBeenCalled(); // non-pooled agents are closed + }); + + it("pools the agent + record under poolKey and does not close it on release", async () => { + create.mockResolvedValue(fakeAgent("a1")); + const r = await acquireAgent({ ...base, poolKey: "s1", record: rec }); + expect(r.resumed).toBe(false); + expect(getPooledAgentId("s1")).toBe("a1"); + expect(getSessionRecord("s1")).toMatchObject({ agentId: "a1", ...rec }); + r.release(); + expect(r.agent.close).not.toHaveBeenCalled(); // pooled agents persist + }); + + it("resumes the given resumeAgentId", async () => { + resume.mockResolvedValue(fakeAgent("a1")); + const r = await acquireAgent({ + ...base, + resumeAgentId: "a1", + poolKey: "s1", + record: rec, + }); + expect(resume).toHaveBeenCalledWith("a1", expect.anything()); + expect(r.resumed).toBe(true); + expect(getPooledAgentId("s1")).toBe("a1"); + }); + + it("falls back to creating a fresh agent when resume fails, re-pooling the new id", async () => { + resume.mockRejectedValue(new Error("agent expired")); + create.mockResolvedValue(fakeAgent("a2")); + const r = await acquireAgent({ + ...base, + resumeAgentId: "stale", + poolKey: "s1", + record: rec, + }); + expect(r.resumed).toBe(false); + expect(getPooledAgentId("s1")).toBe("a2"); + }); + + it("persists mcpHash in the pooled record when provided", async () => { + create.mockResolvedValue(fakeAgent("a1")); + await acquireAgent({ + ...base, + poolKey: "s1", + record: { ...rec, mcpHash: "mcp-v1" }, + }); + expect(getSessionRecord("s1")).toMatchObject({ + agentId: "a1", + ...rec, + mcpHash: "mcp-v1", + }); + }); + + it("re-pools a new record under the same session (divergence)", async () => { + create.mockResolvedValueOnce(fakeAgent("a1")); + await acquireAgent({ ...base, poolKey: "s1", record: rec }); + expect(getPooledAgentId("s1")).toBe("a1"); + + create.mockResolvedValueOnce(fakeAgent("a2")); + const next = { systemHash: "sys", userHashes: ["u1", "u2", "edited"] }; + await acquireAgent({ ...base, poolKey: "s1", record: next }); + expect(getSessionRecord("s1")).toMatchObject({ agentId: "a2", ...next }); + }); + + it("survives a process restart: records rehydrate from disk", async () => { + create.mockResolvedValue(fakeAgent("a1")); + await acquireAgent({ + ...base, + poolKey: "s1", + record: { ...rec, mcpHash: "mcp-v1" }, + }); + + // Simulate an opencode restart: in-memory pool gone, disk store intact. + resetSessionPoolMemory(); + expect(getSessionRecord("s1")).toMatchObject({ + agentId: "a1", + ...rec, + mcpHash: "mcp-v1", + }); + }); + + it("prefers in-memory state over stale disk state when both exist", async () => { + create.mockResolvedValueOnce(fakeAgent("a1")); + await acquireAgent({ ...base, poolKey: "s1", record: rec }); + + // Restart, rehydrate, then advance the conversation in-memory. + resetSessionPoolMemory(); + create.mockResolvedValueOnce(fakeAgent("a2")); + const next = { systemHash: "sys", userHashes: ["u1", "u2"] }; + await acquireAgent({ ...base, poolKey: "s1", record: next }); + expect(getSessionRecord("s1")).toMatchObject({ agentId: "a2", ...next }); + }); + + it("clearAgentPool wipes the disk store too", async () => { + create.mockResolvedValue(fakeAgent("a1")); + await acquireAgent({ ...base, poolKey: "s1", record: rec }); + clearAgentPool(); + resetSessionPoolMemory(); // would rehydrate if the file survived + expect(getSessionRecord("s1")).toBeUndefined(); + }); + + it("resumes an explicit agent without pooling (no poolKey)", async () => { + resume.mockResolvedValue(fakeAgent("explicit")); + const r = await acquireAgent({ ...base, resumeAgentId: "explicit" }); + expect(resume).toHaveBeenCalledWith("explicit", expect.anything()); + expect(create).not.toHaveBeenCalled(); + expect(r.resumed).toBe(true); + expect(getSessionRecord("s1")).toBeUndefined(); + r.release(); + expect(r.agent.close).toHaveBeenCalled(); + }); + + it("does not touch the pool when poolKey is omitted (side-call)", async () => { + create.mockResolvedValueOnce(fakeAgent("a1")); + await acquireAgent({ ...base, poolKey: "s1", record: rec }); + + // A side call: fresh agent, no poolKey -> pool entry must be untouched. + create.mockResolvedValueOnce(fakeAgent("title-gen")); + await acquireAgent({ ...base }); + expect(getPooledAgentId("s1")).toBe("a1"); + }); }); diff --git a/test/session-store.test.ts b/test/session-store.test.ts new file mode 100644 index 0000000..bcfd2ec --- /dev/null +++ b/test/session-store.test.ts @@ -0,0 +1,109 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + deleteSessionStore, + loadSessionRecords, + saveSessionRecords, + type StoredSessionRecord, +} from "../src/provider/session-store.js"; + +function record( + agentId: string, + updatedAt: number, + extra?: Partial, +): StoredSessionRecord { + return { + agentId, + systemHash: "sys", + userHashes: ["u1"], + updatedAt, + ...extra, + }; +} + +const DAY = 24 * 60 * 60 * 1000; + +beforeEach(() => { + process.env.XDG_CACHE_HOME = mkdtempSync( + join(tmpdir(), "cursor-store-test-"), + ); +}); + +describe("session store", () => { + it("round-trips records, including mcpHash", () => { + const now = Date.now(); + const map = new Map([ + ["s1", record("a1", now, { mcpHash: "mcp-v1" })], + ["s2", record("a2", now)], + ]); + saveSessionRecords(map, now); + const loaded = loadSessionRecords(now); + expect(loaded.get("s1")).toMatchObject({ + agentId: "a1", + mcpHash: "mcp-v1", + }); + expect(loaded.get("s2")).toMatchObject({ agentId: "a2" }); + }); + + it("prunes entries older than the TTL on load", () => { + const now = Date.now(); + const map = new Map([ + ["fresh", record("a1", now - 1 * DAY)], + ["stale", record("a2", now - 8 * DAY)], + ]); + saveSessionRecords(map, now - 8 * DAY); // bypass save-side pruning for "stale" + // Re-save with both to exercise load-side pruning at `now`. + saveSessionRecords(map, now - 1 * DAY); + const loaded = loadSessionRecords(now); + expect(loaded.has("fresh")).toBe(true); + expect(loaded.has("stale")).toBe(false); + }); + + it("caps stored entries to the most recently used", () => { + const now = Date.now(); + const map = new Map(); + for (let i = 0; i < 250; i++) { + map.set(`s${i}`, record(`a${i}`, now - i)); + } + saveSessionRecords(map, now); + const loaded = loadSessionRecords(now); + expect(loaded.size).toBe(200); + expect(loaded.has("s0")).toBe(true); // newest kept + expect(loaded.has("s249")).toBe(false); // oldest dropped + }); + + it("returns empty on a missing store", () => { + expect(loadSessionRecords().size).toBe(0); + }); + + it("returns empty on a corrupt store and skips malformed entries", () => { + const dir = join(process.env.XDG_CACHE_HOME!, "opencode-cursor"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "session-pool.json"), "not json", "utf8"); + expect(loadSessionRecords().size).toBe(0); + + const now = Date.now(); + writeFileSync( + join(dir, "session-pool.json"), + JSON.stringify({ + sessions: { + good: record("a1", now), + bad: { agentId: 42, updatedAt: "nope" }, + }, + }), + "utf8", + ); + const loaded = loadSessionRecords(now); + expect(loaded.has("good")).toBe(true); + expect(loaded.has("bad")).toBe(false); + }); + + it("deleteSessionStore removes the file", () => { + const now = Date.now(); + saveSessionRecords(new Map([["s1", record("a1", now)]]), now); + deleteSessionStore(); + expect(loadSessionRecords(now).size).toBe(0); + }); +}); diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 8624d35..0f85043 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -92,6 +92,128 @@ describe("cursorEventsToStream", () => { }); }); + it("closes the open text part before tool blocks so post-tool text renders below them", async () => { + // Interleaved turn: narration → tool activity → conclusion. The conclusion + // must land in a NEW part that starts after the tool block — appending it + // to the pre-tool part makes it render ABOVE the tool block in the UI. + const events: CursorEvent[] = [ + { type: "text-delta", text: "Let me check. " }, + { + type: "tool-call", + id: "c1", + name: "shell", + input: { command: "ls" }, + }, + { + type: "tool-result", + id: "c1", + name: "shell", + result: { + status: "success", + value: { stdout: "a.ts", stderr: "", exitCode: 0 }, + }, + isError: false, + }, + { type: "text-delta", text: "Found it." }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + expect(types(parts)).toEqual([ + "stream-start", + "text-start", // text-0: narration + "text-delta", + "text-end", // closed BEFORE the tool block + "tool-call", + "tool-result", + "text-start", // text-1: conclusion, positioned after the tool block + "text-delta", + "text-end", + "finish", + ]); + const starts = parts.filter((p) => p.type === "text-start"); + expect(starts.map((p) => (p as { id: string }).id)).toEqual([ + "text-0", + "text-1", + ]); + }); + + it("closes the open reasoning part before tool blocks", async () => { + const events: CursorEvent[] = [ + { type: "reasoning-delta", text: "hmm " }, + { + type: "tool-call", + id: "c1", + name: "shell", + input: { command: "ls" }, + }, + { + type: "tool-result", + id: "c1", + name: "shell", + result: { + status: "success", + value: { stdout: "", stderr: "", exitCode: 0 }, + }, + isError: false, + }, + { type: "reasoning-delta", text: "now I see" }, + { type: "finish", text: "done" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + expect(types(parts)).toEqual([ + "stream-start", + "reasoning-start", // reasoning-0 + "reasoning-delta", + "reasoning-end", // closed BEFORE the tool block + "tool-call", + "tool-result", + "reasoning-start", // reasoning-1, after the tool block + "reasoning-delta", + "reasoning-end", + "text-start", + "text-delta", + "text-end", + "finish", + ]); + }); + + it("does not split the text part for a buffered edit call (parts emit at result time)", async () => { + // Edit calls emit NO parts at call time (the diff arrives with the + // result), so the open text part must NOT be closed until the result + // actually emits the edit block. + const events: CursorEvent[] = [ + { type: "text-delta", text: "editing " }, + { type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } }, + { type: "text-delta", text: "still narrating " }, + { + type: "tool-result", + id: "e1", + name: "edit", + result: { + status: "success", + value: { diffString: "@@\n-x\n+y", linesAdded: 1, linesRemoved: 1 }, + }, + isError: false, + }, + { type: "text-delta", text: "done" }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + expect(types(parts)).toEqual([ + "stream-start", + "text-start", // text-0 spans the buffered call (nothing emitted yet) + "text-delta", + "text-delta", + "text-end", // closed when the edit parts actually emit + "tool-call", + "tool-result", + "text-start", // text-1 + "text-delta", + "text-end", + "finish", + ]); + }); + it("closes the open text part when reasoning resumes so parts render in true order", async () => { // Interleaved turn: intro text → tool/reasoning activity → final text. The // final text must land in a NEW part (text-1) that starts after the diff --git a/test/transcript-fingerprint.test.ts b/test/transcript-fingerprint.test.ts new file mode 100644 index 0000000..dc0fc31 --- /dev/null +++ b/test/transcript-fingerprint.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; +import { + classifyTurn, + fingerprint, + mcpServersFingerprint, + type TranscriptRecord, +} from "../src/provider/transcript-fingerprint.js"; + +const sys = (text: string): LanguageModelV3Prompt[number] => ({ + role: "system", + content: text, +}); +const user = (text: string): LanguageModelV3Prompt[number] => ({ + role: "user", + content: [{ type: "text", text }], +}); +const assistant = (text: string): LanguageModelV3Prompt[number] => ({ + role: "assistant", + content: [{ type: "text", text }], +}); + +/** Build a pool record from a prompt + agentId (what the pool would store). */ +function record( + prompt: LanguageModelV3Prompt, + agentId = "a1", +): TranscriptRecord { + return { agentId, ...fingerprint(prompt) }; +} + +describe("classifyTurn", () => { + it("returns 'new' when there is no prior record", () => { + const prompt = [sys("S"), user("hi")]; + expect(classifyTurn(undefined, prompt).kind).toBe("new"); + }); + + it("returns 'continuation' when one new user turn is appended", () => { + const turn1 = [sys("S"), user("hi")]; + const prev = record(turn1); + const turn2 = [sys("S"), user("hi"), assistant("hello"), user("more")]; + const c = classifyTurn(prev, turn2); + expect(c.kind).toBe("continuation"); + expect(c.fingerprint.userHashes).toHaveLength(2); + }); + + it("returns 'side-call' when the system prompt changes (e.g. title gen)", () => { + const prev = record([sys("chat system"), user("hi")]); + const titleGen = [sys("Generate a short title"), user("hi")]; + expect(classifyTurn(prev, titleGen).kind).toBe("side-call"); + }); + + it("returns 'divergence' when an earlier user message was edited", () => { + const prev = record([sys("S"), user("hi"), assistant("ok")]); + const edited = [ + sys("S"), + user("HELLO EDITED"), + assistant("ok"), + user("next"), + ]; + expect(classifyTurn(prev, edited).kind).toBe("divergence"); + }); + + it("returns 'divergence' on revert/compaction (fewer or reshaped user turns)", () => { + const prev = record([sys("S"), user("a"), assistant("x"), user("b")]); + const reverted = [sys("S"), user("a")]; + expect(classifyTurn(prev, reverted).kind).toBe("divergence"); + }); + + it("returns 'divergence' when more than one new user message is queued", () => { + const prev = record([sys("S"), user("a")]); + const queued = [sys("S"), user("a"), user("b"), user("c")]; + expect(classifyTurn(prev, queued).kind).toBe("divergence"); + }); + + it("returns 'divergence' when the last message is not a user turn", () => { + const prev = record([sys("S"), user("a")]); + const endsAssistant = [sys("S"), user("a"), assistant("trailing")]; + expect(classifyTurn(prev, endsAssistant).kind).toBe("divergence"); + }); + + it("treats identical image-bearing user turns as a stable prefix", () => { + const img = (): LanguageModelV3Prompt[number] => ({ + role: "user", + content: [ + { type: "text", text: "look" }, + { type: "file", data: "https://x/a.png", mediaType: "image/png" }, + ], + }); + const prev = record([sys("S"), img()]); + const turn2 = [sys("S"), img(), assistant("seen"), user("and now?")]; + expect(classifyTurn(prev, turn2).kind).toBe("continuation"); + }); +}); + +describe("mcpServersFingerprint", () => { + it("hashes empty/undefined sets to the same empty string", () => { + expect(mcpServersFingerprint(undefined)).toBe(""); + expect(mcpServersFingerprint({})).toBe(""); + }); + + it("is independent of key insertion order", () => { + const a = mcpServersFingerprint({ + serena: { type: "stdio", command: "serena" }, + ctx: { type: "http", url: "https://x" }, + }); + const b = mcpServersFingerprint({ + ctx: { type: "http", url: "https://x" }, + serena: { type: "stdio", command: "serena" }, + }); + expect(a).toBe(b); + }); + + it("changes when a server is added or removed", () => { + const one = mcpServersFingerprint({ + serena: { type: "stdio", command: "serena" }, + }); + const two = mcpServersFingerprint({ + serena: { type: "stdio", command: "serena" }, + ctx: { type: "http", url: "https://x" }, + }); + expect(one).not.toBe(two); + }); +}); + +describe("fingerprint", () => { + it("is stable for identical prompts and ignores assistant content", () => { + const a = fingerprint([sys("S"), user("hi"), assistant("one")]); + const b = fingerprint([sys("S"), user("hi"), assistant("TWO DIFFERENT")]); + expect(a).toEqual(b); + }); + + it("changes when a user message changes", () => { + const a = fingerprint([sys("S"), user("hi")]); + const b = fingerprint([sys("S"), user("bye")]); + expect(a.userHashes).not.toEqual(b.userHashes); + }); +});