From 3ebd601c69b63bcdfe568a06a7bb3f02bea706ac Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Tue, 4 Aug 2026 07:08:32 -0500 Subject: [PATCH] fix(model): stop opencode auto-compacting Cursor sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cursor agent runtime compacts its own conversation as it approaches its context threshold (`preCompact` hook, `trigger: "auto"`), so a second opencode-driven pass is redundant. It is also actively harmful, in two ways. The compaction turn asks the model to summarize with zero tools declared. The Cursor agent runs its own tools regardless, and opencode rejects the result outright: Tool call not allowed while generating summary: Worse, compaction rewrites the transcript, so the next turn no longer matches what the Cursor agent saw. `classifyTurn` correctly reports a divergence and a fresh Cursor agent is created — and every distinct agentId permanently holds a guarded SQLite store.db/-wal/-shm triple. `SDKAgent.close()` cannot release those; it only flushes analytics and releases the executor lease, while the checkpoint store is cached in an agentId-keyed map evicted solely by dispose()/deleteAgent(). That descriptor growth fed an uncatchable EXC_GUARD kill of the whole opencode process (guard cookie 0x08fd4dbfade2dead — Apple's SQLite guard). Suppress the trigger with a large `limit.input`, which is the value opencode uses as its compaction threshold: Is(e) = limit.input ? limit.input - reserved : limit.context - maxOutput `limit.context` is left honest, so the TUI context gauge and cost reporting keep working — the alternative lever, `limit.context: 0`, would disable the trigger but blank the gauge and regress #89. `limit.input` is honored by opencode's runtime but is not declared in the published @opencode-ai/sdk config types, so it is excluded from `_limitKeyGuard` (which still protects context/output) and gated instead by a new assertion in the integration test: without it, opencode dropping support would silently restore auto-compaction and the fd leak. Verified against the opencode 1.18.11 binary by enumerating the call sites of Is() rather than textual hits on limit.input, since consumers reach it transitively. The only other consumer, Pd() (preserve-recent tokens, also used by manual /compact), clamps to 8000 both before and after. Confirmed end-to-end under an isolated HOME that the sentinel survives config validation into Provider.list() with limit.context intact. Manual /compact is unaffected. Opt back out with `provider.cursor.options.autoCompaction: true`. Tradeoff, documented in the README: this suppresses the proactive threshold 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 just the new message, but a cold replay resends everything; if that overflows, the turn fails and /compact is the manual recovery. --- CHANGELOG.md | 23 +++++++++++ README.md | 37 ++++++++++++++++++ scripts/integration-test.sh | 23 +++++++++++ src/model-discovery.ts | 35 ++++++++++++++--- src/model-limits.ts | 35 +++++++++++++++++ src/plugin/index.ts | 13 ++++++- src/plugin/model-v2.ts | 20 ++++++++-- test/model-discovery.test.ts | 33 ++++++++++++---- test/model-v2.test.ts | 19 +++++++++ test/plugin-auto-compaction.test.ts | 60 +++++++++++++++++++++++++++++ 10 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 test/plugin-auto-compaction.test.ts 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, + ); + } + }); +});