Skip to content

Commit df220e8

Browse files
feat: fingerprint-guarded session reuse as default (session: "auto") (#18)
* feat: fingerprint-guarded session reuse as default (session: "auto") Previously the provider created a fresh Cursor agent every turn and re-sent the whole transcript: robust but cache-hostile, increasingly costly as a conversation grows, and it paid opencode's system prompt on top of Cursor's own. Opt-in `session: true` resumed one agent per session but blindly: it could drift from opencode's view of history (message edits, reverts, opencode-side compaction) and was polluted by non-chat side calls (e.g. title generation). Add `session: "auto"` (new default) which hashes only the parts opencode replays verbatim — the system prompt and the user-message sequence — and classifies each turn: - new -> fresh agent, full transcript, pool it - side-call -> system prompt differs; fresh ephemeral agent, pool untouched - continuation -> prior user seq is an exact prefix + exactly one new user message; resume the pooled agent, send only the new message - divergence -> edit/revert/compaction/queued msgs (or failed resume); fresh agent + full transcript, re-pool Worst case on any misclassification 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. Also include tool outputs (truncated: 2000 chars/result, 500/args) in the flattened transcript so fresh/divergence/`session: false` replays stay faithful instead of dropping prior tool results to `[result of X]` placeholders. OPENCODE_CURSOR_DEBUG=1 logs per-turn classification and cache usage. Adds transcript-fingerprint unit tests, rewrites session-pool tests for the decision-driven acquireAgent contract, and adds a live session-reuse smoke script. * feat: re-forward live MCP servers per turn with OAuth mapping The config hook only snapshots opencode's MCP set once at startup, so mid-session enable/disable never reached the Cursor agent. Re-forward the live set from chat.params using client.mcp.status() (runtime truth) + client.config.get() (launch specs), and force a fresh Agent.create when the forwarded set changes between turns (a resumed agent keeps its original servers). Map remote OAuth client registration (clientId/clientSecret/scope) onto the Cursor SDK's auth block so the agent runs its own OAuth flow. opencode's access token never lands in config.mcp, so servers needing OAuth without a shareable clientId (dynamic registration / needs_auth) are skipped and the user is notified via a one-time toast instead of forwarding a spec that 401s. * feat: persist session pool records so reuse survives restarts The pool was in-memory only: an opencode restart lost the fingerprint records, so the first turn of every resumed conversation classified as "new" and paid a cache-cold full-transcript replay even though the Cursor agent (and its conversation, in Cursor's checkpoint store) was still resumable. Persist the records best-effort to ~/.cache/opencode-cursor/session-pool.json following the model-cache pattern (never throws, optimization-only). Records carry updatedAt and are pruned to a 7-day TTL and a 200-entry most-recently-used cap. The in-memory pool lazily hydrates from disk (memory wins on conflict); concurrent opencode processes are last-write-wins on the whole file, where a lost record costs exactly one self-healing full replay. clearAgentPool() now wipes the disk store too; a new resetSessionPoolMemory() test hook simulates process restarts. * fix(stream): close open text/reasoning parts before tool blocks The earlier ordering fix (328aecc) closed parts on text<->reasoning transitions, but blocks-mode tool parts were enqueued while the narration part stayed open. Hosts position a part where it STARTED, so text streamed after a tool call appended to the pre-tool part and rendered ABOVE the tool block. Close the open text/reasoning part before emitting tool parts — but only when parts are actually emitted: edit calls buffer until their result (no parts at call time), so the narration part stays alive across that gap instead of splitting needlessly. Adds three regression tests: text/tool/text ordering, reasoning/tool/ reasoning ordering, and the buffered-edit no-split case. * docs: catch README/CHANGELOG up to MCP re-forwarding and ordering fix - MCP section: live per-turn forwarding via chat.params (mcp.status + config.get), OAuth client mapping to the Cursor auth block, the unshareable-OAuth skip + one-time toast, and the session-reuse interaction (changed set -> fresh agent; tools sit atop the cache-prefix hierarchy). - Session reuse table: add the MCP-set-changed row. - Cache implications: MCP changes listed among prefix re-seeders. - CHANGELOG: entries for MCP re-forwarding and the tool-block part ordering fix. * style: normalise indentation to tabs (formatter)
1 parent 39dd3e7 commit df220e8

19 files changed

Lines changed: 1885 additions & 367 deletions

CHANGELOG.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Fingerprint-guarded session reuse, now the default (`session: "auto"`).**
8+
Previously the provider created a fresh Cursor agent every turn and re-sent
9+
the whole transcript (robust but cache-hostile and increasingly costly as a
10+
conversation grows), while opt-in `session: true` resumed one agent per
11+
session but could drift from opencode's history (edits/reverts/compaction) and
12+
was disturbed by non-chat side calls. `session: "auto"` (the new default)
13+
hashes only the parts opencode replays verbatim — the system prompt and the
14+
user-message sequence — and classifies each turn: a clean **continuation**
15+
resumes the pooled agent and sends only the new message (maximizing prefix
16+
cache hits); a **side-call** (system prompt differs, e.g. title generation)
17+
runs a fresh ephemeral agent without touching the pool; a **divergence**
18+
(edit/revert/compaction/queued messages) or a failed resume falls back to a
19+
fresh agent + full transcript and re-pools. Worst case is one self-healing
20+
full replay — never worse than the old default. `session: true` is now an
21+
alias for `"auto"`; `session: false` keeps the always-fresh behavior.
22+
Set `OPENCODE_CURSOR_DEBUG=1` to log per-turn classification and cache usage.
23+
- **Session reuse survives opencode restarts.** The pool's fingerprint records
24+
persist (best-effort) to `~/.cache/opencode-cursor/session-pool.json` (7-day
25+
TTL, 200-entry LRU cap), so the first turn after a restart resumes the
26+
session's Cursor agent — whose conversation lives in Cursor's own checkpoint
27+
store — instead of paying a cache-cold full-transcript replay.
28+
- **MCP servers are re-forwarded live, per turn, with OAuth mapping.** The
29+
`config` hook's startup snapshot meant mid-session MCP enable/disable never
30+
reached the Cursor agent. The `chat.params` hook now forwards the live set
31+
each turn (`client.mcp.status()` for runtime truth, `client.config.get()` for
32+
launch specs). Because a resumed agent keeps its original servers, a changed
33+
set forces a fresh agent (full-transcript replay, re-pooled) so the new
34+
servers take effect — the session fingerprint carries an `mcpHash` for this.
35+
Remote servers with a registered OAuth client are forwarded with a Cursor
36+
`auth` block so the agent runs its own OAuth flow; servers needing OAuth
37+
without a shareable `clientId` (dynamic registration) are skipped with a
38+
one-time toast instead of forwarding a spec that would 401.
39+
- **Fixed: text/reasoning streamed after a tool call rendered above the tool
40+
block.** The earlier ordering fix closed parts on text↔reasoning transitions,
41+
but blocks-mode tool parts were emitted while the narration part stayed open
42+
— and hosts position a part where it started. Open text/reasoning parts are
43+
now closed before tool parts are emitted (except for buffered edit calls,
44+
which emit nothing until their result arrives, so narration isn't split
45+
needlessly).
46+
- **Tool outputs are included (truncated) in flattened transcripts.** The
47+
fresh/divergence/`session: false` replay paths previously dropped Cursor tool
48+
results to bare `[result of X]` placeholders, so a fresh agent re-read a
49+
transcript with prior tool outputs missing. Outputs are now inlined and capped
50+
(2,000 chars per result, 500 per tool-call args) so context stays faithful
51+
without unbounded bloat.
52+
753
## [0.2.0] — 2026-06-11
854

955
- **More Cursor tools map onto opencode's native tool renderers (blocks mode).**
@@ -47,7 +93,8 @@ and a permission-gated delegation tool surface.
4793
- **Session reuse** (`session: true`) — keeps one Cursor agent per opencode
4894
session via `Agent.resume()` across turns, with automatic fallback to a fresh
4995
agent. A run wedged by a crashed/duplicate process is recovered by retrying
50-
the send once with the SDK's `local.force` escape hatch.
96+
the send once with the SDK's `local.force` escape hatch. (Superseded by the
97+
fingerprint-guarded `session: "auto"` default; see Unreleased.)
5198
- **Native diff viewer for Cursor edits (blocks mode).** A Cursor `edit` tool
5299
call is now surfaced under opencode's registered `edit` tool with its real
53100
unified diff in `metadata.diff`, so opencode renders its built-in diff viewer

README.md

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -152,20 +152,53 @@ This plugin also registers two **delegation tools** that complement the provider
152152
| `settingSources` || Cursor settings layers to load from disk: `["project","user","all",...]` — pulls in your Cursor **skills**, rules, and `.cursor/mcp.json` |
153153
| `sandbox` || Run the agent's tools inside Cursor's sandbox (`true`/`false`) |
154154
| `agents` || Cursor subagent definitions (`{ <name>: { description, prompt, model?, mcpServers? } }`) |
155-
| `session` | `false` | Reuse one Cursor agent per opencode session (resume across turns; see below) |
155+
| `session` | `"auto"` | Session reuse strategy: `"auto"` (fingerprint-guarded resume), `true` (alias for `"auto"`), or `false` (always fresh). See below |
156156
| `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent |
157157
| `mcpServers` || Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones |
158158
| `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) |
159159

