diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c7c585..4b0f730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **opencode's threshold-triggered auto-compaction is now suppressed for Cursor models by + default.** The Cursor agent runtime already self-compacts on its own context threshold + (`preCompact` hook with `trigger: "auto"`), so opencode-driven compaction was redundant — + and it caused two real failures. First, the compaction turn runs with zero tools declared + while the Cursor agent uses its own tools anyway, which opencode rejects (`Tool call not + allowed while generating summary`) — mitigated in 0.7.1-next.1 (#91), and now avoided + entirely for the automatic trigger. Second, compaction rewrites the transcript, which + classifies as a divergence and mints a **fresh Cursor agentId** — and every distinct + agentId permanently holds a guarded SQLite `store.db`/`-wal`/`-shm` triple that + `agent.close()` cannot release (it only flushes analytics and releases the executor lease). + That descriptor growth fed an uncatchable `EXC_GUARD` process kill. + + Suppression uses a large `limit.input` — the value opencode uses as its compaction + threshold — leaving the real `limit.context` intact so the TUI context gauge and cost + reporting still work. Manual `/compact` is unaffected and still relies on #91's fix. + + **Tradeoff:** this suppresses the proactive threshold trigger only, and opencode has no + reactive context-overflow recovery wired up for this provider, so its transcript is no + longer trimmed automatically. Ordinary turns send only the new message, but a cold replay + (new session, expired agent, changed MCP set) resends everything; if that overflows the + model the turn fails and `/compact` is the manual recovery. Opt back out with + `provider.cursor.options.autoCompaction: true`. + ## [0.7.1-next.1] — 2026-08-03 (pre-release) Pre-release of the compaction fix (#91). Not yet on `latest`; install with diff --git a/README.md b/README.md index f540d02..1786000 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ See [SECURITY.md](./SECURITY.md) for the full threat model. | `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown — see [Tool display](#tool-display) | | `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) | | `transport` | — | Cursor agent transport (`"http1"` \| `"http2-direct"` \| `"sidecar"`) — see [Transport](#transport) | +| `autoCompaction` | `false` | Let opencode drive auto-compaction. Off by default because the Cursor agent self-compacts — see [Compaction](#compaction) | | Environment variable | Default | Meaning | | --- | --- | --- | @@ -423,6 +424,42 @@ To force the fallback: { "provider": { "cursor": { "options": { "toolDisplay": "reasoning" } } } } ``` +## Compaction + +**opencode's threshold-triggered auto-compaction is suppressed for Cursor models by default.** The +Cursor agent runtime compacts its own conversation as it approaches its context threshold, so a +second, opencode-driven pass is redundant — and it is actively harmful here: + +- The compaction turn asks the model to summarize with **no tools available**. The Cursor agent runs + its own tools regardless, which opencode rejects outright + (`Tool call not allowed while generating summary`). +- Compaction rewrites the transcript, so the next turn no longer matches what the Cursor agent saw. + The plugin correctly treats that as a divergence and creates a **fresh Cursor agent** — and every + distinct agent permanently holds its own SQLite store open for the life of the process, which has + been observed to crash opencode outright. + +The suppression works by emitting a very large `limit.input`, which is what opencode uses as its +compaction threshold. The real `limit.context` is left untouched, so the TUI's context-window gauge +and cost reporting keep working. + +> [!IMPORTANT] +> This suppresses the **proactive** threshold trigger only, and opencode has no reactive +> context-overflow recovery wired up for this provider. In exchange, opencode's transcript is no +> longer trimmed automatically, so it grows for the life of the session. Ordinary turns send only +> the new message to an already-running agent, but a *cold replay* — a new session, an expired +> agent, or a changed MCP server set — resends the whole transcript. If that ever overflows the +> model, the turn fails with a provider error and the fix is to run `/compact` manually. +> +> Set `autoCompaction: true` if you would rather have opencode keep bounding the transcript for you. + +Manual `/compact` is unaffected and still works — it has no threshold gate. + +To hand compaction back to opencode: + +```json +{ "provider": { "cursor": { "options": { "autoCompaction": true } } } } +``` + ## Transport opencode runs on [Bun](https://bun.sh), whose `node:http2` client is incompatible with the Cursor diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 51af86c..6232eaa 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -73,6 +73,29 @@ fi echo "PASS: opencode loaded the plugin and listed $CURSOR_COUNT Cursor model(s)." +# Drift gate for the auto-compaction suppression. We disable opencode's +# threshold-triggered compaction by emitting a large `limit.input`, which is what +# opencode uses as that threshold. That field is honored by opencode's runtime +# but is NOT declared in the published @opencode-ai/sdk config types, so nothing +# in `tsc` or the unit suite can notice if opencode ever stops reading it. +# Without this check, such a regression is silent: auto-compaction quietly +# resumes, and with it the per-compaction agentId churn that leaks guarded +# SQLite descriptors and has crashed opencode outright. +VERBOSE_OUT="$("$OPENCODE" models cursor --verbose 2>/dev/null)" +if ! printf '%s\n' "$VERBOSE_OUT" | grep -q '"input": 1000000000'; then + echo "FAIL: limit.input sentinel did not survive into opencode's model registry." + echo " Auto-compaction suppression is broken — see 'Compaction' in README.md." + echo "----- limit blocks as resolved by opencode -----" + printf '%s\n' "$VERBOSE_OUT" | grep -A4 '"limit"' | head -20 + exit 1 +fi +# The gauge must still work: context has to stay a real value, not be zeroed. +if printf '%s\n' "$VERBOSE_OUT" | grep -A4 '"limit"' | grep -q '"context": 0'; then + echo "FAIL: limit.context was zeroed — the TUI context gauge would be dead." + exit 1 +fi +echo "PASS: limit.input sentinel reaches opencode's registry with limit.context intact." + # Assert the packed artifact actually ships the delegation tools. PLUGIN_JS="$WORK/node_modules/@stablekernel/opencode-cursor/dist/plugin/index.js" for TOOL in cursor_cloud_agent cursor_delegate; do diff --git a/src/model-discovery.ts b/src/model-discovery.ts index ebb0b0e..66a8e68 100644 --- a/src/model-discovery.ts +++ b/src/model-discovery.ts @@ -1,7 +1,12 @@ import type { ModelListItem } from "@cursor/sdk"; import type { Config } from "@opencode-ai/plugin"; import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js"; -import { resolveContextLimit, resolveCost, resolveOutputLimit } from "./model-limits.js"; +import { + NO_AUTO_COMPACTION_INPUT_LIMIT, + resolveContextLimit, + resolveCost, + resolveOutputLimit, +} from "./model-limits.js"; import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js"; import { FALLBACK_MODELS } from "./fallback-models.js"; import { loadCursorSdk } from "./cursor-runtime.js"; @@ -111,9 +116,17 @@ export interface OpencodeModelConfigEntry { * Per-model context/output window. opencode's config channel is the only * one that reaches the model registry for providers absent from * models.dev, so the TUI session header's context-window percentage - * depends on this being present. Both fields are required by the schema. + * depends on this being present. `context` and `output` are required by + * the schema. + * + * `input` is an undocumented-but-runtime-honored field used only as + * opencode's auto-compaction threshold. We emit + * {@link NO_AUTO_COMPACTION_INPUT_LIMIT} to suppress auto-compaction while + * keeping `context` honest so the TUI gauge still works. The published + * `@opencode-ai/sdk` config types omit it, so it is excluded from + * `_limitKeyGuard` below. */ - limit: { context: number; output: number }; + limit: { context: number; input?: number; output: number }; /** * Per-model API pricing, USD per million tokens. Note the FLAT snake_case * cache keys — the config schema (`ProviderConfig` in @@ -154,8 +167,11 @@ const _costKeyGuard: _KeysAccepted< NonNullable > = true; void _costKeyGuard; +// `input` is deliberately excluded: opencode's runtime reads it (verified in +// the 1.18.11 binary and end-to-end via `Provider.list()`), but the published +// config types don't declare it. The guard still protects `context`/`output`. const _limitKeyGuard: _KeysAccepted< - OpencodeModelConfigEntry["limit"], + Omit, NonNullable > = true; void _limitKeyGuard; @@ -165,7 +181,10 @@ void _limitKeyGuard; * Cursor SDK runs an agent (it calls tools itself), so every model is marked * `tool_call: true` and `temperature: false`. */ -export function toOpencodeModels(items: ModelListItem[]): Record { +export function toOpencodeModels( + items: ModelListItem[], + opts: { autoCompaction?: boolean } = {}, +): Record { const out: Record = {}; for (const item of items) { const params = defaultModelParams(item); @@ -181,6 +200,12 @@ export function toOpencodeModels(items: ModelListItem[]): Record 0 ? { params } : {}, limit: { context: resolveContextLimit(item.id), + // Suppress opencode's auto-compaction unless the user opts in: the + // Cursor agent self-compacts, and opencode's compaction mints a + // fresh agentId per cycle, permanently leaking guarded SQLite fds. + ...(opts.autoCompaction + ? {} + : { input: NO_AUTO_COMPACTION_INPUT_LIMIT }), output: resolveOutputLimit(item.id), }, cost: { diff --git a/src/model-limits.ts b/src/model-limits.ts index 3b3e021..ff87336 100644 --- a/src/model-limits.ts +++ b/src/model-limits.ts @@ -74,6 +74,41 @@ const MODEL_CONTEXT_LIMITS: Record = { const DEFAULT_CONTEXT_LIMIT = 200_000; +/** + * Sentinel `limit.input` that pushes opencode's auto-compaction threshold out + * of reach, so auto-compaction never fires. opencode computes the threshold as + * `limit.input ? limit.input - reserved : limit.context - maxOutput`, so a huge + * `input` makes it unreachable while `limit.context` stays honest — the TUI + * context gauge keeps working. + * + * Why suppress it: the Cursor agent runtime self-compacts on its own context + * threshold (`@cursor/sdk` `dist/esm/357.js`, `preCompact` hook with + * `trigger: "auto"`), so opencode-driven compaction is redundant. It is also + * harmful — each opencode compaction rewrites the transcript, which classifies + * as `divergence` and mints a fresh Cursor agentId, and every distinct agentId + * permanently adds a guarded SQLite `store.db`/`-wal`/`-shm` triple that + * `agent.close()` cannot release. + * + * This is NOT a real model capability. Verified against the opencode 1.18.11 + * binary by enumerating the call sites of `Is()` (the threshold function) rather + * than textual hits on `limit.input`, since consumers reach it transitively: + * - `vl()` — the proactive auto-compaction trigger. Suppressed here. + * - `Pd()` — preserve-recent-tokens budget, also used by manual + * `/compact`. Inert: it is `min(8000, max(2000, + * floor(Is*0.25)))`, which saturates at 8000 for any + * `Is >= 32000` — true both before and after the sentinel. + * Everything else that touches `limit.input` is catalog merge/serialization. + * + * Also verified end-to-end (isolated HOME, `opencode models cursor --verbose`) + * that a config-channel `limit.input` survives validation and reaches + * `Provider.list()` with `limit.context` intact. + * + * Caveat: `Is()` is `max(0, input - reserved)`, so a user setting + * `compaction.reserved >= this value` would drive the threshold to 0 and make + * compaction fire every turn. Absurd but user-settable. + */ +export const NO_AUTO_COMPACTION_INPUT_LIMIT = 1_000_000_000; + /** * Resolve a model's context window by longest-prefix match against * {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1c02976..a1f18d9 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -126,6 +126,11 @@ export const CursorPlugin: Plugin = async (input) => { let resolvedCwd = directory ?? process.cwd(); let forwardMcp = true; let userMcp: Record = {}; + // Whether to let opencode drive auto-compaction. Default false: the Cursor + // agent self-compacts (preCompact hook, trigger:"auto"), so opencode's + // compaction is redundant and is what mints a fresh agentId per compaction. + // Opt in with `provider.cursor.options.autoCompaction: true`. + let autoCompaction = false; // Skill forwarding state, mirroring the MCP forwarding pattern. let forwardSkills = true; let skillFilterOptions: SkillFilterOptions | undefined; @@ -183,6 +188,7 @@ 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`. + autoCompaction = existingOptions["autoCompaction"] === true; forwardMcp = existingOptions["forwardMcp"] !== false; userMcp = (existingOptions["mcpServers"] ?? {}) as Record< string, @@ -265,7 +271,10 @@ export const CursorPlugin: Plugin = async (input) => { ? { skillsCatalogue: currentSkillsCatalogue } : {}), }, - models: { ...toOpencodeModels(models), ...(existing.models ?? {}) }, + models: { + ...toOpencodeModels(models, { autoCompaction }), + ...(existing.models ?? {}), + }, }; }, @@ -274,7 +283,7 @@ export const CursorPlugin: Plugin = async (input) => { models: async (_provider, ctx) => { const apiKey = apiKeyFromAuth(ctx.auth); const { models } = await discoverModels({ apiKey }); - return buildModelV2Map(models); + return buildModelV2Map(models, { autoCompaction }); }, }, diff --git a/src/plugin/model-v2.ts b/src/plugin/model-v2.ts index 753937e..4fdfd53 100644 --- a/src/plugin/model-v2.ts +++ b/src/plugin/model-v2.ts @@ -1,7 +1,12 @@ import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import type { ModelListItem } from "@cursor/sdk"; import { modelSupportsReasoning } from "../model-discovery.js"; -import { resolveContextLimit, resolveCost, resolveOutputLimit } from "../model-limits.js"; +import { + NO_AUTO_COMPACTION_INPUT_LIMIT, + resolveContextLimit, + resolveCost, + resolveOutputLimit, +} from "../model-limits.js"; import { buildModelVariants, defaultModelParams } from "../model-variants.js"; export const PROVIDER_ID = "cursor"; @@ -24,7 +29,10 @@ export function providerNpm(): string { * limits are resolved per model from the shared maps in `../model-limits.js`, * falling back to $0 / 200K context / 32K output for models absent from them. */ -export function buildModelV2Map(items: ModelListItem[]): Record { +export function buildModelV2Map( + items: ModelListItem[], + opts: { autoCompaction?: boolean } = {}, +): Record { const out: Record = {}; for (const item of items) { const params = defaultModelParams(item); @@ -46,7 +54,13 @@ export function buildModelV2Map(items: ModelListItem[]): Record const c = resolveCost(item.id); return { input: c.input, output: c.output, cache: { read: c.cacheRead, write: c.cacheWrite } }; })(), - limit: { context: resolveContextLimit(item.id), output: resolveOutputLimit(item.id) }, + limit: { + context: resolveContextLimit(item.id), + ...(opts.autoCompaction + ? {} + : { input: NO_AUTO_COMPACTION_INPUT_LIMIT }), + output: resolveOutputLimit(item.id), + }, status: "active", options: Object.keys(params).length > 0 ? { params } : {}, headers: {}, diff --git a/test/model-discovery.test.ts b/test/model-discovery.test.ts index b8669bf..f1ac4a2 100644 --- a/test/model-discovery.test.ts +++ b/test/model-discovery.test.ts @@ -11,6 +11,7 @@ vi.mock("../src/model-cache.js", () => ({ const { discoverModels, modelSupportsReasoning, toOpencodeModels } = await import( "../src/model-discovery.js" ); +const { NO_AUTO_COMPACTION_INPUT_LIMIT } = await import("../src/model-limits.js"); afterEach(() => readLatestModelCache.mockReset()); @@ -83,16 +84,30 @@ describe("toOpencodeModels", () => { describe("toOpencodeModels config-channel limits and cost", () => { it("emits per-model limit with both context and output", () => { - const out = toOpencodeModels([ - { id: "claude-opus-4-8", displayName: "Opus 4.8" }, - { id: "gpt-5.5", displayName: "GPT-5.5" }, - { id: "grok-4.5", displayName: "Grok 4.5" }, - ] satisfies ModelListItem[]); + const out = toOpencodeModels( + [ + { id: "claude-opus-4-8", displayName: "Opus 4.8" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "grok-4.5", displayName: "Grok 4.5" }, + ] satisfies ModelListItem[], + { autoCompaction: true }, + ); expect(out["claude-opus-4-8"]!.limit).toEqual({ context: 300_000, output: 64_000 }); expect(out["gpt-5.5"]!.limit).toEqual({ context: 272_000, output: 64_000 }); expect(out["grok-4.5"]!.limit).toEqual({ context: 256_000, output: 32_000 }); }); + it("emits the no-auto-compaction input sentinel by default, keeping context honest", () => { + const out = toOpencodeModels([ + { id: "claude-opus-4-8", displayName: "Opus 4.8" }, + ] satisfies ModelListItem[]); + expect(out["claude-opus-4-8"]!.limit).toEqual({ + context: 300_000, + input: NO_AUTO_COMPACTION_INPUT_LIMIT, + output: 64_000, + }); + }); + it("emits cost with FLAT snake_case cache keys, not nested cache object", () => { const out = toOpencodeModels([ { id: "claude-sonnet-4-6", displayName: "Sonnet 4.6" }, @@ -119,10 +134,12 @@ describe("toOpencodeModels config-channel limits and cost", () => { }); it("falls back to 200K/32K and $0 for unknown models", () => { - const out = toOpencodeModels([ - { id: "brand-new-model", displayName: "New" }, - ] satisfies ModelListItem[]); + const out = toOpencodeModels( + [{ id: "brand-new-model", displayName: "New" }] satisfies ModelListItem[], + { autoCompaction: true }, + ); expect(out["brand-new-model"]!.limit).toEqual({ context: 200_000, output: 32_000 }); + expect(out["brand-new-model"]!.limit.input).toBeUndefined(); expect(out["brand-new-model"]!.cost).toEqual({ input: 0, output: 0, diff --git a/test/model-v2.test.ts b/test/model-v2.test.ts index d1db3f6..307a131 100644 --- a/test/model-v2.test.ts +++ b/test/model-v2.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ModelListItem } from "@cursor/sdk"; import { buildModelV2Map } from "../src/plugin/model-v2.js"; +import { NO_AUTO_COMPACTION_INPUT_LIMIT } from "../src/model-limits.js"; describe("buildModelV2Map", () => { it("seeds the fast-off default into options and exposes a fast opt-in variant", () => { @@ -47,6 +48,24 @@ describe("buildModelV2Map", () => { expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); }); + it("emits the no-auto-compaction input sentinel by default, keeping context honest", () => { + // The Cursor agent self-compacts; opencode's compaction mints a fresh + // agentId per cycle, which permanently leaks guarded SQLite descriptors. + const map = buildModelV2Map([{ id: "claude-opus-4-8", displayName: "Opus 4.8" }]); + expect(map["claude-opus-4-8"]!.limit.input).toBe(NO_AUTO_COMPACTION_INPUT_LIMIT); + // context stays real so the TUI gauge keeps working + expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); + expect(map["claude-opus-4-8"]!.limit.output).toBe(64_000); + }); + + it("omits the input sentinel when autoCompaction is opted in", () => { + const map = buildModelV2Map([{ id: "claude-opus-4-8", displayName: "Opus 4.8" }], { + autoCompaction: true, + }); + expect(map["claude-opus-4-8"]!.limit.input).toBeUndefined(); + expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); + }); + it("sets cost from per-model map for known models", () => { const map = buildModelV2Map([ { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, diff --git a/test/plugin-auto-compaction.test.ts b/test/plugin-auto-compaction.test.ts new file mode 100644 index 0000000..c1a7e0e --- /dev/null +++ b/test/plugin-auto-compaction.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { Config } from "@opencode-ai/plugin"; +import plugin from "../src/plugin/index.js"; +import { NO_AUTO_COMPACTION_INPUT_LIMIT } from "../src/model-limits.js"; + +/** + * These drive the REAL `config` hook, not the pure model-map builders. The + * option is read with a string key (`existingOptions["autoCompaction"]`), so a + * typo there is invisible to `tsc` and to the pure-function tests — only a + * hook-level test can catch it. Mirrors the `forwardMcp:false` pattern in + * mcp-config.test.ts. + */ +function firstModelLimit(config: Config): Record { + const models = config.provider!.cursor!.models as Record< + string, + { limit: Record } + >; + const first = Object.values(models)[0]; + if (!first) throw new Error("no cursor models emitted by the config hook"); + return first.limit; +} + +describe("plugin config hook — auto-compaction suppression", () => { + it("emits the no-auto-compaction input sentinel by default", async () => { + const hooks = await plugin({} as never); + const config: Config = {}; + await hooks.config!(config); + const limit = firstModelLimit(config); + expect(limit["input"]).toBe(NO_AUTO_COMPACTION_INPUT_LIMIT); + // context stays real so the TUI gauge and cost reporting keep working + expect(limit["context"]).toBeTypeOf("number"); + expect(limit["context"]).toBeGreaterThan(0); + }); + + it("omits the sentinel when autoCompaction:true is opted in", async () => { + const hooks = await plugin({} as never); + const config: Config = { + provider: { cursor: { options: { autoCompaction: true } } }, + }; + await hooks.config!(config); + const limit = firstModelLimit(config); + expect(limit["input"]).toBeUndefined(); + expect(limit["context"]).toBeGreaterThan(0); + }); + + it("treats any non-true value as disabled (sentinel emitted)", async () => { + for (const value of [false, "true", 1, undefined]) { + const hooks = await plugin({} as never); + const config: Config = { + provider: { + cursor: { options: { autoCompaction: value } as never }, + }, + }; + await hooks.config!(config); + expect(firstModelLimit(config)["input"]).toBe( + NO_AUTO_COMPACTION_INPUT_LIMIT, + ); + } + }); +});