diff --git a/README.md b/README.md index 4762dbc..8a4cda5 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ opencode loads two things from this one package: | opencode concept | What it loads | Export | | --- | --- | --- | -| Plugin (`plugin` config) | auth + provider registration + dynamic model listing + a refresh tool | `@stablekernel/opencode-cursor/plugin` | +| Plugin (`plugin` config) | auth + provider registration + dynamic model listing + a refresh tool | `@stablekernel/opencode-cursor` (resolved via the package's `./server` export) | | Provider (`provider.cursor.npm`) | a Vercel AI SDK `LanguageModelV3` that drives a local Cursor agent | `@stablekernel/opencode-cursor` (`createCursor`) | The plugin's `config` hook registers `provider.cursor` (pointing `npm` at this package) and seeds @@ -71,7 +71,7 @@ Add the plugin to your `opencode.json` (project or global): ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["@stablekernel/opencode-cursor/plugin"] + "plugin": ["@stablekernel/opencode-cursor"] } ``` diff --git a/package.json b/package.json index 88706be..86abae9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,10 @@ "types": "./dist/provider/index.d.ts", "import": "./dist/provider/index.js" }, + "./server": { + "types": "./dist/plugin/index.d.ts", + "import": "./dist/plugin/index.js" + }, "./plugin": { "types": "./dist/plugin/index.d.ts", "import": "./dist/plugin/index.js" diff --git a/src/cursor-runtime.ts b/src/cursor-runtime.ts index 37398b0..66c31a2 100644 --- a/src/cursor-runtime.ts +++ b/src/cursor-runtime.ts @@ -6,21 +6,27 @@ * dependency is missing) degrades gracefully into a clear error instead of * crashing opencode at startup. */ +import { ensureSqliteBinding } from "./native-binding.js"; + export type CursorSdkModule = typeof import("@cursor/sdk"); let cached: Promise | undefined; export async function loadCursorSdk(): Promise { if (!cached) { - cached = import("@cursor/sdk").catch((err: unknown) => { - // Allow a later retry if the failure was transient. - cached = undefined; - const detail = err instanceof Error ? err.message : String(err); - throw new Error( - `[opencode-cursor] Failed to load "@cursor/sdk". Make sure it is installed ` + - `(\`npm install @cursor/sdk\`). Original error: ${detail}`, - ); - }); + // @cursor/sdk eagerly requires sqlite3 (native addon); opencode's Bun + // install skips its build script, so repair the binding first if missing. + cached = ensureSqliteBinding() + .then(() => import("@cursor/sdk")) + .catch((err: unknown) => { + // Allow a later retry if the failure was transient. + cached = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + `[opencode-cursor] Failed to load "@cursor/sdk". Make sure it is installed ` + + `(\`npm install @cursor/sdk\`). Original error: ${detail}`, + ); + }); } return cached; } diff --git a/src/model-discovery.ts b/src/model-discovery.ts index 7570770..268fb5e 100644 --- a/src/model-discovery.ts +++ b/src/model-discovery.ts @@ -3,6 +3,7 @@ import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js"; import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js"; import { FALLBACK_MODELS } from "./fallback-models.js"; import { loadCursorSdk } from "./cursor-runtime.js"; +import { buildModelVariants, type CursorVariant } from "./model-variants.js"; export type ModelSource = "live" | "cache" | "fallback"; @@ -90,6 +91,13 @@ export interface OpencodeModelConfigEntry { reasoning: boolean; temperature: boolean; tool_call: boolean; + /** + * opencode model variants (thinking levels + plan mode). They MUST be seeded + * here: opencode discards the plugin `provider.models()` hook for providers + * absent from its models.dev catalog, so this config map is the only channel + * through which cursor model variants reach the picker. + */ + variants: Record; } /** @@ -107,6 +115,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record { const out: Record = {}; for (const param of item.parameters ?? []) { if (!REASONING_PARAM.test(param.id)) continue; - for (const { value } of param.values ?? []) { - // Key is unique across params; value object carries the param id+value. - const key = param.id.toLowerCase() === "thinking" ? value : `${param.id}-${value}`; + const values = (param.values ?? []).map((v) => v.value); + if (values.length === 0) continue; + + if (values.every((v) => BOOLEAN_VALUES.has(v))) { + // Boolean toggle (e.g. thinking=["false","true"]). Literal true/false + // variant names are meaningless in the picker — surface a single + // variant named after the param that switches it on. "Off" is the + // model's default (no variant selected). + if (values.includes("true")) { + out[param.id.toLowerCase()] = { params: { [param.id]: "true" } }; + } + continue; + } + + for (const value of values) { + // Key by the bare value (e.g. "high"); prefix with the param id only + // when two params share a value (e.g. reasoning-low vs effort-low). + const key = out[value] === undefined ? value : `${param.id}-${value}`; out[key] = { params: { [param.id]: value } }; } } - // Plan mode is orthogonal to model params and never auto-signaled by opencode, - // so always offer it as a selectable variant. - out["plan"] = { mode: "plan" }; - return out; } diff --git a/src/native-binding.ts b/src/native-binding.ts new file mode 100644 index 0000000..5846130 --- /dev/null +++ b/src/native-binding.ts @@ -0,0 +1,172 @@ +/** + * Self-heal for sqlite3's native binding. + * + * `@cursor/sdk` depends on `sqlite3` (a native addon). opencode installs + * plugin packages with Bun, which does not run sqlite3's `install` lifecycle + * script (`prebuild-install -r napi || node-gyp rebuild`), so the installed + * tree has **no** `node_sqlite3.node` binary and the SDK crashes at import + * with "Could not locate the bindings file". + * + * Before loading the SDK (in-process or via the Node sidecar) we check for a + * binding and, when it is missing, run sqlite3's own `prebuild-install -r napi` + * to fetch the prebuilt NAPI binary (ABI-portable across Node versions, also + * loadable by Bun). Failures degrade to a clear warning; the SDK import then + * surfaces its own error. + */ +import { execSync, spawn } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +export type EnsureResult = "present" | "repaired" | "failed" | "not-found"; + +export interface EnsureOptions { + /** Override the sqlite3 package directory (tests). */ + sqliteDir?: string; + /** Override the repair runner (tests). Returns true when the command succeeded. */ + run?: (sqliteDir: string) => Promise; + /** Override the warning sink (tests). */ + log?: (message: string) => void; +} + +/** Directories (relative to the sqlite3 package root) that may hold the binding. */ +const BINDING_ROOTS = ["build", "lib/binding", "compiled"]; + +function hasNodeFile(dir: string, depth: number): boolean { + if (depth < 0) return false; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return false; + } + for (const entry of entries) { + const path = join(dir, entry); + if (entry.endsWith(".node")) { + try { + if (statSync(path).isFile()) return true; + } catch { + // ignore unreadable entries + } + continue; + } + try { + if (statSync(path).isDirectory() && hasNodeFile(path, depth - 1)) return true; + } catch { + // ignore unreadable entries + } + } + return false; +} + +/** True when the sqlite3 package dir contains a compiled `.node` binding. */ +export function hasSqliteBinding(sqliteDir: string): boolean { + return BINDING_ROOTS.some((root) => hasNodeFile(join(sqliteDir, root), 3)); +} + +/** + * Locate the sqlite3 package directory that `@cursor/sdk` will load, walking + * the same resolution chain (our module -> @cursor/sdk -> sqlite3). + */ +export function resolveSqliteDir(): string | undefined { + const req = createRequire(import.meta.url); + try { + const sdkPkg = req.resolve("@cursor/sdk/package.json"); + return dirname(createRequire(sdkPkg).resolve("sqlite3/package.json")); + } catch { + // fall through: try resolving sqlite3 directly (hoisted installs) + } + try { + return dirname(req.resolve("sqlite3/package.json")); + } catch { + return undefined; + } +} + +function detectNodeExecutable(): string { + const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; + if (!isBun) return process.execPath; + // Under Bun prefer a real Node (matches the sidecar runtime); prebuild-install + // itself is plain JS, so Bun works as a last resort. + try { + const out = execSync(process.platform === "win32" ? "where node" : "command -v node", { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out.split("\n")[0] || process.execPath; + } catch { + return process.execPath; + } +} + +/** Default repair: run sqlite3's own `prebuild-install -r napi` in its package dir. */ +async function runPrebuildInstall(sqliteDir: string): Promise { + let bin: string; + try { + const req = createRequire(join(sqliteDir, "package.json")); + const pkgPath = req.resolve("prebuild-install/package.json"); + const pkg = (await import(pkgPath, { with: { type: "json" } })) as { + default: { bin?: string | Record }; + }; + const binField = pkg.default.bin; + const rel = typeof binField === "string" ? binField : binField?.["prebuild-install"]; + if (!rel) return false; + bin = join(dirname(pkgPath), rel); + } catch { + return false; + } + if (!existsSync(bin)) return false; + + return new Promise((resolve) => { + const child = spawn(detectNodeExecutable(), [bin, "-r", "napi"], { + cwd: sqliteDir, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolve(false)); + child.on("exit", (code) => { + if (code !== 0 && stderr && process.env["OPENCODE_CURSOR_DEBUG"]) { + console.error(`[opencode-cursor] prebuild-install stderr: ${stderr.trim()}`); + } + resolve(code === 0); + }); + }); +} + +let cached: Promise | undefined; + +/** + * Ensure the sqlite3 native binding exists, repairing it once per process if + * needed. Never throws; "failed"/"not-found" outcomes warn and let the SDK + * import surface its own error. + */ +export function ensureSqliteBinding(options: EnsureOptions = {}): Promise { + cached ??= (async () => { + const log = options.log ?? ((message: string) => console.error(message)); + const sqliteDir = options.sqliteDir ?? resolveSqliteDir(); + if (!sqliteDir || !existsSync(join(sqliteDir, "package.json"))) { + return "not-found"; + } + if (hasSqliteBinding(sqliteDir)) return "present"; + + const run = options.run ?? runPrebuildInstall; + const ok = await run(sqliteDir).catch(() => false); + if (ok && hasSqliteBinding(sqliteDir)) return "repaired"; + + log( + `[opencode-cursor] sqlite3 native binding is missing in ${sqliteDir} and automatic ` + + `repair failed. @cursor/sdk will not load. Fix manually with: ` + + `cd ${sqliteDir} && npx prebuild-install -r napi (or: npm rebuild sqlite3)`, + ); + return "failed"; + })(); + return cached; +} + +/** Test hook. */ +export function resetNativeBinding(): void { + cached = undefined; +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index bf2f6b1..c86d00a 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -93,9 +93,17 @@ export const CursorPlugin: Plugin = async (input) => { // Bridge opencode's session id to the provider: it lands in // providerOptions.cursor.sessionID, which the provider reads to pool/resume a // Cursor agent per session (when the `session` option is enabled). + // + // Also map opencode's plan AGENT to Cursor's plan mode. This hook fires + // after opencode merges the selected variant into `output.options`, so an + // explicit mode from the `plan` variant (or model options) wins — the + // agent-based default only applies when no mode was set. "chat.params": async (input, output) => { if (input.model?.providerID !== PROVIDER_ID) return; output.options = { ...(output.options ?? {}), sessionID: input.sessionID }; + if (input.agent === "plan" && output.options["mode"] === undefined) { + output.options["mode"] = "plan"; + } }, tool: { diff --git a/src/provider/agent-backend.ts b/src/provider/agent-backend.ts index 7d3e1d8..6529e46 100644 --- a/src/provider/agent-backend.ts +++ b/src/provider/agent-backend.ts @@ -14,6 +14,7 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { loadCursorSdk } from "../cursor-runtime.js"; +import { ensureSqliteBinding } from "../native-binding.js"; import { SidecarClient, type AgentLike } from "./sidecar-client.js"; export type { AgentLike, AgentRunLike, AgentSendOptions } from "./sidecar-client.js"; @@ -93,10 +94,18 @@ export function resolveSidecarScript(): string | undefined { function sidecarBackend(nodePath: string, scriptPath: string): AgentBackend { const client = new SidecarClient({ scriptPath, nodePath }); + // The sidecar imports @cursor/sdk (which eagerly requires sqlite3's native + // binding) in the child process; repair the binding before first use. return { kind: "sidecar", - createAgent: (options) => client.createAgent(options), - resumeAgent: (agentId, options) => client.resumeAgent(agentId, options), + createAgent: async (options) => { + await ensureSqliteBinding(); + return client.createAgent(options); + }, + resumeAgent: async (agentId, options) => { + await ensureSqliteBinding(); + return client.resumeAgent(agentId, options); + }, }; } diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 83a4214..a749886 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -181,6 +181,7 @@ export function cursorEventsToStream( controller.enqueue({ type: "stream-start", warnings: [] }); let textId: string | undefined; + let textCount = 0; let reasoningId: string | undefined; let reasoningCount = 0; let usage: LanguageModelV3Usage | undefined; @@ -200,15 +201,25 @@ export function cursorEventsToStream( reasoningId = undefined; } }; + // Close the open text part when reasoning resumes: hosts position a part + // where it STARTED, so appending later text to an earlier part would + // render the final answer above the reasoning that preceded it. + const closeText = () => { + if (textId) { + controller.enqueue({ type: "text-end", id: textId }); + textId = undefined; + } + }; const ensureText = () => { closeReasoning(); if (!textId) { - textId = "text-0"; + textId = `text-${textCount++}`; controller.enqueue({ type: "text-start", id: textId }); } return textId; }; const ensureReasoning = () => { + closeText(); if (!reasoningId) { reasoningId = `reasoning-${reasoningCount++}`; controller.enqueue({ type: "reasoning-start", id: reasoningId }); @@ -260,14 +271,14 @@ export function cursorEventsToStream( closeDanglingToolCalls(); closeReasoning(); - if (textId) controller.enqueue({ type: "text-end", id: textId }); + closeText(); controller.enqueue({ type: "finish", usage: usage ?? EMPTY_USAGE, finishReason: FINISH_STOP }); controller.close(); } catch (err) { controller.enqueue({ type: "error", error: err }); closeDanglingToolCalls(); closeReasoning(); - if (textId) controller.enqueue({ type: "text-end", id: textId }); + closeText(); controller.enqueue({ type: "finish", usage: usage ?? EMPTY_USAGE, finishReason: FINISH_ERROR }); controller.close(); } diff --git a/test/controls.test.ts b/test/controls.test.ts index 3343c0a..5aa2521 100644 --- a/test/controls.test.ts +++ b/test/controls.test.ts @@ -41,38 +41,4 @@ describe("resolveControls", () => { }); }); -describe("buildModelVariants", () => { - it("creates thinking variants from a reasoning parameter plus a plan variant", () => { - const item: ModelListItem = { - id: "composer-2.5", - displayName: "Composer 2.5", - parameters: [ - { id: "thinking", values: [{ value: "off" }, { value: "high" }] }, - ], - }; - expect(buildModelVariants(item)).toEqual({ - off: { params: { thinking: "off" } }, - high: { params: { thinking: "high" } }, - plan: { mode: "plan" }, - }); - }); - - it("prefixes non-thinking reasoning params and ignores unrelated params", () => { - const item: ModelListItem = { - id: "m", - displayName: "M", - parameters: [ - { id: "reasoningEffort", values: [{ value: "low" }] }, - { id: "verbosity", values: [{ value: "high" }] }, - ], - }; - expect(buildModelVariants(item)).toEqual({ - "reasoningEffort-low": { params: { reasoningEffort: "low" } }, - plan: { mode: "plan" }, - }); - }); - - it("offers at least a plan variant when there are no reasoning params", () => { - expect(buildModelVariants({ id: "m", displayName: "M" })).toEqual({ plan: { mode: "plan" } }); - }); -}); +// buildModelVariants behavior is covered in test/model-variants.test.ts. diff --git a/test/model-discovery.test.ts b/test/model-discovery.test.ts index c685825..aa84494 100644 --- a/test/model-discovery.test.ts +++ b/test/model-discovery.test.ts @@ -49,6 +49,19 @@ describe("toOpencodeModels", () => { const map = toOpencodeModels([{ id: "x", displayName: "" }]); expect(map["x"]!.name).toBe("x"); }); + + it("seeds variants so opencode's picker exposes thinking levels and plan mode", () => { + // opencode discards the provider.models() hook for providers outside the + // models.dev catalog, so the config-seeded models map is the ONLY place + // variants can come from — they must be present here. + const map = toOpencodeModels(items); + expect(map["composer-2.5"]!.variants).toEqual({ + off: { params: { thinking: "off" } }, + on: { params: { thinking: "on" } }, + }); + // No reasoning params → no variants (plan is an opencode agent, not a variant). + expect(map["plain"]!.variants).toEqual({}); + }); }); describe("discoverModels without a key", () => { diff --git a/test/model-variants.test.ts b/test/model-variants.test.ts new file mode 100644 index 0000000..7db464b --- /dev/null +++ b/test/model-variants.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { ModelListItem } from "@cursor/sdk"; +import { buildModelVariants } from "../src/model-variants.js"; + +function model(parameters: ModelListItem["parameters"]): ModelListItem { + return { id: "m", displayName: "M", parameters }; +} + +describe("buildModelVariants", () => { + it("collapses a boolean thinking param into a single param-named variant", () => { + // Real catalog shape: thinking=["false","true"] (claude-* models). A + // literal "true"/"false" variant pair is meaningless in the picker. + const variants = buildModelVariants( + model([{ id: "thinking", values: [{ value: "false" }, { value: "true" }] }]), + ); + expect(variants).toEqual({ thinking: { params: { thinking: "true" } } }); + }); + + it("keys enum reasoning params by their values", () => { + // Real catalog shape: reasoning=["none","low","medium","high","extra-high"]. + const variants = buildModelVariants( + model([{ id: "reasoning", values: [{ value: "low" }, { value: "high" }] }]), + ); + expect(variants).toEqual({ + low: { params: { reasoning: "low" } }, + high: { params: { reasoning: "high" } }, + }); + }); + + it("combines boolean thinking with enum effort (claude catalog shape)", () => { + const variants = buildModelVariants( + model([ + { id: "thinking", values: [{ value: "false" }, { value: "true" }] }, + { id: "effort", values: [{ value: "low" }, { value: "max" }] }, + ]), + ); + expect(variants).toEqual({ + thinking: { params: { thinking: "true" } }, + low: { params: { effort: "low" } }, + max: { params: { effort: "max" } }, + }); + }); + + it("prefixes a value key on collision between two enum params", () => { + const variants = buildModelVariants( + model([ + { id: "reasoning", values: [{ value: "low" }] }, + { id: "effort", values: [{ value: "low" }] }, + ]), + ); + expect(variants).toEqual({ + low: { params: { reasoning: "low" } }, + "effort-low": { params: { effort: "low" } }, + }); + }); + + it("ignores non-reasoning params and offers no plan variant", () => { + // fast/context are not reasoning levels; plan is opencode's plan AGENT + // (Tab), mapped via the chat.params hook — not a model variant. + const variants = buildModelVariants( + model([ + { id: "fast", values: [{ value: "false" }, { value: "true" }] }, + { id: "context", values: [{ value: "300k" }, { value: "1m" }] }, + ]), + ); + expect(variants).toEqual({}); + }); + + it("returns no variants for a model without parameters", () => { + expect(buildModelVariants(model(undefined))).toEqual({}); + }); +}); diff --git a/test/native-binding-wiring.test.ts b/test/native-binding-wiring.test.ts new file mode 100644 index 0000000..86cbfd6 --- /dev/null +++ b/test/native-binding-wiring.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ensureSqliteBinding = vi.fn(async () => "present" as const); +vi.mock("../src/native-binding.js", () => ({ ensureSqliteBinding })); + +const createAgent = vi.fn(async () => ({ agentId: "sidecar-agent" })); +const resumeAgent = vi.fn(async () => ({ agentId: "sidecar-agent" })); +vi.mock("../src/provider/sidecar-client.js", () => ({ + SidecarClient: class { + createAgent = createAgent; + resumeAgent = resumeAgent; + }, +})); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe("loadCursorSdk", () => { + it("ensures the sqlite binding before importing the SDK", async () => { + const { loadCursorSdk } = await import("../src/cursor-runtime.js"); + await loadCursorSdk(); + expect(ensureSqliteBinding).toHaveBeenCalled(); + }); +}); + +describe("sidecar backend", () => { + it("ensures the sqlite binding before creating an agent", async () => { + vi.stubEnv("OPENCODE_CURSOR_SIDECAR", "1"); + const { loadAgentBackend, resetAgentBackend } = await import( + "../src/provider/agent-backend.js" + ); + resetAgentBackend(); + const backend = loadAgentBackend(); + expect(backend.kind).toBe("sidecar"); + await backend.createAgent({}); + expect(ensureSqliteBinding).toHaveBeenCalled(); + expect(createAgent).toHaveBeenCalled(); + resetAgentBackend(); + }); +}); diff --git a/test/native-binding.test.ts b/test/native-binding.test.ts new file mode 100644 index 0000000..e1ef665 --- /dev/null +++ b/test/native-binding.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ensureSqliteBinding, + hasSqliteBinding, + resetNativeBinding, + resolveSqliteDir, +} from "../src/native-binding.js"; + +let dir: string; + +beforeEach(() => { + resetNativeBinding(); + dir = mkdtempSync(join(tmpdir(), "oc-cursor-sqlite-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** Lay down a minimal fake sqlite3 package dir. */ +function fakeSqliteDir(opts: { binding?: string } = {}): string { + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "sqlite3", version: "5.1.7" })); + if (opts.binding) { + const bindingPath = join(dir, opts.binding); + mkdirSync(join(bindingPath, ".."), { recursive: true }); + writeFileSync(bindingPath, "fake-native"); + } + return dir; +} + +describe("hasSqliteBinding", () => { + it("finds a binding in build/Release", () => { + fakeSqliteDir({ binding: "build/Release/node_sqlite3.node" }); + expect(hasSqliteBinding(dir)).toBe(true); + }); + + it("finds a binding in lib/binding//", () => { + fakeSqliteDir({ binding: "lib/binding/napi-v6-darwin-arm64/node_sqlite3.node" }); + expect(hasSqliteBinding(dir)).toBe(true); + }); + + it("returns false when no .node binary exists", () => { + fakeSqliteDir(); + expect(hasSqliteBinding(dir)).toBe(false); + }); + + it("returns false for a missing directory", () => { + expect(hasSqliteBinding(join(dir, "nope"))).toBe(false); + }); +}); + +describe("resolveSqliteDir", () => { + it("resolves the real sqlite3 package dir from this repo", () => { + // Integration: @cursor/sdk and sqlite3 are real deps of this repo. + const resolved = resolveSqliteDir(); + expect(resolved).toBeDefined(); + expect(resolved).toMatch(/node_modules[/\\]sqlite3$/); + expect(hasSqliteBinding(resolved!)).toBe(true); + }); +}); + +describe("ensureSqliteBinding", () => { + it("reports present without running a repair when the binding exists", async () => { + fakeSqliteDir({ binding: "build/Release/node_sqlite3.node" }); + const run = vi.fn(); + const result = await ensureSqliteBinding({ sqliteDir: dir, run }); + expect(result).toBe("present"); + expect(run).not.toHaveBeenCalled(); + }); + + it("runs the repair and reports repaired when it produces a binding", async () => { + fakeSqliteDir(); + const run = vi.fn(async (cwd: string) => { + mkdirSync(join(cwd, "build", "Release"), { recursive: true }); + writeFileSync(join(cwd, "build", "Release", "node_sqlite3.node"), "built"); + return true; + }); + const result = await ensureSqliteBinding({ sqliteDir: dir, run }); + expect(result).toBe("repaired"); + expect(run).toHaveBeenCalledWith(dir); + }); + + it("reports failed (without throwing) when the repair does not produce a binding", async () => { + fakeSqliteDir(); + const log = vi.fn(); + const run = vi.fn(async () => false); + const result = await ensureSqliteBinding({ sqliteDir: dir, run, log }); + expect(result).toBe("failed"); + expect(log).toHaveBeenCalled(); + }); + + it("reports not-found when sqlite3 cannot be located", async () => { + const run = vi.fn(); + const result = await ensureSqliteBinding({ sqliteDir: join(dir, "missing"), run }); + expect(result).toBe("not-found"); + expect(run).not.toHaveBeenCalled(); + }); + + it("caches the outcome (repair runs once across concurrent calls)", async () => { + fakeSqliteDir(); + let calls = 0; + const run = async (cwd: string) => { + calls++; + mkdirSync(join(cwd, "build", "Release"), { recursive: true }); + writeFileSync(join(cwd, "build", "Release", "node_sqlite3.node"), "built"); + return true; + }; + const [a, b] = await Promise.all([ + ensureSqliteBinding({ sqliteDir: dir, run }), + ensureSqliteBinding({ sqliteDir: dir, run }), + ]); + expect(a).toBe("repaired"); + expect(b).toBe("repaired"); + expect(calls).toBe(1); + }); +}); diff --git a/test/plugin-tools.test.ts b/test/plugin-tools.test.ts index 03f31fe..16adfaa 100644 --- a/test/plugin-tools.test.ts +++ b/test/plugin-tools.test.ts @@ -102,3 +102,38 @@ describe("CursorPlugin tool hook", () => { expect(runCloudAgent.mock.calls[0]![0].apiKey).toBe("env-key"); }); }); + +describe("CursorPlugin chat.params hook", () => { + const model = { providerID: "cursor", modelID: "composer-2.5" } as never; + + async function runHook(agent: string, options?: Record, m: unknown = model) { + const hooks = await plugin({ directory: "/work" } as never); + const output = { options: { ...(options ?? {}) } } as never; + await hooks["chat.params"]!( + { sessionID: "s1", agent, model: m, provider: {}, message: {} } as never, + output, + ); + return (output as { options: Record }).options; + } + + it("maps opencode's plan agent to Cursor plan mode", async () => { + const options = await runHook("plan"); + expect(options["mode"]).toBe("plan"); + expect(options["sessionID"]).toBe("s1"); + }); + + it("does not force a mode for non-plan agents", async () => { + const options = await runHook("build"); + expect(options["mode"]).toBeUndefined(); + }); + + it("never clobbers a mode already set by a selected variant", async () => { + const options = await runHook("plan", { mode: "agent" }); + expect(options["mode"]).toBe("agent"); + }); + + it("leaves other providers' params untouched", async () => { + const options = await runHook("plan", {}, { providerID: "anthropic", modelID: "x" }); + expect(options).toEqual({}); + }); +}); diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index f0cd5df..063244c 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -78,6 +78,45 @@ describe("cursorEventsToStream", () => { }); }); + 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 + // reasoning block — appending it to text-0 makes the final answer render + // ABOVE the thinking blocks in opencode's UI. + const events: CursorEvent[] = [ + { type: "text-delta", text: "intro" }, + { type: "reasoning-delta", text: "thinking" }, + { type: "text-delta", text: "final" }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + expect(types(parts)).toEqual([ + "stream-start", + "text-start", + "text-delta", + "text-end", // closed before reasoning starts + "reasoning-start", + "reasoning-delta", + "reasoning-end", + "text-start", // fresh part for the final answer + "text-delta", + "text-end", + "finish", + ]); + + const textStartIds = parts + .filter((p): p is Extract => p.type === "text-start") + .map((p) => p.id); + expect(new Set(textStartIds).size).toBe(2); + + // Each delta belongs to the part that was open at the time. + const deltas = parts.filter( + (p): p is Extract => p.type === "text-delta", + ); + expect(deltas[0]!.id).toBe(textStartIds[0]); + expect(deltas[1]!.id).toBe(textStartIds[1]); + }); + it("renders Cursor's own tool activity as reasoning, not tool-call parts", async () => { const events: CursorEvent[] = [ { type: "tool-call", id: "c1", name: "write", input: { path: "a.txt" } },