160160
### Session reuse (`session`)
161161

162-
By default each opencode turn spins up a **fresh** Cursor agent and re-sends the full conversation
163-
transcript — robust, and correct even for opencode's non-chat calls (e.g. title generation). Set
164-
`session: true` to instead keep **one Cursor agent per opencode session**: the provider names the
165-
agent after the session, `Agent.resume()`s it on later turns, and sends only the new message so
166-
Cursor uses its native conversation memory and checkpoints (the agent is visible in Cursor's
167-
dashboard). The opencode session id reaches the provider via the plugin's `chat.params` hook
168-
(`providerOptions.cursor.sessionID`); a failed resume falls back to a fresh turn automatically.
162+
opencode re-sends the **entire** conversation transcript on every turn. Replaying that into a fresh
163+
Cursor agent each turn is robust but costs more input tokens as the conversation grows (and pays
164+
opencode's system prompt on top of Cursor's own). Reusing one Cursor agent and sending only the new
165+
message is the cache-friendly, native-CLI-like path — but a blindly resumed agent can drift from
166+
opencode's view of history (message edits, reverts, opencode-side compaction) and must not be
167+
disturbed by opencode's non-chat side calls (e.g. title generation).
168+
169+
**`session: "auto"` (the default) resolves this with a per-turn fingerprint.** The provider hashes
170+
only the parts opencode replays verbatim — the system prompt and the user-message sequence — and
171+
classifies each turn:
172+
173+
| Situation | Classification | What the provider does |
174+
| --- | --- | --- |
175+
| First turn of the session | **new** | fresh agent, full transcript, pool it |
176+
| System prompt differs (title gen and other side calls) | **side-call** | fresh ephemeral agent; the pooled agent is left untouched |
177+
| Prior user sequence is an exact prefix + exactly one new user message | **continuation** | `Agent.resume` the pooled agent, send **only** the new message |
178+
| 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 |
179+
| Earlier message edited/reverted, conversation compacted, or several messages queued | **divergence** | fresh agent, full transcript, re-pool |
180+
181+
The worst case on any misclassification is a single full-transcript replay that self-heals on the
182+
next turn — never worse than `session: false`. A failed resume also degrades to a fresh replay. The
183+
resumed agent is named after the session and visible in Cursor's dashboard; the opencode session id
184+
reaches the provider via the plugin's `chat.params` hook (`providerOptions.cursor.sessionID`).
185+
Fingerprint records persist (best-effort) to `~/.cache/opencode-cursor/session-pool.json`, so
186+
session reuse survives opencode restarts — the conversation itself lives in Cursor's own local
187+
checkpoint store, and the next turn resumes it instead of replaying the transcript.
188+
189+
- `session: true` is an alias for `"auto"`.
190+
- `session: false` restores the original behavior: always a fresh agent + full transcript, every
191+
turn. Use it if you want each turn fully independent.
192+
193+
**Cache implications.** Cursor builds prompts cache-friendly and the model provider's own prefix
194+
cache (Anthropic uses a ~5-minute sliding TTL) decides hits. `"auto"` keeps the prompt prefix stable
195+
across turns, which is what lands cache reads instead of expensive re-seeds. Things that re-seed the
196+
cache even mid-window: switching model/variant, changing the thinking level, toggling agent/plan
197+
mode, editing an earlier message, or changing the forwarded MCP server set (tool definitions sit at
198+
the top of the provider's cache-prefix hierarchy, so they invalidate everything after them). Tool outputs from earlier
199+
turns are included (truncated) in the replay paths so a fresh/diverged agent still sees what prior
200+
tools produced. Set `OPENCODE_CURSOR_DEBUG=1` to log the per-turn classification and the
201+
`cacheReadTokens`/`cacheWriteTokens` reported by Cursor.
169202

170203
### Per-request controls (`mode`, thinking level)
171204

@@ -205,20 +238,36 @@ To disable MCP forwarding, set `provider.cursor.options.forwardMcp: false` in yo
205238

206239
## MCP servers
207240

208-
The Cursor agent can use the **same MCP servers you've configured in opencode**. The plugin's
209-
`config` hook reads opencode's `config.mcp`, translates each entry into the Cursor SDK's
210-
`McpServerConfig` shape, and hands them to the agent via `Agent.create({ mcpServers })`:
241+
The Cursor agent can use the **same MCP servers you've configured in opencode**. Forwarding is
242+
**live, per turn**: the plugin's `chat.params` hook reads opencode's current MCP state
243+
(`client.mcp.status()` for what's actually enabled right now, `client.config.get()` for the launch
244+
specs), translates each entry into the Cursor SDK's `McpServerConfig` shape, and hands the set to
245+
the agent — so enabling or disabling an MCP server mid-session takes effect on the next turn, not
246+
the next restart. A startup snapshot from the `config` hook remains as the fallback when the live
247+
read is unavailable.
211248

212249
| opencode `config.mcp` | → Cursor |
213250
| --- | --- |
214251
| `{ type: "local", command: [cmd, ...args], environment }` | `{ type: "stdio", command: cmd, args, env }` |
215252
| `{ type: "remote", url, headers }` | `{ type: "http", url, headers }` |
253+
| remote with registered OAuth client (`clientId`, optional secret/scopes) | `{ type: "http", url, auth: { CLIENT_ID, … } }` — the agent runs its own OAuth flow |
216254

217255
So whatever MCP servers your `opencode.json` defines, your Cursor agent connects to those same
218256
servers — MCP servers are independent processes, so opencode and the agent each connect to them
219257
directly.
220258
Disabled entries (`enabled: false`) are skipped. Turn this off with `forwardMcp: false`.
221259

260+
> **OAuth caveat.** opencode's own access tokens never land in `config.mcp`, so a remote server
261+
> that needs OAuth **without** a shareable `clientId` (dynamic client registration / `needs_auth`)
262+
> can't be forwarded — forwarding its spec would just 401. Such servers are skipped and a one-time
263+
> toast tells you which ones; they keep working inside opencode itself.
264+
>
265+
> **Session-reuse interaction.** A resumed Cursor agent keeps the MCP servers it was created with,
266+
> so when the forwarded set changes between turns the provider creates a fresh agent (full
267+
> transcript replay, re-pooled) instead of resuming — see
268+
> [Session reuse](#session-reuse-session). Tool definitions sit at the top of the provider's
269+
> cache-prefix hierarchy, so an MCP change also re-seeds the prompt cache.
270+
222271
> Scope note: this forwards **MCP servers**. opencode's *loop-internal* features — its own skills
223272
> and subagents — are not exposed to the Cursor agent (they run inside opencode's agent loop, which
224273
> 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 } })`
297346
directory. How that activity is shown is controlled by the [`toolDisplay`](#tool-display) option.
298347
Either way it is **not** routed through opencode's tool/permission system — Cursor runs the tools
299348
itself.
300-
- By default each turn creates a fresh local agent and sends the full conversation transcript, so
301-
context is always complete. Enable `session: true` to reuse Cursor's native per-agent memory
302-
across turns (see [Session reuse](#session-reuse-session)).
349+
- By default (`session: "auto"`) the provider resumes one Cursor agent per session and sends only
350+
the new message on a clean continuation, falling back to a fresh agent + full transcript on
351+
edits/reverts/compaction/side calls (see [Session reuse](#session-reuse-session)). Set
352+
`session: false` to always create a fresh agent and re-send the full transcript every turn.
303353
- Token usage is reported from Cursor's `turn-ended` event; cost is shown as `0` because Cursor
304354
bills your account separately.
305355
- **Provider path is local.** The `cursor/*` models you chat with run as a **local** agent. Cursor's

scripts/session-reuse-smoke.mjs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Live smoke test for fingerprint-guarded session reuse (`session: "auto"`).
2+
//
3+
// Simulates how opencode drives the provider across turns: it re-sends the
4+
// whole transcript each call with a stable providerOptions.cursor.sessionID.
5+
// This script asserts the classification + cache behavior empirically:
6+
//
7+
// Turn 1 (new) -> fresh agent, full transcript
8+
// Turn 2,3 (continuation)-> RESUME pooled agent, send only the new message;
9+
// inputTokens stays flat, cacheRead dominates
10+
// Turn 4 (divergence) -> edit an earlier user message -> fresh replay,
11+
// re-pool. Demonstrates the safety fallback.
12+
//
13+
// Classification is logged to stderr (OPENCODE_CURSOR_DEBUG=1, set below).
14+
// Skips cleanly (exit 0) when CURSOR_API_KEY is absent.
15+
import { mkdtempSync } from "node:fs";
16+
import { tmpdir } from "node:os";
17+
import { join } from "node:path";
18+
19+
process.env.OPENCODE_CURSOR_DEBUG = "1";
20+
21+
const apiKey = process.env.CURSOR_API_KEY?.trim();
22+
if (!apiKey) {
23+
console.log("[session-smoke] No CURSOR_API_KEY; skipping.");
24+
process.exit(0);
25+
}
26+
27+
const modelId = process.env.CURSOR_SMOKE_MODEL?.trim() || "composer-2.5";
28+
const providerUrl = new URL("../dist/provider/index.js", import.meta.url).href;
29+
const { createCursor } = await import(providerUrl);
30+
31+
const cwd = mkdtempSync(join(tmpdir(), "cursor-session-"));
32+
const model = createCursor({ apiKey, cwd, session: "auto" }).languageModel(
33+
modelId,
34+
);
35+
const sessionID = `smoke-${Date.now()}`;
36+
37+
const sys = {
38+
role: "system",
39+
content: "You are terse. Answer in one short sentence.",
40+
};
41+
const user = (text) => ({ role: "user", content: [{ type: "text", text }] });
42+
const assistant = (text) => ({
43+
role: "assistant",
44+
content: [{ type: "text", text }],
45+
});
46+
47+
async function turn(label, prompt) {
48+
const controller = new AbortController();
49+
const timer = setTimeout(() => controller.abort(), 180_000);
50+
let text = "";
51+
let usage;
52+
try {
53+
const { stream } = await model.doStream({
54+
prompt,
55+
abortSignal: controller.signal,
56+
providerOptions: { cursor: { sessionID } },
57+
});
58+
const reader = stream.getReader();
59+
for (;;) {
60+
const { done, value } = await reader.read();
61+
if (done) break;
62+
if (value.type === "text-delta") text += value.delta;
63+
else if (value.type === "finish") usage = value.usage;
64+
}
65+
} finally {
66+
clearTimeout(timer);
67+
}
68+
const inp = usage?.inputTokens ?? {};
69+
console.log(
70+
`[session-smoke:${label}] reply=${JSON.stringify(text.trim().slice(0, 60))} ` +
71+
`input=${inp.total ?? "?"} cacheRead=${inp.cacheRead ?? "?"} cacheWrite=${inp.cacheWrite ?? "?"}`,
72+
);
73+
return text.trim();
74+
}
75+
76+
// Turn 1 — new session.
77+
const r1 = await turn("t1-new", [sys, user("Name a primary color.")]);
78+
// Turn 2 — continuation (one new user message appended).
79+
const r2 = await turn("t2-cont", [
80+
sys,
81+
user("Name a primary color."),
82+
assistant(r1),
83+
user("Name another one."),
84+
]);
85+
// Turn 3 — continuation again.
86+
const r3 = await turn("t3-cont", [
87+
sys,
88+
user("Name a primary color."),
89+
assistant(r1),
90+
user("Name another one."),
91+
assistant(r2),
92+
user("And a third?"),
93+
]);
94+
// Turn 4 — divergence: edit the FIRST user message -> must fall back to replay.
95+
await turn("t4-diverge", [
96+
sys,
97+
user("Name a primary color. (edited)"),
98+
assistant(r1),
99+
user("Name another one."),
100+
assistant(r2),
101+
user("And a third?"),
102+
assistant(r3),
103+
user("One more?"),
104+
]);
105+
106+
console.log(
107+
"[session-smoke] Done. Expect stderr classifications: " +
108+
"fresh:new, resume, resume, fresh:divergence. " +
109+
"On t2/t3 inputTokens should stay flat with cacheRead dominating.",
110+
);

0 commit comments

Comments
 (0)