Skip to content

Commit 361f152

Browse files
committed
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.
1 parent 39dd3e7 commit 361f152

12 files changed

Lines changed: 908 additions & 350 deletions

CHANGELOG.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ 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+
- **Tool outputs are included (truncated) in flattened transcripts.** The
24+
fresh/divergence/`session: false` replay paths previously dropped Cursor tool
25+
results to bare `[result of X]` placeholders, so a fresh agent re-read a
26+
transcript with prior tool outputs missing. Outputs are now inlined and capped
27+
(2,000 chars per result, 500 per tool-call args) so context stays faithful
28+
without unbounded bloat.
29+
730
## [0.2.0] — 2026-06-11
831

932
- **More Cursor tools map onto opencode's native tool renderers (blocks mode).**
@@ -47,7 +70,8 @@ and a permission-gated delegation tool surface.
4770
- **Session reuse** (`session: true`) — keeps one Cursor agent per opencode
4871
session via `Agent.resume()` across turns, with automatic fallback to a fresh
4972
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.
73+
the send once with the SDK's `local.force` escape hatch. (Superseded by the
74+
fingerprint-guarded `session: "auto"` default; see Unreleased.)
5175
- **Native diff viewer for Cursor edits (blocks mode).** A Cursor `edit` tool
5276
call is now surfaced under opencode's registered `edit` tool with its real
5377
unified diff in `metadata.diff`, so opencode renders its built-in diff viewer

README.md

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,20 +152,48 @@ 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+
| Earlier message edited/reverted, conversation compacted, or several messages queued | **divergence** | fresh agent, full transcript, re-pool |
179+
180+
The worst case on any misclassification is a single full-transcript replay that self-heals on the
181+
next turn — never worse than `session: false`. A failed resume also degrades to a fresh replay. The
182+
resumed agent is named after the session and visible in Cursor's dashboard; the opencode session id
183+
reaches the provider via the plugin's `chat.params` hook (`providerOptions.cursor.sessionID`).
184+
185+
- `session: true` is an alias for `"auto"`.
186+
- `session: false` restores the original behavior: always a fresh agent + full transcript, every
187+
turn. Use it if you want each turn fully independent.
188+
189+
**Cache implications.** Cursor builds prompts cache-friendly and the model provider's own prefix
190+
cache (Anthropic uses a ~5-minute sliding TTL) decides hits. `"auto"` keeps the prompt prefix stable
191+
across turns, which is what lands cache reads instead of expensive re-seeds. Things that re-seed the
192+
cache even mid-window: switching model/variant, changing the thinking level, toggling agent/plan
193+
mode, or editing an earlier message (all change the exact token prefix). Tool outputs from earlier
194+
turns are included (truncated) in the replay paths so a fresh/diverged agent still sees what prior
195+
tools produced. Set `OPENCODE_CURSOR_DEBUG=1` to log the per-turn classification and the
196+
`cacheReadTokens`/`cacheWriteTokens` reported by Cursor.
169197

170198
### Per-request controls (`mode`, thinking level)
171199

@@ -297,9 +325,10 @@ This plugin runs Cursor as a **local agent** (`Agent.create({ local: { cwd } })`
297325
directory. How that activity is shown is controlled by the [`toolDisplay`](#tool-display) option.
298326
Either way it is **not** routed through opencode's tool/permission system — Cursor runs the tools
299327
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)).
328+
- By default (`session: "auto"`) the provider resumes one Cursor agent per session and sends only
329+
the new message on a clean continuation, falling back to a fresh agent + full transcript on
330+
edits/reverts/compaction/side calls (see [Session reuse](#session-reuse-session)). Set
331+
`session: false` to always create a fresh agent and re-send the full transcript every turn.
303332
- Token usage is reported from Cursor's `turn-ended` event; cost is shown as `0` because Cursor
304333
bills your account separately.
305334
- **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)