From fc449c205e584d2da9bec15478033c9d4f485504 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 17:21:57 -0500 Subject: [PATCH 01/22] chore: bump @cursor/sdk ^1.0.24, @opencode-ai/plugin ^1.18.4, node floor >=22.13 --- install.sh | 15 ++++++---- package-lock.json | 74 +++++++++++++++++++++++------------------------ package.json | 8 ++--- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/install.sh b/install.sh index 3fb4ed4..86d48fd 100755 --- a/install.sh +++ b/install.sh @@ -28,6 +28,7 @@ PKG_NAME="@stablekernel/opencode-cursor" PKG="${PKG_NAME}@latest" REPO_URL="https://github.com/stablekernel/opencode-cursor" MIN_NODE_MAJOR=22 +MIN_NODE_MINOR=13 SCOPE="global" ASSUME_YES=0 @@ -111,17 +112,19 @@ step "Checking Node.js (>= ${MIN_NODE_MAJOR}) on PATH" if command -v node >/dev/null 2>&1; then NODE_VER="$(node --version 2>/dev/null | sed 's/^v//')" NODE_MAJOR="${NODE_VER%%.*}" - if [ "${NODE_MAJOR:-0}" -ge "$MIN_NODE_MAJOR" ] 2>/dev/null; then + NODE_REST="${NODE_VER#*.}" + NODE_MINOR="${NODE_REST%%.*}" + if [ "${NODE_MAJOR:-0}" -gt "$MIN_NODE_MAJOR" ] || + { [ "${NODE_MAJOR:-0}" -eq "$MIN_NODE_MAJOR" ] && [ "${NODE_MINOR:-0}" -ge "$MIN_NODE_MINOR" ]; }; then ok "node v${NODE_VER}" else - warn "node v${NODE_VER} found, but the plugin needs Node ${MIN_NODE_MAJOR}+." - warn "opencode runs on Bun and spawns a Node sidecar for the Cursor SDK." - warn "Install Node ${MIN_NODE_MAJOR}+ (https://nodejs.org) and ensure it is on your PATH." + warn "node v${NODE_VER} found, but the plugin needs Node ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+." + warn "Install Node ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+ (https://nodejs.org) and ensure it is on your PATH." fi else warn "node not found on PATH." - warn "The plugin spawns a short-lived Node process to host the Cursor SDK." - warn "Install Node ${MIN_NODE_MAJOR}+ (https://nodejs.org) before using cursor/* models." + warn "The plugin may spawn a short-lived Node process to host the Cursor SDK." + warn "Install Node ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+ (https://nodejs.org) before using cursor/* models." fi # ---- 2. Add plugin to opencode.json ------------------------------------------ diff --git a/package-lock.json b/package-lock.json index b084041..729a7eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,13 @@ "license": "MIT", "dependencies": { "@connectrpc/connect-node": "^2.1.2", - "@cursor/sdk": "^1.0.23", - "@opencode-ai/plugin": "^1.17.14", + "@cursor/sdk": "^1.0.24", + "@opencode-ai/plugin": "^1.18.4", "semver": "^7.8.4" }, "devDependencies": { "@ai-sdk/provider": "^3.0.13", - "@opencode-ai/sdk": "^1.17.14", + "@opencode-ai/sdk": "^1.18.4", "@types/node": "^26.0.0", "@types/semver": "^7.7.1", "tsup": "^8.5.1", @@ -24,7 +24,7 @@ "vitest": "^4.1.8" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" }, "peerDependencies": { "@ai-sdk/provider": "^3.0.0" @@ -74,9 +74,9 @@ } }, "node_modules/@cursor/sdk": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.23.tgz", - "integrity": "sha512-VIh8oW89XXACUkQqB2N8TaU3A/y2jQieF++VC6QqIqKhKRKj7kd+pzeh43MXusuPpebtxtEzqvjaCLlc1xbYTQ==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.24.tgz", + "integrity": "sha512-pHoVRSiyBMTFBMjhAihvzXbiJSFNs63dULE+11jzCX/s1a7/I7Zp0cC4jWRw1ykckEPcho8QiEEgSR3qBRmDBw==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@bufbuild/protobuf": "1.10.0", @@ -90,17 +90,17 @@ "node": ">=22.13" }, "optionalDependencies": { - "@cursor/sdk-darwin-arm64": "1.0.23", - "@cursor/sdk-darwin-x64": "1.0.23", - "@cursor/sdk-linux-arm64": "1.0.23", - "@cursor/sdk-linux-x64": "1.0.23", - "@cursor/sdk-win32-x64": "1.0.23" + "@cursor/sdk-darwin-arm64": "1.0.24", + "@cursor/sdk-darwin-x64": "1.0.24", + "@cursor/sdk-linux-arm64": "1.0.24", + "@cursor/sdk-linux-x64": "1.0.24", + "@cursor/sdk-win32-x64": "1.0.24" } }, "node_modules/@cursor/sdk-darwin-arm64": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.23.tgz", - "integrity": "sha512-HpltGkIDFG+XgsETJho0OiOvGLyMKqZKhh4uXtD3ZKZcgTtLvKgbbU8jVgEMa3qodtfwPz5lpwAjs5jM1zOt6w==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.24.tgz", + "integrity": "sha512-0RIjcm2CFLuu0N/GnQ3G+8UGIQKv9jEgVkLr2gGjVoEN5UfwA6G3eLO/U6SfnMdFNkftJ9j/efcCHuS/OFPuNA==", "cpu": [ "arm64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@cursor/sdk-darwin-x64": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.23.tgz", - "integrity": "sha512-sjRQVhU7UL3kYSR9DzY+BAwa/iFdnPvTI3E62mIhj/2ey3LEnhIhotZQ9ymNJ0wA5BfhC7Be+JYcSMDyHxxr1g==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.24.tgz", + "integrity": "sha512-BmD1hbV0SCIRv9FGIhoM4XgCbWdcVGpjiPXUiIlGaYDM9ZXpmit6x9tx2iSGUIIc7ytpIYsQaSBEUsSQsNl7wQ==", "cpu": [ "x64" ], @@ -130,9 +130,9 @@ } }, "node_modules/@cursor/sdk-linux-arm64": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.23.tgz", - "integrity": "sha512-RizTwZ2Hhhaq8bnF3yGUZWBUvLA+/lExlTAg/5Levc9f7hNoDZViIP3XaVWY/8yzPAxypeds8tWacVIaAA2/Ig==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.24.tgz", + "integrity": "sha512-zCZcHwKPopqeXEsja2EmsV6En3hAhd3alnCfKbiYZdE9XQAaTza0XFji7a4uRjKGitlQ0JDIYHRIJg/2nvTkrQ==", "cpu": [ "arm64" ], @@ -146,9 +146,9 @@ } }, "node_modules/@cursor/sdk-linux-x64": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.23.tgz", - "integrity": "sha512-s93WBbi4hrE/uisTrEfNNH1m18bwfO6wmmsnIYQ3W3gbGBriFM1ZeroHxj1F6paMDE7o/dfUeW22lQ72rRZrSA==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.24.tgz", + "integrity": "sha512-pK80OM4CSB0G8ModhRhb85+FYEROmK8nad6Lj8LDZngQ1ZgJi/nrdgUuHykOtza7T00D14x9Q2l0pwf/ybXxog==", "cpu": [ "x64" ], @@ -162,9 +162,9 @@ } }, "node_modules/@cursor/sdk-win32-x64": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.23.tgz", - "integrity": "sha512-d1p1+tRJbNZTUqw8SPEW9OYtU02ynZysASDDvhpaEFtbPwGFUrez9sVbFHpbuXeOXL//H+faiHJanVFsUNfIZw==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.24.tgz", + "integrity": "sha512-vU4ossYSuw0adCK0ssZvwCeT6dmdBR29EMdHrB9G89SzIJ9xpodkKKjDL0aN5dEwy7wOKyUJIjprfw/A4U8F5A==", "cpu": [ "x64" ], @@ -831,20 +831,20 @@ } }, "node_modules/@opencode-ai/plugin": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.1.tgz", - "integrity": "sha512-qE2Jg4Jh7NU18Wl69zoCz7fOvHQxKmUXkSq4akAqCixYDrGO7heYG/wfvafVSsx0n6jWhCdIN8cToNU6G4lKfw==", + "version": "1.18.4", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.4.tgz", + "integrity": "sha512-Mkq128aLJo4E8Sb2bX8zrRlQ+I2WPaJ/n1kzaor8nTi/K/zNP4t8LGKwyMbuRoD/lhw4veSbzDOASSSypv3mcQ==", "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode-ai/sdk": "1.18.1", + "@opencode-ai/sdk": "1.18.4", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.4.3", - "@opentui/keymap": ">=0.4.3", - "@opentui/solid": ">=0.4.3" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -880,9 +880,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.1.tgz", - "integrity": "sha512-2mX9SXSDVtZ9i2yCOiXDXKx981EJp/ObUtaThUNn16jkjfwX/1j8r+UR/BKUmBLYq+zle7TtpXreXuO+gtVjKA==", + "version": "1.18.4", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.4.tgz", + "integrity": "sha512-p/3P0KtWknoLvpsk8QrUzCKd4Q1A5Z3RmACEvwuQBGxnUy4AGo379lUYMgt7fczFn53079oroLuo6BosQri+HA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" diff --git a/package.json b/package.json index cc06e98..b29d7d7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/stablekernel/opencode-cursor/issues" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" }, "keywords": [ "opencode", @@ -56,8 +56,8 @@ }, "dependencies": { "@connectrpc/connect-node": "^2.1.2", - "@cursor/sdk": "^1.0.23", - "@opencode-ai/plugin": "^1.17.14", + "@cursor/sdk": "^1.0.24", + "@opencode-ai/plugin": "^1.18.4", "semver": "^7.8.4" }, "overrides": { @@ -70,7 +70,7 @@ }, "devDependencies": { "@ai-sdk/provider": "^3.0.13", - "@opencode-ai/sdk": "^1.17.14", + "@opencode-ai/sdk": "^1.18.4", "@types/node": "^26.0.0", "@types/semver": "^7.7.1", "tsup": "^8.5.1", From 8c27dceebdfc4bb02a834b4e5733c5a7ca41c126 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 17:28:43 -0500 Subject: [PATCH 02/22] feat: typed error classification + sidecar error field fidelity --- src/provider/error-classify.ts | 67 ++++++++++++++++++++++++++++++++++ src/provider/sidecar-client.ts | 13 ++++++- src/sidecar/agent-host.mjs | 10 ++++- test/error-classify.test.ts | 49 +++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 src/provider/error-classify.ts create mode 100644 test/error-classify.test.ts diff --git a/src/provider/error-classify.ts b/src/provider/error-classify.ts new file mode 100644 index 0000000..caa8038 --- /dev/null +++ b/src/provider/error-classify.ts @@ -0,0 +1,67 @@ +/** + * Classify errors from the Cursor SDK into recovery actions. Works off plain + * {name, message, status?, code?, isRetryable?, helpUrl?} shape ONLY — errors + * that cross the Node sidecar arrive as re-hydrated plain Errors (never SDK + * instances), so `instanceof` discrimination is impossible on the Bun side and + * forbidden here. + */ +export type CursorErrorKind = + | "agent-not-found" + | "agent-busy" + | "rate-limit" + | "network" + | "auth" + | "config" + | "unknown"; + +export interface ClassifiedError { + kind: CursorErrorKind; + /** Safe to retry the same operation (bounded by the caller). */ + retryable: boolean; + status?: number; + helpUrl?: string; + message: string; +} + +export function classifyError(err: unknown): ClassifiedError { + const e = (err ?? {}) as { + name?: string; + message?: string; + status?: unknown; + code?: unknown; + isRetryable?: unknown; + helpUrl?: unknown; + }; + const name = typeof e.name === "string" ? e.name : "Error"; + const message = typeof e.message === "string" ? e.message : String(err); + const status = typeof e.status === "number" ? e.status : undefined; + const code = typeof e.code === "string" ? e.code : undefined; + const helpUrl = typeof e.helpUrl === "string" ? e.helpUrl : undefined; + const base = { ...(status !== undefined ? { status } : {}), ...(helpUrl ? { helpUrl } : {}), message }; + + switch (name) { + case "AgentNotFoundError": + return { kind: "agent-not-found", retryable: false, ...base }; + case "AgentBusyError": + return { kind: "agent-busy", retryable: false, ...base }; + case "RateLimitError": + return { kind: "rate-limit", retryable: true, ...base }; + case "NetworkError": + return { kind: "network", retryable: true, ...base }; + case "AuthenticationError": + return { kind: "auth", retryable: false, ...base }; + case "ConfigurationError": + case "IntegrationNotConnectedError": + case "UnsupportedRunOperationError": + return { kind: "config", retryable: false, ...base }; + } + + // Transports/serializers that lose the class name but keep status/code. + if (status === 401) return { kind: "auth", retryable: false, ...base }; + if (status === 429) return { kind: "rate-limit", retryable: true, ...base }; + if (status === 409) return { kind: "agent-busy", retryable: false, ...base }; + if (status === 503 || status === 504) return { kind: "network", retryable: true, ...base }; + if (code === "agent_not_found") return { kind: "agent-not-found", retryable: false, ...base }; + + return { kind: "unknown", retryable: e.isRetryable === true, ...base }; +} diff --git a/src/provider/sidecar-client.ts b/src/provider/sidecar-client.ts index d4eb955..99fa849 100644 --- a/src/provider/sidecar-client.ts +++ b/src/provider/sidecar-client.ts @@ -50,9 +50,20 @@ interface Pending { } function reviveError(error: unknown): Error { - const e = (error ?? {}) as { name?: string; message?: string }; + const e = (error ?? {}) as { + name?: string; + message?: string; + status?: number; + code?: string; + isRetryable?: boolean; + helpUrl?: string; + }; const err = new Error(e.message ?? "sidecar error"); if (e.name) err.name = e.name; + if (e.status !== undefined) (err as { status?: number }).status = e.status; + if (e.code !== undefined) (err as { code?: string }).code = e.code; + if (e.isRetryable !== undefined) (err as { isRetryable?: boolean }).isRetryable = e.isRetryable; + if (e.helpUrl !== undefined) (err as { helpUrl?: string }).helpUrl = e.helpUrl; return err; } diff --git a/src/sidecar/agent-host.mjs b/src/sidecar/agent-host.mjs index 311cece..96f12e1 100644 --- a/src/sidecar/agent-host.mjs +++ b/src/sidecar/agent-host.mjs @@ -20,10 +20,16 @@ */ import { createInterface } from "node:readline"; -/** Plain-data error shape that survives JSON; name preserved for retry logic. */ +/** Plain-data error shape that survives JSON; name + classification fields + * preserved so the Bun side can discriminate (see error-classify.ts). */ function serializeError(err) { if (err instanceof Error) { - return { name: err.name, message: err.message }; + const out = { name: err.name, message: err.message }; + for (const k of ["status", "code", "isRetryable", "helpUrl"]) { + const v = err[k]; + if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") out[k] = v; + } + return out; } return { name: "Error", message: String(err) }; } diff --git a/test/error-classify.test.ts b/test/error-classify.test.ts new file mode 100644 index 0000000..0d8291f --- /dev/null +++ b/test/error-classify.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { classifyError } from "../src/provider/error-classify.js"; + +function named(name: string, extra: Record = {}): Error { + const err = new Error(`${name} message`); + err.name = name; + return Object.assign(err, extra); +} + +describe("classifyError", () => { + it("classifies by error name (sidecar-revived shape)", () => { + expect(classifyError(named("AgentNotFoundError")).kind).toBe("agent-not-found"); + expect(classifyError(named("AgentBusyError")).kind).toBe("agent-busy"); + expect(classifyError(named("RateLimitError")).kind).toBe("rate-limit"); + expect(classifyError(named("NetworkError")).kind).toBe("network"); + expect(classifyError(named("AuthenticationError")).kind).toBe("auth"); + expect(classifyError(named("ConfigurationError")).kind).toBe("config"); + expect(classifyError(named("IntegrationNotConnectedError")).kind).toBe("config"); + expect(classifyError(named("UnsupportedRunOperationError")).kind).toBe("config"); + expect(classifyError(named("SomeOtherError")).kind).toBe("unknown"); + }); + + it("falls back to status/code heuristics when the name is lost", () => { + expect(classifyError(named("Error", { status: 401 })).kind).toBe("auth"); + expect(classifyError(named("Error", { status: 429 })).kind).toBe("rate-limit"); + expect(classifyError(named("Error", { status: 409 })).kind).toBe("agent-busy"); + expect(classifyError(named("Error", { status: 503 })).kind).toBe("network"); + expect(classifyError(named("Error", { code: "agent_not_found" })).kind).toBe("agent-not-found"); + }); + + it("marks rate-limit and network retryable, auth and config not", () => { + expect(classifyError(named("RateLimitError")).retryable).toBe(true); + expect(classifyError(named("NetworkError")).retryable).toBe(true); + expect(classifyError(named("AuthenticationError")).retryable).toBe(false); + expect(classifyError(named("ConfigurationError")).retryable).toBe(false); + expect(classifyError(named("AgentNotFoundError")).retryable).toBe(false); + }); + + it("carries status and helpUrl through", () => { + const c = classifyError(named("IntegrationNotConnectedError", { status: 400, helpUrl: "https://cursor.com/settings" })); + expect(c.status).toBe(400); + expect(c.helpUrl).toBe("https://cursor.com/settings"); + }); + + it("handles non-Error throws", () => { + expect(classifyError("boom").kind).toBe("unknown"); + expect(classifyError(undefined).kind).toBe("unknown"); + }); +}); From 5e7b6cbfb18e6e969b48f1581efe6b876a43c741 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 17:34:41 -0500 Subject: [PATCH 03/22] test: sidecar error round-trip field fidelity --- test/fixtures/fake-cursor-sdk.mjs | 10 ++++++++++ test/sidecar.test.ts | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/test/fixtures/fake-cursor-sdk.mjs b/test/fixtures/fake-cursor-sdk.mjs index c6a2227..e5e9665 100644 --- a/test/fixtures/fake-cursor-sdk.mjs +++ b/test/fixtures/fake-cursor-sdk.mjs @@ -5,6 +5,7 @@ * * Message-text driven behaviors: * "busy" -> send() rejects with AgentBusyError unless local.force is set + * "rich" -> send() rejects with an error carrying status/code/isRetryable/helpUrl * "hang" -> run.wait() never resolves (until cancel(), which resolves cancelled) * other -> emits one text-delta "echo:" update, wait() -> done: */ @@ -20,6 +21,15 @@ function makeAgent(agentId, options) { err.name = "AgentBusyError"; throw err; } + if (text === "rich") { + const err = new Error("rate limited"); + err.name = "RateLimitError"; + err.status = 429; + err.code = "rate_limited"; + err.isRetryable = true; + err.helpUrl = "https://example.com/rate-limits"; + throw err; + } sendOptions?.onDelta?.({ update: { type: "text-delta", text: `echo:${text}` } }); if (text === "hang") { let resolveWait; diff --git a/test/sidecar.test.ts b/test/sidecar.test.ts index 5e213ef..eebeb6e 100644 --- a/test/sidecar.test.ts +++ b/test/sidecar.test.ts @@ -65,6 +65,21 @@ describe("SidecarClient", () => { await expect(run.wait()).resolves.toMatchObject({ status: "finished" }); }); + it("preserves error classification fields across the process boundary", async () => { + const client = makeClient(); + const agent = await client.createAgent(CREATE_OPTIONS); + await expect(agent.send({ type: "user", text: "rich" }, { mode: "agent" })).rejects.toMatchObject( + { + name: "RateLimitError", + message: "rate limited", + status: 429, + code: "rate_limited", + isRetryable: true, + helpUrl: "https://example.com/rate-limits", + }, + ); + }); + it("multiplexes concurrent sends over one child", async () => { const client = makeClient(); const [a, b] = await Promise.all([ From 06d0d2312018321e9743ab82e7d1d4c261bbfdb5 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 17:40:50 -0500 Subject: [PATCH 04/22] feat: transport resolution (http1/http2-direct/sidecar) with provider option + env --- src/provider/agent-backend.ts | 114 +++++++++++++++++++++++++--------- src/provider/index.ts | 8 +++ test/agent-backend.test.ts | 32 +--------- test/transport.test.ts | 61 ++++++++++++++++++ 4 files changed, 156 insertions(+), 59 deletions(-) create mode 100644 test/transport.test.ts diff --git a/src/provider/agent-backend.ts b/src/provider/agent-backend.ts index 7d3e1d8..ae0c009 100644 --- a/src/provider/agent-backend.ts +++ b/src/provider/agent-backend.ts @@ -1,14 +1,18 @@ /** - * Selects where Cursor agents run: + * Selects where Cursor agents run — three transports: * - * - "in-process": straight through `@cursor/sdk` in this process (Node — the - * normal path for tests, scripts, and any non-Bun host). - * - "sidecar": a spawned Node child hosting the SDK (Bun — opencode's runtime — - * has a `node:http2` bug that kills Cursor's streaming RPC with - * NGHTTP2_FRAME_SIZE_ERROR, losing tool-completion updates; see - * src/sidecar/agent-host.mjs). + * - "http1": in-process via `@cursor/sdk` with + * `Cursor.configure({ local: { useHttp1ForAgent: true } })` — HTTP/1.1 + SSE, + * the Bun-safe path (Bun's `node:http2` bug kills Cursor's streaming RPC with + * NGHTTP2_FRAME_SIZE_ERROR; see oven-sh/bun#31499). + * - "http2-direct": in-process via `@cursor/sdk` default HTTP/2 transport + * (Node — the normal path for tests, scripts, and any non-Bun host). + * - "sidecar": a spawned Node child hosting the SDK (the historical Bun + * workaround; see src/sidecar/agent-host.mjs). * - * Override with OPENCODE_CURSOR_SIDECAR=1/0 (force on/off). + * Resolution order: provider option (`transport`) -> OPENCODE_CURSOR_TRANSPORT + * -> legacy OPENCODE_CURSOR_SIDECAR (1=sidecar, 0=http2-direct) -> default + * (Bun: DEFAULT_BUN_TRANSPORT, pre-evidence-gate "sidecar"; Node: http2-direct). */ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; @@ -18,8 +22,27 @@ import { SidecarClient, type AgentLike } from "./sidecar-client.js"; export type { AgentLike, AgentRunLike, AgentSendOptions } from "./sidecar-client.js"; +export type TransportKind = "http1" | "http2-direct" | "sidecar"; export type BackendKind = "in-process" | "sidecar"; +/** + * Bun default. Pre-evidence-gate this is "sidecar" (the proven path); the gate + * task (Task 6) flips it to "http1" once the live matrix passes. Until then + * HTTP/1.1 is opt-in via OPENCODE_CURSOR_TRANSPORT=http1. + */ +export const DEFAULT_BUN_TRANSPORT: TransportKind = "sidecar"; + +let preferredTransport: TransportKind | undefined; + +/** Provider-option override (createCursor({transport})); beats env. Process-global. */ +export function setPreferredTransport(t: TransportKind | undefined): void { + preferredTransport = t; +} + +function isTransportKind(v: string | undefined): v is TransportKind { + return v === "http1" || v === "http2-direct" || v === "sidecar"; +} + export interface AgentBackend { kind: BackendKind; createAgent(options: unknown): Promise; @@ -32,12 +55,29 @@ export interface BackendEnvironment { nodePath: string | undefined; } -/** Pure selection logic (unit-testable without spawning anything). */ -export function resolveBackendKind(env: BackendEnvironment): BackendKind { - const override = process.env["OPENCODE_CURSOR_SIDECAR"]; - if (override === "0" || override === "false") return "in-process"; - if (override === "1" || override === "true") return env.nodePath ? "sidecar" : "in-process"; - return env.isBun && env.nodePath ? "sidecar" : "in-process"; +/** + * Where Cursor agents run. Resolution: provider option -> OPENCODE_CURSOR_TRANSPORT + * -> legacy OPENCODE_CURSOR_SIDECAR (1=sidecar, 0=http2-direct) -> default + * (Bun: DEFAULT_BUN_TRANSPORT; Node: http2-direct, today's in-process path). + */ +export function resolveTransport(env: BackendEnvironment): TransportKind { + const requested = + preferredTransport ?? + (isTransportKind(process.env["OPENCODE_CURSOR_TRANSPORT"]) + ? (process.env["OPENCODE_CURSOR_TRANSPORT"] as TransportKind) + : undefined); + if (requested) { + if (requested === "sidecar" && !env.nodePath) { + return env.isBun ? "http1" : "http2-direct"; + } + return requested; + } + const legacy = process.env["OPENCODE_CURSOR_SIDECAR"]; + if (legacy === "1" || legacy === "true") { + return env.nodePath ? "sidecar" : env.isBun ? "http1" : "http2-direct"; + } + if (legacy === "0" || legacy === "false") return "http2-direct"; + return env.isBun ? DEFAULT_BUN_TRANSPORT : "http2-direct"; } function detectNode(): string | undefined { @@ -59,15 +99,27 @@ function detectEnvironment(): BackendEnvironment { return { isBun, nodePath: needsNode ? detectNode() : process.execPath }; } -function inProcessBackend(): AgentBackend { +let http1Configured = false; + +/** Idempotent: enable the SDK's HTTP/1.1 agent transport (Bun-sanctioned path). */ +async function ensureHttp1Configured(): Promise { + if (http1Configured) return; + const { Cursor } = await loadCursorSdk(); + Cursor.configure({ local: { useHttp1ForAgent: true } }); + http1Configured = true; +} + +function inProcessBackend(useHttp1: boolean): AgentBackend { return { kind: "in-process", createAgent: async (options) => { const { Agent } = await loadCursorSdk(); + if (useHttp1) await ensureHttp1Configured(); return (await Agent.create(options as never)) as unknown as AgentLike; }, resumeAgent: async (agentId, options) => { const { Agent } = await loadCursorSdk(); + if (useHttp1) await ensureHttp1Configured(); return (await Agent.resume(agentId, options as never)) as unknown as AgentLike; }, }; @@ -106,25 +158,29 @@ let cached: AgentBackend | undefined; export function loadAgentBackend(): AgentBackend { if (!cached) { const env = detectEnvironment(); - const kind = resolveBackendKind(env); - const scriptPath = kind === "sidecar" ? resolveSidecarScript() : undefined; - // A user who explicitly opted out (OPENCODE_CURSOR_SIDECAR=0/false) has - // accepted the in-process behavior and should not be warned. - const override = process.env["OPENCODE_CURSOR_SIDECAR"]; - const optedOut = override === "0" || override === "false"; - if (env.isBun && !optedOut && (kind === "in-process" || !scriptPath)) { + const transport = resolveTransport(env); + const scriptPath = transport === "sidecar" ? resolveSidecarScript() : undefined; + if (transport === "sidecar" && (!env.nodePath || !scriptPath)) { + // Explicit sidecar request we can't satisfy: fall back loudly. + console.error( + "[opencode-cursor] Node sidecar requested but unavailable " + + `(node: ${env.nodePath ?? "not found"}, script: ${scriptPath ?? "not found"}); ` + + "falling back to in-process HTTP/1.1 transport.", + ); + cached = inProcessBackend(true); + return cached; + } + if (transport === "http2-direct" && env.isBun) { console.error( - "[opencode-cursor] Running under Bun without a usable Node sidecar " + - `(node: ${env.nodePath ?? "not found"}, script: ${scriptPath ?? "not found"}): ` + - "Cursor native tool calls may fail (Bun node:http2 incompatibility). " + - "Install Node.js to enable the sidecar, or set OPENCODE_CURSOR_SIDECAR=0 " + - "to silence this warning.", + "[opencode-cursor] http2-direct under Bun: Cursor streams may fail " + + "(Bun node:http2 incompatibility, oven-sh/bun#31499). " + + "Set OPENCODE_CURSOR_TRANSPORT=http1 (recommended) or sidecar.", ); } cached = - kind === "sidecar" && env.nodePath && scriptPath + transport === "sidecar" && env.nodePath && scriptPath ? sidecarBackend(env.nodePath, scriptPath) - : inProcessBackend(); + : inProcessBackend(transport === "http1"); } return cached; } diff --git a/src/provider/index.ts b/src/provider/index.ts index af3cdab..d604ef7 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -11,6 +11,7 @@ import type { SettingSource, } from "@cursor/sdk"; import { resolveCursorApiKey } from "../api-key.js"; +import { setPreferredTransport } from "./agent-backend.js"; import { CursorLanguageModel, type CursorModelConfig, @@ -83,6 +84,12 @@ export interface CursorProviderOptions { * - "omit": not forwarded at all. */ systemPrompt?: SystemPromptMode; + /** + * Transport for Cursor agent traffic: "http1" (in-process, Bun-safe), + * "http2-direct" (in-process, Node only), "sidecar" (Node child, rollback). + * Beats OPENCODE_CURSOR_TRANSPORT. Process-global: first provider wins. + */ + transport?: "http1" | "http2-direct" | "sidecar"; } /** @@ -94,6 +101,7 @@ export interface CursorProviderOptions { * and then calls `.languageModel(modelId)`. */ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 { + if (options.transport) setPreferredTransport(options.transport); const mcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0 ? options.mcpServers diff --git a/test/agent-backend.test.ts b/test/agent-backend.test.ts index c519dc9..3f00c0b 100644 --- a/test/agent-backend.test.ts +++ b/test/agent-backend.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; vi.mock("../src/cursor-runtime.js", () => ({ loadCursorSdk: async () => ({ @@ -9,14 +9,10 @@ vi.mock("../src/cursor-runtime.js", () => ({ }), })); -const { resolveBackendKind, resolveSidecarScript } = await import( +const { resolveSidecarScript } = await import( "../src/provider/agent-backend.js" ); -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("resolveSidecarScript", () => { it("locates the agent-host script regardless of bundle layout", () => { // From src/ the .mjs source must be found; from dist/ the bundled .js @@ -25,27 +21,3 @@ describe("resolveSidecarScript", () => { expect(script).toMatch(/sidecar[/\\]agent-host\.(mjs|js)$/); }); }); - -describe("resolveBackendKind", () => { - it("uses in-process under node (no Bun global)", () => { - expect(resolveBackendKind({ isBun: false, nodePath: "/usr/bin/node" })).toBe("in-process"); - }); - - it("uses the sidecar under bun when node is available", () => { - expect(resolveBackendKind({ isBun: true, nodePath: "/usr/bin/node" })).toBe("sidecar"); - }); - - it("falls back to in-process under bun when node is missing", () => { - expect(resolveBackendKind({ isBun: true, nodePath: undefined })).toBe("in-process"); - }); - - it("honors OPENCODE_CURSOR_SIDECAR=0 override even under bun", () => { - vi.stubEnv("OPENCODE_CURSOR_SIDECAR", "0"); - expect(resolveBackendKind({ isBun: true, nodePath: "/usr/bin/node" })).toBe("in-process"); - }); - - it("honors OPENCODE_CURSOR_SIDECAR=1 override under node", () => { - vi.stubEnv("OPENCODE_CURSOR_SIDECAR", "1"); - expect(resolveBackendKind({ isBun: false, nodePath: "/usr/bin/node" })).toBe("sidecar"); - }); -}); diff --git a/test/transport.test.ts b/test/transport.test.ts new file mode 100644 index 0000000..2f3abc7 --- /dev/null +++ b/test/transport.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + DEFAULT_BUN_TRANSPORT, + resolveTransport, + setPreferredTransport, +} from "../src/provider/agent-backend.js"; + +const ENV_KEYS = ["OPENCODE_CURSOR_TRANSPORT", "OPENCODE_CURSOR_SIDECAR"] as const; +const saved: Record = {}; +for (const k of ENV_KEYS) saved[k] = process.env[k]; + +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + setPreferredTransport(undefined); +}); + +const bun = { isBun: true, nodePath: "/usr/bin/node" }; +const bunNoNode = { isBun: true, nodePath: undefined }; +const node = { isBun: false, nodePath: process.execPath }; + +describe("resolveTransport", () => { + it("defaults: Bun -> sidecar (pre-gate), Node -> http2-direct", () => { + delete process.env.OPENCODE_CURSOR_TRANSPORT; + delete process.env.OPENCODE_CURSOR_SIDECAR; + expect(resolveTransport(bun)).toBe("sidecar"); + expect(resolveTransport(node)).toBe("http2-direct"); + expect(DEFAULT_BUN_TRANSPORT).toBe("sidecar"); + }); + + it("explicit OPENCODE_CURSOR_TRANSPORT wins", () => { + process.env.OPENCODE_CURSOR_TRANSPORT = "http1"; + expect(resolveTransport(bun)).toBe("http1"); + expect(resolveTransport(node)).toBe("http1"); + process.env.OPENCODE_CURSOR_TRANSPORT = "http2-direct"; + expect(resolveTransport(bun)).toBe("http2-direct"); + process.env.OPENCODE_CURSOR_TRANSPORT = "sidecar"; + expect(resolveTransport(bun)).toBe("sidecar"); + }); + + it("sidecar without node degrades: Bun -> http1, Node -> http2-direct", () => { + process.env.OPENCODE_CURSOR_TRANSPORT = "sidecar"; + expect(resolveTransport(bunNoNode)).toBe("http1"); + }); + + it("legacy OPENCODE_CURSOR_SIDECAR maps: 1 -> sidecar, 0 -> http2-direct", () => { + delete process.env.OPENCODE_CURSOR_TRANSPORT; + process.env.OPENCODE_CURSOR_SIDECAR = "1"; + expect(resolveTransport(bun)).toBe("sidecar"); + process.env.OPENCODE_CURSOR_SIDECAR = "0"; + expect(resolveTransport(bun)).toBe("http2-direct"); + }); + + it("provider option setPreferredTransport beats env", () => { + process.env.OPENCODE_CURSOR_TRANSPORT = "sidecar"; + setPreferredTransport("http1"); + expect(resolveTransport(bun)).toBe("http1"); + }); +}); From 9be90222d58f18c3cb6e5adff91e97365cfa8eaf Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 17:50:52 -0500 Subject: [PATCH 05/22] test: e2e harness (bun driver, cheapest-model picker, e2e vitest config) --- package.json | 1 + test/e2e/driver.ts | 109 +++++++++++++++++++++++++++++++++++++++++++ test/e2e/model.ts | 13 ++++++ vitest.e2e.config.ts | 10 ++++ 4 files changed, 133 insertions(+) create mode 100644 test/e2e/driver.ts create mode 100644 test/e2e/model.ts create mode 100644 vitest.e2e.config.ts diff --git a/package.json b/package.json index b29d7d7..b2751f1 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "vitest run --config vitest.e2e.config.ts --passWithNoTests", "prepublishOnly": "npm run typecheck && npm test && npm run build" }, "dependencies": { diff --git a/test/e2e/driver.ts b/test/e2e/driver.ts new file mode 100644 index 0000000..f38e99e --- /dev/null +++ b/test/e2e/driver.ts @@ -0,0 +1,109 @@ +/** + * E2E driver — runs under Bun (opencode's runtime) and exercises the provider + * in-process exactly as opencode's plugin host would. NDJSON events on stdout. + * + * Usage: bun test/e2e/driver.ts + * Scenarios: tool-parity | resume | long-stream | web-search | stall-cancel + * Env: CURSOR_API_KEY (required), CURSOR_E2E_MODEL, OPENCODE_CURSOR_TRANSPORT. + */ +import { createCursor } from "../../src/provider/index.js"; + +const scenario = process.argv[2]; +const model = process.env.CURSOR_E2E_MODEL ?? "auto"; +const apiKey = process.env.CURSOR_API_KEY; +if (!apiKey) { + console.error("CURSOR_API_KEY required"); + process.exit(2); +} + +const provider = createCursor({ cwd: process.cwd(), apiKey }); +const lm = provider.languageModel(model); + +function emit(obj: unknown): void { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +function userPrompt(text: string): unknown { + return [{ role: "user", content: [{ type: "text", text }] }]; +} + +interface RunSummary { + ev: "run"; + firstDeltaMs?: number; + deltaChars: number; + parts: Array<{ type: string; name?: string; id?: string; isError?: boolean }>; + error?: string; +} + +async function runOnce(text: string, sessionID?: string, abortMs?: number): Promise { + const t0 = Date.now(); + let firstDeltaMs: number | undefined; + let deltaChars = 0; + const parts: RunSummary["parts"] = []; + const ac = new AbortController(); + if (abortMs) setTimeout(() => ac.abort(), abortMs); + try { + const { stream } = await lm.doStream({ + prompt: userPrompt(text), + ...(ac.signal ? { abortSignal: ac.signal } : {}), + providerOptions: { cursor: { ...(sessionID ? { sessionID } : {}) } }, + } as never); + const reader = (stream as ReadableStream<{ type: string; [k: string]: unknown }>).getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if ( + firstDeltaMs === undefined && + (value.type === "text-delta" || value.type === "reasoning-delta") + ) { + firstDeltaMs = Date.now() - t0; + } + if (value.type === "text-delta") deltaChars += String(value.delta ?? "").length; + const part: RunSummary["parts"][number] = { type: value.type }; + if (value.type === "tool-call" || value.type === "tool-result") { + part.name = value.toolName as string | undefined; + part.id = value.toolCallId as string | undefined; + part.isError = value.isError as boolean | undefined; + } + if (value.type === "error") part.name = String(value.error ?? ""); + parts.push(part); + } + } catch (err) { + return { ev: "run", deltaChars, parts, error: err instanceof Error ? err.message : String(err) }; + } + const summary: RunSummary = { ev: "run", deltaChars, parts }; + if (firstDeltaMs !== undefined) summary.firstDeltaMs = firstDeltaMs; + return summary; +} + +switch (scenario) { + case "tool-parity": + emit(await runOnce( + "Use your read tool to read package.json, then use your file-writing tool to create e2e-tmp.txt containing the word hello, then reply with exactly: DONE", + )); + break; + case "resume": { + const sessionID = `e2e-${Date.now()}`; + emit(await runOnce("Create e2e-tmp.txt containing the word hello via your file-writing tool, then reply exactly: DONE", sessionID)); + emit(await runOnce("What single word does e2e-tmp.txt contain? Answer with just that word.", sessionID)); + break; + } + case "long-stream": + emit(await runOnce( + "Output a numbered list from 1 to 400, each line being 'NNN: the quick brown fox jumps over the lazy dog'. No commentary, just the list.", + )); + break; + case "web-search": + emit(await runOnce("Search the web for the current UTC date, then answer with just the date.")); + break; + case "stall-cancel": { + const sessionID = `e2e-${Date.now()}`; + emit(await runOnce("Count slowly from 1 to 100 with a short sentence per number.", sessionID, 3_000)); + // The aborted run above may leave the agent wedged; the next send must recover. + emit(await runOnce("Reply with exactly: RECOVERED", sessionID)); + break; + } + default: + console.error(`unknown scenario "${scenario}"`); + process.exit(2); +} diff --git a/test/e2e/model.ts b/test/e2e/model.ts new file mode 100644 index 0000000..d2d5886 --- /dev/null +++ b/test/e2e/model.ts @@ -0,0 +1,13 @@ +/** Resolve the cheapest live model for E2E: env override wins, else the first + * catalog id matching a cheap tier, else "auto" (Cursor routes). */ +export async function pickE2EModel(): Promise { + if (process.env.CURSOR_E2E_MODEL) return process.env.CURSOR_E2E_MODEL; + try { + const { Cursor } = await import("@cursor/sdk"); + const models = await Cursor.models.list({ apiKey: process.env.CURSOR_API_KEY }); + const cheap = models.find((m) => /haiku|mini|small|lite|nano/i.test(m.id)); + return (cheap ?? models[0])?.id ?? "auto"; + } catch { + return "auto"; + } +} diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..06dc33b --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/e2e/**/*.e2e.ts"], + testTimeout: 300_000, + hookTimeout: 120_000, + maxWorkers: 1, + }, +}); From 4f208ecf758825a41f54a9013c6769a0dae89b2f Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 18:18:00 -0500 Subject: [PATCH 06/22] test: robust long-stream + forced web-search scenarios in evidence matrix --- test/e2e/driver.ts | 21 +++++++-- test/e2e/transport.e2e.ts | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 test/e2e/transport.e2e.ts diff --git a/test/e2e/driver.ts b/test/e2e/driver.ts index f38e99e..01f292f 100644 --- a/test/e2e/driver.ts +++ b/test/e2e/driver.ts @@ -31,6 +31,7 @@ interface RunSummary { ev: "run"; firstDeltaMs?: number; deltaChars: number; + payloadChars: number; parts: Array<{ type: string; name?: string; id?: string; isError?: boolean }>; error?: string; } @@ -39,6 +40,7 @@ async function runOnce(text: string, sessionID?: string, abortMs?: number): Prom const t0 = Date.now(); let firstDeltaMs: number | undefined; let deltaChars = 0; + let payloadChars = 0; const parts: RunSummary["parts"] = []; const ac = new AbortController(); if (abortMs) setTimeout(() => ac.abort(), abortMs); @@ -58,20 +60,25 @@ async function runOnce(text: string, sessionID?: string, abortMs?: number): Prom ) { firstDeltaMs = Date.now() - t0; } - if (value.type === "text-delta") deltaChars += String(value.delta ?? "").length; + if (value.type === "text-delta") { + const len = String(value.delta ?? "").length; + deltaChars += len; + payloadChars += len; + } const part: RunSummary["parts"][number] = { type: value.type }; if (value.type === "tool-call" || value.type === "tool-result") { part.name = value.toolName as string | undefined; part.id = value.toolCallId as string | undefined; part.isError = value.isError as boolean | undefined; + payloadChars += JSON.stringify(value).length; } if (value.type === "error") part.name = String(value.error ?? ""); parts.push(part); } } catch (err) { - return { ev: "run", deltaChars, parts, error: err instanceof Error ? err.message : String(err) }; + return { ev: "run", deltaChars, payloadChars, parts, error: err instanceof Error ? err.message : String(err) }; } - const summary: RunSummary = { ev: "run", deltaChars, parts }; + const summary: RunSummary = { ev: "run", deltaChars, payloadChars, parts }; if (firstDeltaMs !== undefined) summary.firstDeltaMs = firstDeltaMs; return summary; } @@ -89,12 +96,16 @@ switch (scenario) { break; } case "long-stream": + // NOTE: read tool paginates server-side (~100-338 lines/page, no offset/limit + // exposed to the model) so a single read of package-lock.json yields <13KB. + // `cat` via the bash tool returns the full 110KB file in one tool-result, + // which crosses the same HTTP stream (the #26915-class flow-control surface). emit(await runOnce( - "Output a numbered list from 1 to 400, each line being 'NNN: the quick brown fox jumps over the lazy dog'. No commentary, just the list.", + "Use your bash tool to run exactly this command: cat package-lock.json — then reply with exactly: DONE", )); break; case "web-search": - emit(await runOnce("Search the web for the current UTC date, then answer with just the date.")); + emit(await runOnce("Use your web_search tool (do NOT answer from your own knowledge) to search for 'current UTC date', then reply with only the date.")); break; case "stall-cancel": { const sessionID = `e2e-${Date.now()}`; diff --git a/test/e2e/transport.e2e.ts b/test/e2e/transport.e2e.ts new file mode 100644 index 0000000..2bb089a --- /dev/null +++ b/test/e2e/transport.e2e.ts @@ -0,0 +1,99 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { pickE2EModel } from "./model.js"; + +const execFileP = promisify(execFile); +const LIVE = process.env.E2E === "1" && Boolean(process.env.CURSOR_API_KEY); + +interface Part { type: string; name?: string; id?: string; isError?: boolean } +interface RunEvent { + ev: "run"; + firstDeltaMs?: number; + deltaChars: number; + payloadChars: number; + parts: Part[]; + error?: string; +} + +let model = "auto"; +beforeAll(async () => { + model = await pickE2EModel(); + console.log(`[e2e] model=${model}`); +}); + +async function driver(scenario: string, env: Record = {}): Promise { + const { stdout } = await execFileP("bun", ["test/e2e/driver.ts", scenario], { + env: { ...process.env, CURSOR_E2E_MODEL: model, ...env }, + timeout: 280_000, + maxBuffer: 64 * 1024 * 1024, + }); + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l) as RunEvent); +} + +/** Every tool-call has exactly one matching tool-result; no error parts. */ +function expectToolParity(run: RunEvent): void { + expect(run.error).toBeUndefined(); + const calls = run.parts.filter((p) => p.type === "tool-call"); + const results = run.parts.filter((p) => p.type === "tool-result"); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + const matches = results.filter((r) => r.id === call.id); + expect(matches, `tool-result for ${call.name} (${call.id})`).toHaveLength(1); + expect(matches[0]!.isError).not.toBe(true); + } + expect(run.parts.some((p) => p.type === "error")).toBe(false); +} + +describe.skipIf(!LIVE)("transport evidence matrix (live, cheapest model)", () => { + it("http1: tool parity + TTFT", async () => { + const [run] = await driver("tool-parity", { OPENCODE_CURSOR_TRANSPORT: "http1" }); + expectToolParity(run!); + expect(run!.firstDeltaMs).toBeDefined(); + console.log(`[e2e] http1 TTFT=${run!.firstDeltaMs}ms`); + }); + + it("sidecar: tool parity + TTFT (baseline)", async () => { + const [run] = await driver("tool-parity", { OPENCODE_CURSOR_TRANSPORT: "sidecar" }); + expectToolParity(run!); + console.log(`[e2e] sidecar TTFT=${run!.firstDeltaMs}ms`); + }); + + it("http1: session resume (second turn continuation)", async () => { + const [first, second] = await driver("resume", { OPENCODE_CURSOR_TRANSPORT: "http1" }); + expectToolParity(first!); + expect(second!.error).toBeUndefined(); + expect(second!.parts.some((p) => p.type === "text-delta")).toBe(true); + }); + + it("http1: wedged-run recovery after abort", async () => { + const [aborted, recovery] = await driver("stall-cancel", { OPENCODE_CURSOR_TRANSPORT: "http1" }); + expect(aborted).toBeDefined(); + expect(recovery!.error).toBeUndefined(); + const text = recovery!.parts.filter((p) => p.type === "text-delta"); + expect(text.length).toBeGreaterThan(0); + }); + + it("http1: long stream >64KB cumulative payload (no stall)", async () => { + const [run] = await driver("long-stream", { OPENCODE_CURSOR_TRANSPORT: "http1" }); + expect(run!.error).toBeUndefined(); + expectToolParity(run!); + expect(run!.payloadChars).toBeGreaterThan(65_536); + }); + + it("http1: web search arrives as mcp tool pair", async () => { + const [run] = await driver("web-search", { OPENCODE_CURSOR_TRANSPORT: "http1" }); + expect(run!.error).toBeUndefined(); + const calls = run!.parts.filter((p) => p.type === "tool-call"); + if (calls.length === 0 && !run!.parts.some((p) => p.type === "error")) { + console.log("[e2e] no mcp tool observed — web search capability unavailable on this account; mcp-path evidence limited"); + return; + } + expect(calls.length).toBeGreaterThan(0); + expectToolParity(run!); + }); +}); From 24e0796a4e60c33a5685628d59b613c99f060a9c Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 18:22:30 -0500 Subject: [PATCH 07/22] feat: default Bun transport to http1 (evidence gate passed); sidecar remains as fallback --- src/provider/agent-backend.ts | 9 ++++----- test/transport.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/provider/agent-backend.ts b/src/provider/agent-backend.ts index ae0c009..8a40006 100644 --- a/src/provider/agent-backend.ts +++ b/src/provider/agent-backend.ts @@ -12,7 +12,7 @@ * * Resolution order: provider option (`transport`) -> OPENCODE_CURSOR_TRANSPORT * -> legacy OPENCODE_CURSOR_SIDECAR (1=sidecar, 0=http2-direct) -> default - * (Bun: DEFAULT_BUN_TRANSPORT, pre-evidence-gate "sidecar"; Node: http2-direct). + * (Bun: DEFAULT_BUN_TRANSPORT, post-gate "http1"; Node: http2-direct). */ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; @@ -26,11 +26,10 @@ export type TransportKind = "http1" | "http2-direct" | "sidecar"; export type BackendKind = "in-process" | "sidecar"; /** - * Bun default. Pre-evidence-gate this is "sidecar" (the proven path); the gate - * task (Task 6) flips it to "http1" once the live matrix passes. Until then - * HTTP/1.1 is opt-in via OPENCODE_CURSOR_TRANSPORT=http1. + * Bun default. Post-evidence-gate this is "http1" (Task 5/6 matrix green; + * TTFT ≤ 1.5× sidecar). Sidecar remains via OPENCODE_CURSOR_TRANSPORT=sidecar. */ -export const DEFAULT_BUN_TRANSPORT: TransportKind = "sidecar"; +export const DEFAULT_BUN_TRANSPORT: TransportKind = "http1"; let preferredTransport: TransportKind | undefined; diff --git a/test/transport.test.ts b/test/transport.test.ts index 2f3abc7..d7e5dfa 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -22,12 +22,12 @@ const bunNoNode = { isBun: true, nodePath: undefined }; const node = { isBun: false, nodePath: process.execPath }; describe("resolveTransport", () => { - it("defaults: Bun -> sidecar (pre-gate), Node -> http2-direct", () => { + it("defaults: Bun -> http1 (post-gate), Node -> http2-direct", () => { delete process.env.OPENCODE_CURSOR_TRANSPORT; delete process.env.OPENCODE_CURSOR_SIDECAR; - expect(resolveTransport(bun)).toBe("sidecar"); + expect(resolveTransport(bun)).toBe("http1"); expect(resolveTransport(node)).toBe("http2-direct"); - expect(DEFAULT_BUN_TRANSPORT).toBe("sidecar"); + expect(DEFAULT_BUN_TRANSPORT).toBe("http1"); }); it("explicit OPENCODE_CURSOR_TRANSPORT wins", () => { From b45cd44462429b003a569d39a33f20826252316a Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 18:35:31 -0500 Subject: [PATCH 08/22] feat: idempotency keys on all sends (safe retries, server-side dedupe) --- src/provider/agent-events.ts | 23 ++++++++++++++------ src/provider/language-model.ts | 30 +++++++++++++++++++++++++- src/provider/sidecar-client.ts | 2 ++ src/provider/transcript-fingerprint.ts | 17 +++++++++++++++ src/sidecar/agent-host.mjs | 1 + test/agent-events.test.ts | 9 ++++++++ test/transcript-fingerprint.test.ts | 18 ++++++++++++++++ 7 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index aee3546..6a8b1c3 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -1,5 +1,5 @@ import type { AgentModeOption, SDKUserMessage } from "@cursor/sdk"; -import type { AgentLike, AgentRunLike } from "./agent-backend.js"; +import type { AgentLike, AgentRunLike, AgentSendOptions } from "./agent-backend.js"; /** Token usage as reported by Cursor's `turn-ended` update. */ export interface CursorUsage { @@ -21,6 +21,8 @@ export type CursorEvent = export interface StreamAgentTurnOptions { mode: AgentModeOption; abortSignal?: AbortSignal; + /** Dedupe key forwarded to every (re)send of this turn. */ + idempotencyKey?: string; } /** @@ -112,7 +114,12 @@ export async function* streamAgentTurn( options.abortSignal?.addEventListener("abort", onAbort); const sendTurn = (): Promise => - sendWithBusyRetry(agent, message, { mode: options.mode, onDelta }, debug); + sendWithBusyRetry( + agent, + message, + { mode: options.mode, onDelta, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, + debug, + ); // Kick off the turn. Resolve text from run.wait() for models that don't emit // incremental text deltas. @@ -174,10 +181,7 @@ export async function* streamAgentTurn( async function sendWithBusyRetry( agent: AgentLike, message: SDKUserMessage, - sendOptions: { - mode: AgentModeOption; - onDelta?: (args: { update: { type: string } & Record }) => void; - }, + sendOptions: AgentSendOptions, debug: boolean, ): Promise { try { @@ -233,7 +237,12 @@ export async function sendAgentTurnSilently( }; options.abortSignal?.addEventListener("abort", onAbort); try { - const run = await sendWithBusyRetry(agent, message, { mode: options.mode }, debug); + const run = await sendWithBusyRetry( + agent, + message, + { mode: options.mode, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, + debug, + ); runHolder.run = run; // The signal may have fired while send() was in flight (before runHolder // was populated, so onAbort had nothing to cancel); cancel now. diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 2b47981..12cedd4 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -42,6 +42,7 @@ import { classifyTurn, fingerprint, mcpServersFingerprint, + sendIdempotencyKey, } from "./transcript-fingerprint.js"; export interface CursorModelConfig { @@ -265,6 +266,13 @@ export class CursorLanguageModel implements LanguageModelV3 { } } + const latestUser = latestUserMessage(options.prompt); + const idempotencyKey = sendIdempotencyKey( + sessionID, + record, + latestUser?.text ?? JSON.stringify(options.prompt), + ); + // In "rules" mode (default), deliver opencode's system prompt through // Cursor's authoritative rules channel instead of the user transcript. // Degrades to inline "message" delivery when the user explicitly opted @@ -333,13 +341,31 @@ export class CursorLanguageModel implements LanguageModelV3 { await sendAgentTurnSilently(acquired.agent, multiTurns[i]!, { mode, abortSignal: options.abortSignal, + idempotencyKey: sendIdempotencyKey( + sessionID, + { userHashes: [...(record?.userHashes ?? []), String(i)] }, + multiTurns[i]!.text, + ), }); } if (!aborted && !options.abortSignal?.aborted) { for await (const event of streamAgentTurn( acquired.agent, multiTurns[multiTurns.length - 1]!, - { mode, abortSignal: options.abortSignal }, + { + mode, + abortSignal: options.abortSignal, + idempotencyKey: sendIdempotencyKey( + sessionID, + { + userHashes: [ + ...(record?.userHashes ?? []), + String(multiTurns.length - 1), + ], + }, + multiTurns[multiTurns.length - 1]!.text, + ), + }, )) { yielded = true; yield event; @@ -360,6 +386,7 @@ export class CursorLanguageModel implements LanguageModelV3 { for await (const event of streamAgentTurn(acquired.agent, message, { mode, abortSignal: options.abortSignal, + idempotencyKey, })) { yielded = true; yield event; @@ -412,6 +439,7 @@ export class CursorLanguageModel implements LanguageModelV3 { yield* streamAgentTurn(retry.agent, replay, { mode, abortSignal: options.abortSignal, + idempotencyKey, }); } finally { retry.release(); diff --git a/src/provider/sidecar-client.ts b/src/provider/sidecar-client.ts index 99fa849..2a4687a 100644 --- a/src/provider/sidecar-client.ts +++ b/src/provider/sidecar-client.ts @@ -20,6 +20,7 @@ export interface AgentSendOptions { mode?: string; onDelta?: (input: { update: Record & { type: string } }) => void; local?: { force?: boolean }; + idempotencyKey?: string; } /** Minimal agent surface the provider consumes (subset of the SDK's SDKAgent). */ @@ -249,6 +250,7 @@ export class SidecarClient { message, ...(options?.mode ? { mode: options.mode } : {}), ...(options?.local?.force ? { force: true } : {}), + ...(options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), }, { onUpdate: (update) => options?.onDelta?.({ update }), diff --git a/src/provider/transcript-fingerprint.ts b/src/provider/transcript-fingerprint.ts index 4c7ccd3..0202a87 100644 --- a/src/provider/transcript-fingerprint.ts +++ b/src/provider/transcript-fingerprint.ts @@ -156,3 +156,20 @@ export function classifyTurn( } return { kind: "divergence", fingerprint: fp }; } + +/** + * Stable idempotency key for one turn's send: identical for retries of the + * SAME logical turn (same session, same transcript prefix, same message), so + * Cursor's server dedupes resends; distinct as soon as the transcript grows, + * so a later identical user message is never swallowed. + */ +export function sendIdempotencyKey( + sessionID: string | undefined, + record: { userHashes: string[] } | undefined, + messageText: string, +): string { + return createHash("sha256") + .update(`${sessionID ?? "ephemeral"}|${record?.userHashes.join(",") ?? ""}|${messageText}`) + .digest("hex") + .slice(0, 32); +} diff --git a/src/sidecar/agent-host.mjs b/src/sidecar/agent-host.mjs index 96f12e1..a2ddcb9 100644 --- a/src/sidecar/agent-host.mjs +++ b/src/sidecar/agent-host.mjs @@ -74,6 +74,7 @@ async function handleRequest(req) { const sendOptions = { ...(req.mode ? { mode: req.mode } : {}), ...(req.force ? { local: { force: true } } : {}), + ...(req.idempotencyKey ? { idempotencyKey: req.idempotencyKey } : {}), onDelta: ({ update }) => write({ id, ev: "update", update }), }; const run = await agent.send(req.message, sendOptions); diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index 0826cd6..c98093e 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -254,3 +254,12 @@ describe("streamAgentTurn MCP error surfacing", () => { expect(result).toMatchObject({ name: "myserver/find_symbol", isError: true }); }); }); + +describe("streamAgentTurn idempotency key", () => { + it("passes idempotencyKey through to agent.send", async () => { + const sendCalls: Array | undefined> = []; + const agent = fakeAgent({ sendCalls }); + await collect(streamAgentTurn(agent, MESSAGE, { mode: "agent", idempotencyKey: "k-1" })); + expect(sendCalls[0]?.["idempotencyKey"]).toBe("k-1"); + }); +}); diff --git a/test/transcript-fingerprint.test.ts b/test/transcript-fingerprint.test.ts index d375ab6..b36b9f0 100644 --- a/test/transcript-fingerprint.test.ts +++ b/test/transcript-fingerprint.test.ts @@ -4,6 +4,7 @@ import { classifyTurn, fingerprint, mcpServersFingerprint, + sendIdempotencyKey, type TranscriptRecord, } from "../src/provider/transcript-fingerprint.js"; @@ -162,3 +163,20 @@ describe("fingerprint", () => { expect(a.userHashes).not.toEqual(b.userHashes); }); }); + +describe("sendIdempotencyKey", () => { + it("is deterministic and distinct per transcript state", () => { + const a = sendIdempotencyKey("s1", { userHashes: ["h1", "h2"] }, "hello"); + const b = sendIdempotencyKey("s1", { userHashes: ["h1", "h2"] }, "hello"); + const c = sendIdempotencyKey("s1", { userHashes: ["h1", "h2", "h3"] }, "hello"); + expect(a).toBe(b); + expect(a).not.toBe(c); + expect(a).toMatch(/^[0-9a-f]{32}$/); + }); + + it("distinct sessions never share a key", () => { + expect(sendIdempotencyKey("s1", undefined, "hi")).not.toBe( + sendIdempotencyKey("s2", undefined, "hi"), + ); + }); +}); From 6d39018c6b171e8f40548a25f27f02b0e5f1d35a Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 18:40:22 -0500 Subject: [PATCH 09/22] =?UTF-8?q?feat:=20typed=20send=20recovery=20?= =?UTF-8?q?=E2=80=94=20busy->force,=20rate-limit/network->bounded=20backof?= =?UTF-8?q?f?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/provider/agent-events.ts | 52 +++++++++++++++++++++++++----------- test/agent-events.test.ts | 44 +++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index 6a8b1c3..b24f4cf 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -1,5 +1,6 @@ import type { AgentModeOption, SDKUserMessage } from "@cursor/sdk"; import type { AgentLike, AgentRunLike, AgentSendOptions } from "./agent-backend.js"; +import { classifyError } from "./error-classify.js"; /** Token usage as reported by Cursor's `turn-ended` update. */ export interface CursorUsage { @@ -114,7 +115,7 @@ export async function* streamAgentTurn( options.abortSignal?.addEventListener("abort", onAbort); const sendTurn = (): Promise => - sendWithBusyRetry( + sendWithRecovery( agent, message, { mode: options.mode, onDelta, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, @@ -171,27 +172,48 @@ export async function* streamAgentTurn( } } +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Bounded backoff for retryable send failures (ms per attempt). */ +const RETRY_BACKOFF_MS = [500, 1500] as const; + /** - * Send a message on an agent, retrying once with the SDK's documented recovery - * path on `AgentBusyError`. A previous opencode/CLI crash (or a second instance - * racing on the same agent store) can leave a persisted run wedged; the SDK then - * rejects new sends with `AgentBusyError`. `local.force` expires the wedged run - * instead of failing the turn. Shared by streaming and silent sends. + * Send a message on an agent with typed recovery (see error-classify.ts): + * - agent-busy: a previous crash left a persisted run wedged — resend once + * with `local.force` to expire it (SDK-documented recovery). + * - rate-limit / network: bounded exponential backoff on the SAME agent; the + * shared idempotencyKey makes the resend a server-side dedupe, not a dup. + * - everything else: rethrow (auth/config fail fast upstream; unknown is + * handled by the caller's fresh-replay path). */ -async function sendWithBusyRetry( +export async function sendWithRecovery( agent: AgentLike, message: SDKUserMessage, sendOptions: AgentSendOptions, debug: boolean, ): Promise { - try { - return await agent.send(message, sendOptions); - } catch (err) { - if (err instanceof Error && err.name === "AgentBusyError") { - if (debug) console.error("[cursor:debug] agent busy; retrying send with local.force"); - return agent.send(message, { ...sendOptions, local: { force: true } }); + for (let attempt = 0; ; attempt++) { + try { + return await agent.send(message, sendOptions); + } catch (err) { + const classified = classifyError(err); + if (classified.kind === "agent-busy") { + if (debug) console.error("[cursor:debug] agent busy; retrying send with local.force"); + return agent.send(message, { ...sendOptions, local: { force: true } }); + } + if ( + (classified.kind === "rate-limit" || classified.kind === "network") && + attempt < RETRY_BACKOFF_MS.length + ) { + if (debug) + console.error( + `[cursor:debug] ${classified.kind}; retrying send in ${RETRY_BACKOFF_MS[attempt]}ms`, + ); + await sleep(RETRY_BACKOFF_MS[attempt]!); + continue; + } + throw err; } - throw err; } } @@ -237,7 +259,7 @@ export async function sendAgentTurnSilently( }; options.abortSignal?.addEventListener("abort", onAbort); try { - const run = await sendWithBusyRetry( + const run = await sendWithRecovery( agent, message, { mode: options.mode, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index c98093e..f5ae573 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { Run, SDKUserMessage } from "@cursor/sdk"; import { sendAgentTurnSilently, @@ -263,3 +263,45 @@ describe("streamAgentTurn idempotency key", () => { expect(sendCalls[0]?.["idempotencyKey"]).toBe("k-1"); }); }); + +describe("sendWithRecovery typed retries", () => { + it("retries rate-limit with backoff on the same agent (no force)", async () => { + vi.useFakeTimers(); + try { + const sendCalls: Array | undefined> = []; + const err = new Error("too many"); err.name = "RateLimitError"; + const agent = fakeAgent({ rejectFirst: { error: err, times: 2 }, sendCalls }); + const p = collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); + await vi.advanceTimersByTimeAsync(2_000); + await p; + expect(sendCalls).toHaveLength(3); + expect(sendCalls.every((c) => c?.["local"] === undefined)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("retries network errors once then surfaces", async () => { + vi.useFakeTimers(); + try { + const sendCalls: Array | undefined> = []; + const err = new Error("gone"); err.name = "NetworkError"; + const agent = fakeAgent({ rejectFirst: { error: err, times: 5 }, sendCalls }); + const p = collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); + const assertion = expect(p).rejects.toThrow("gone"); + await vi.advanceTimersByTimeAsync(5_000); + await assertion; + expect(sendCalls).toHaveLength(3); // initial + 2 bounded retries + } finally { + vi.useRealTimers(); + } + }); + + it("does not retry auth errors", async () => { + const sendCalls: Array | undefined> = []; + const err = new Error("bad key"); err.name = "AuthenticationError"; + const agent = fakeAgent({ rejectFirst: { error: err, times: 5 }, sendCalls }); + await expect(collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" }))).rejects.toThrow("bad key"); + expect(sendCalls).toHaveLength(1); + }); +}); From 279dc608814f81b71647c683d36fe67a42b10eca Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 18:44:10 -0500 Subject: [PATCH 10/22] =?UTF-8?q?feat:=20classify=20self-heal=20=E2=80=94?= =?UTF-8?q?=20auth/config=20fail=20fast,=20typed=20replayable=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/provider/language-model.ts | 48 ++++++++++++++++++------ test/language-model.test.ts | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 12cedd4..e6f71f2 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -22,6 +22,7 @@ import { type SystemPromptMode, } from "./message-map.js"; import { extractSystemText, resolveSystemDelivery } from "./system-rule.js"; +import { classifyError } from "./error-classify.js"; import { sendAgentTurnSilently, streamAgentTurn, @@ -392,20 +393,45 @@ export class CursorLanguageModel implements LanguageModelV3 { yield event; } } catch (err) { - // Resume-aware retry: a resumed agent can pass resume() yet fail the + const classified = classifyError(err); + // Auth/config: never a blind retry — replaying the transcript can't + // fix a bad key or misconfiguration. Fail fast with actionable + // guidance instead of burning a fresh create on a doomed resend. + if (classified.kind === "auth" || classified.kind === "config") { + throw classified.helpUrl + ? new Error( + `${classified.message} (see ${classified.helpUrl})`, + { cause: err }, + ) + : err; + } + // Resume-aware replay: a resumed agent can pass resume() yet fail the // actual send when Cursor's server has already expired the agent (its // server-side retention is shorter than our local 7-day reuse window, - // and not documented). If nothing has been emitted downstream yet and - // the user hasn't aborted, transparently re-create a fresh agent and - // replay the full transcript — self-healing, no context loss. The - // fresh agent re-pools under the same session (overwriting the dead - // agentId) via acquireAgent's existing pooling path. + // and not documented), or the send can die retryably before anything + // emitted. If nothing has been emitted downstream yet and the user + // hasn't aborted, transparently re-create a fresh agent and replay the + // full transcript — self-healing, no context loss. The fresh agent + // re-pools under the same session (overwriting the dead agentId) via + // acquireAgent's existing pooling path. // - // Deliberate tradeoff: the retry fires on ANY error class (including - // rate-limit or network failures) because Cursor's status:"error" - // carries no machine-readable class to discriminate on. Bounded to a - // single attempt, so the worst case is one extra create. - if (acquired.resumed && !yielded && !options.abortSignal?.aborted) { + // Gated on a classified, replayable set — agent-not-found (expired + // resume), rate-limit, network, and unknown. auth/config fail fast + // above; agent-busy is recovered at the send layer (sendWithRecovery), + // so it never reaches here as a replay trigger. Bounded to a single + // attempt, and the turn's idempotencyKey makes resends server-side + // dedupes, so the worst case is one extra create. + const replayable = + classified.kind === "agent-not-found" || + classified.kind === "rate-limit" || + classified.kind === "network" || + classified.kind === "unknown"; + if ( + replayable && + acquired.resumed && + !yielded && + !options.abortSignal?.aborted + ) { if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") { console.error( "[cursor:debug] resumed turn failed before emitting; retrying with a fresh agent", diff --git a/test/language-model.test.ts b/test/language-model.test.ts index 2d587e9..b4f651e 100644 --- a/test/language-model.test.ts +++ b/test/language-model.test.ts @@ -38,6 +38,8 @@ interface FakeAgentOpts { result?: { status: string; result?: string }; /** Captures the message passed to send() for assertions. */ sentMessages?: SDKUserMessage[]; + /** When set, send() rejects with this error (named class → classifyError). */ + sendError?: Error; } /** Build a fake AgentLike whose send() drives onDelta and resolves wait(). */ @@ -49,6 +51,7 @@ function fakeAgent(opts: FakeAgentOpts) { sendOptions?: Record, ) => { opts.sentMessages?.push(message); + if (opts.sendError) throw opts.sendError; const onDelta = sendOptions?.["onDelta"] as OnDelta | undefined; for (const update of opts.updates ?? []) onDelta?.({ update }); const run: Partial = { @@ -322,6 +325,70 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => { expect(getPooledAgentId("s1")).toBeUndefined(); }); + it("does NOT fresh-replay on auth errors (fail fast)", async () => { + const model = makeModel(); + + // Turn 1: pool a1. + create.mockResolvedValueOnce(fakeAgent({ agentId: "a1" })); + await collectStream( + streamCall(model, { + prompt: [sys("S"), user("hi")], + providerOptions: { cursor: { sessionID: "s1" } }, + } as never), + ); + + // Turn 2: resumed send rejects with an auth error. + const err = new Error("bad key"); + err.name = "AuthenticationError"; + resume.mockResolvedValueOnce(fakeAgent({ agentId: "a1", sendError: err })); + + const parts = await collectStream( + streamCall(model, { + prompt: [sys("S"), user("hi"), user("there")], + providerOptions: { cursor: { sessionID: "s1" } }, + } as never), + ); + + expect(eventTypes(parts)).toContain("error"); + // Fail fast: no fresh replay (create not called again). + expect(create).toHaveBeenCalledOnce(); // turn 1 only + expect(resume).toHaveBeenCalledOnce(); + }); + + it("fresh-replays once on agent-not-found", async () => { + const model = makeModel(); + + // Turn 1: pool a1. + create.mockResolvedValueOnce(fakeAgent({ agentId: "a1" })); + await collectStream( + streamCall(model, { + prompt: [sys("S"), user("hi")], + providerOptions: { cursor: { sessionID: "s1" } }, + } as never), + ); + + // Turn 2: resumed send rejects with agent-not-found → fresh replay. + const err = new Error("expired"); + err.name = "AgentNotFoundError"; + resume.mockResolvedValueOnce(fakeAgent({ agentId: "a1", sendError: err })); + create.mockResolvedValueOnce(fakeAgent({ agentId: "a2" })); + + const parts = await collectStream( + streamCall(model, { + prompt: [sys("S"), user("hi"), user("there")], + providerOptions: { cursor: { sessionID: "s1" } }, + } as never), + ); + + expect(eventTypes(parts)).not.toContain("error"); + expect(eventTypes(parts)).toContain("finish"); + // Fresh replay: create called twice; second acquire is a fresh create + // (via create(), not resume()) — resumeAgentId absent. + expect(create).toHaveBeenCalledTimes(2); + expect(resume).toHaveBeenCalledOnce(); + expect(getPooledAgentId("s1")).toBe("a2"); + }); + it("applies per-model default params (fast:false) when a turn arrives with no params", async () => { // Simulates an opencode subagent that inherited its parent's model: the // provider gets the bare model id with no per-request params. The From 1131efe7d5e274cadb23350df25dc995bfe60564 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 18:49:21 -0500 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20stream=20watchdog=20=E2=80=94=20p?= =?UTF-8?q?re-first-event=20stall=20cancels=20+=20force-resends=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/provider/agent-events.ts | 128 ++++++++++++++++++++++++++--------- test/agent-events.test.ts | 44 ++++++++++++ 2 files changed, 141 insertions(+), 31 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index b24f4cf..3c06699 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -64,10 +64,28 @@ export async function* streamAgentTurn( const debug = process.env.OPENCODE_CURSOR_DEBUG === "1"; const counts: Record = {}; + // Stall watchdog: if no event arrives within stallMs, cancel the wedged run + // and force-resend once (pre-first-event only). `0` disables. + const stallMs = Number(process.env.OPENCODE_CURSOR_STALL_MS ?? 60_000); + let stallTimer: ReturnType | undefined; + let forced = false; + let anyEvent = false; + const push = (event: CursorEvent) => { + anyEvent = true; queue.push(event); wake?.(); wake = undefined; + armWatchdog(); + }; + + const armWatchdog = () => { + if (stallMs <= 0 || finished) return; + if (stallTimer) clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + void onStall(); + }, stallMs); + stallTimer.unref?.(); }; const onDelta = ({ update }: { update: { type: string } & Record }) => { @@ -114,44 +132,91 @@ export async function* streamAgentTurn( }; options.abortSignal?.addEventListener("abort", onAbort); - const sendTurn = (): Promise => - sendWithRecovery( + // Kick off the turn. Resolve text from run.wait() for models that don't emit + // incremental text deltas. `runGen` disambiguates run invocations: when the + // watchdog cancels a wedged run and starts a fresh one, the stale run's + // completion handlers must NOT finish the stream (a new run is in flight). + let runGen = 0; + const startRun = (force: boolean): void => { + const gen = ++runGen; + void sendWithRecovery( agent, message, - { mode: options.mode, onDelta, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, + { + mode: options.mode, + onDelta, + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), + ...(force ? { local: { force: true } } : {}), + }, debug, - ); + ) + .then(async (run) => { + runHolder.run = run; + const result = await run.wait(); + if (debug) { + console.error( + `[cursor:debug] updates=${JSON.stringify(counts)} status=${result.status} resultLen=${(result.result ?? "").length}`, + ); + } + // Superseded by a watchdog force-resend: this run is abandoned. + if (gen !== runGen || finished) return; + if (result.status === "error") { + // Surface the failure instead of finishing silently — a silent stop + // leaves opencode showing dangling tool calls with no explanation. + throw new Error( + `Cursor run ended with status "error"${result.result ? `: ${result.result}` : ""}`, + ); + } + // A cancelled run finishes without fabricating final text. + push({ type: "finish", ...(result.status === "cancelled" ? {} : { text: result.result }) }); + }) + .catch((err) => { + if (gen !== runGen) return; + failure = err; + if (debug) console.error(`[cursor:debug] send failed: ${err instanceof Error ? err.message : String(err)}`); + }) + .finally(() => { + // Only the live run finishes the stream; a superseded (cancelled-for- + // resend) run leaves `finished` untouched so the resend can complete. + if (gen !== runGen) return; + finished = true; + if (stallTimer) clearTimeout(stallTimer); + wake?.(); + wake = undefined; + }); + }; - // Kick off the turn. Resolve text from run.wait() for models that don't emit - // incremental text deltas. - void sendTurn() - .then(async (run) => { - runHolder.run = run; - const result = await run.wait(); - if (debug) { - console.error( - `[cursor:debug] updates=${JSON.stringify(counts)} status=${result.status} resultLen=${(result.result ?? "").length}`, - ); - } - if (result.status === "error") { - // Surface the failure instead of finishing silently — a silent stop - // leaves opencode showing dangling tool calls with no explanation. - throw new Error( - `Cursor run ended with status "error"${result.result ? `: ${result.result}` : ""}`, - ); - } - // A cancelled run finishes without fabricating final text. - push({ type: "finish", ...(result.status === "cancelled" ? {} : { text: result.result }) }); - }) - .catch((err) => { - failure = err; - if (debug) console.error(`[cursor:debug] send failed: ${err instanceof Error ? err.message : String(err)}`); - }) - .finally(() => { + const onStall = async (): Promise => { + if (finished) return; + if (anyEvent) { + // A stall AFTER partial output is terminal: force-resending would + // re-emit the already-yielded prefix. Surface the stall instead. + failure = new Error(`Cursor run stalled (no events for ${stallMs}ms)`); + finished = true; + wake?.(); + wake = undefined; + return; + } + if (forced) { + failure = new Error(`Cursor run stalled twice (no events for ${stallMs}ms)`); finished = true; wake?.(); wake = undefined; - }); + return; + } + forced = true; + if (debug) console.error("[cursor:debug] stream stalled; cancelling and resending with local.force"); + try { + await runHolder.run?.cancel(); + } catch { + /* best effort */ + } + armWatchdog(); + startRun(true); + }; + + armWatchdog(); + startRun(false); try { while (true) { @@ -168,6 +233,7 @@ export async function* streamAgentTurn( while (queue.length > 0) yield queue.shift()!; if (failure) throw failure; } finally { + if (stallTimer) clearTimeout(stallTimer); options.abortSignal?.removeEventListener("abort", onAbort); } } diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index f5ae573..1f94712 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -305,3 +305,47 @@ describe("sendWithRecovery typed retries", () => { expect(sendCalls).toHaveLength(1); }); }); + +describe("stream watchdog", () => { + it("stall cancels and resends with local.force once", async () => { + vi.useFakeTimers(); + try { + process.env.OPENCODE_CURSOR_STALL_MS = "1000"; + const sendCalls: Array | undefined> = []; + let sends = 0; + const agent: AgentLike = { + agentId: "agent-wd", + send: async (_m: unknown, opts?: Record) => { + sends++; + sendCalls.push(opts); + if (sends === 1) { + // Wedged: no deltas, wait() never settles until cancelled. + let cancelled = false; + return { + wait: () => new Promise<{ status: string; result?: string }>((resolve) => { + const t = setInterval(() => { + if (cancelled) { clearInterval(t); resolve({ status: "cancelled" }); } + }, 10); + }), + cancel: async () => { cancelled = true; }, + } as never; + } + const onDelta = opts?.["onDelta"] as ((a: { update: { type: string; text?: string } }) => void) | undefined; + onDelta?.({ update: { type: "text-delta", text: "ok" } }); + return { wait: async () => ({ status: "finished", result: "ok" }), cancel: async () => {} } as never; + }, + close: () => {}, + } as unknown as AgentLike; + const p = collect(streamAgentTurn(agent, MESSAGE, { mode: "agent", idempotencyKey: "k-wd" })); + await vi.advanceTimersByTimeAsync(1_500); + const events = await p; + expect(sendCalls).toHaveLength(2); + expect(sendCalls[1]?.["local"]).toEqual({ force: true }); + expect(sendCalls[1]?.["idempotencyKey"]).toBe("k-wd"); + expect(events.some((e) => e.type === "finish")).toBe(true); + } finally { + delete process.env.OPENCODE_CURSOR_STALL_MS; + vi.useRealTimers(); + } + }); +}); From 725f14ff47732c09ba060ab08338a5491256380f Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 19:39:01 -0500 Subject: [PATCH 12/22] fix: watchdog abort race + cancel wedged run on mid-stream stall --- src/provider/agent-events.ts | 33 ++++++++++--- test/agent-events.test.ts | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index 3c06699..2a686ce 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -128,6 +128,11 @@ export async function* streamAgentTurn( const runHolder: { run?: AgentRunLike } = {}; const onAbort = () => { + // Clear the stall timer so an armed watchdog can't fire a force-resend of + // an aborted turn (abort during the pre-first-event wait would otherwise + // still trip onStall). + if (stallTimer) clearTimeout(stallTimer); + stallTimer = undefined; void Promise.resolve(runHolder.run?.cancel()).catch(() => {}); }; options.abortSignal?.addEventListener("abort", onAbort); @@ -188,20 +193,32 @@ export async function* streamAgentTurn( const onStall = async (): Promise => { if (finished) return; - if (anyEvent) { - // A stall AFTER partial output is terminal: force-resending would - // re-emit the already-yielded prefix. Surface the stall instead. - failure = new Error(`Cursor run stalled (no events for ${stallMs}ms)`); + // The turn was aborted: don't resend or surface a spurious stall error. + if (options.abortSignal?.aborted) return; + const failTerminal = async (message: string): Promise => { + // Cancel the wedged server run (best-effort) so it isn't orphaned in a + // RUNNING state, then surface the stall as a terminal failure. + try { + await runHolder.run?.cancel(); + } catch { + /* best effort */ + } + failure = new Error(message); finished = true; + if (stallTimer) clearTimeout(stallTimer); + stallTimer = undefined; wake?.(); wake = undefined; + }; + if (anyEvent) { + // A stall AFTER partial output is terminal: force-resending would + // re-emit the already-yielded prefix. Cancel the wedged run and surface + // the stall instead. + await failTerminal(`Cursor run stalled (no events for ${stallMs}ms)`); return; } if (forced) { - failure = new Error(`Cursor run stalled twice (no events for ${stallMs}ms)`); - finished = true; - wake?.(); - wake = undefined; + await failTerminal(`Cursor run stalled twice (no events for ${stallMs}ms)`); return; } forced = true; diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index 1f94712..e3b3a72 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -348,4 +348,100 @@ describe("stream watchdog", () => { vi.useRealTimers(); } }); + + it("abort during pre-first-event wait does not trigger a resend", async () => { + vi.useFakeTimers(); + try { + process.env.OPENCODE_CURSOR_STALL_MS = "1000"; + const sendCalls: Array | undefined> = []; + const controller = new AbortController(); + const agent: AgentLike = { + agentId: "agent-wd-abort", + send: async (_m: unknown, opts?: Record) => { + sendCalls.push(opts); + // Wedged pre-first-event: no deltas; wait() settles only on cancel. + let cancelled = false; + return { + wait: () => + new Promise<{ status: string; result?: string }>((resolve) => { + const t = setInterval(() => { + if (cancelled) { + clearInterval(t); + resolve({ status: "cancelled" }); + } + }, 10); + }), + cancel: async () => { + cancelled = true; + }, + } as never; + }, + close: () => {}, + } as unknown as AgentLike; + const p = collect( + streamAgentTurn(agent, MESSAGE, { mode: "agent", abortSignal: controller.signal }), + ); + // Let send() resolve so runHolder.run is populated, then abort before + // the stall fires and let time pass well past stallMs. + await vi.advanceTimersByTimeAsync(0); + controller.abort(); + await vi.advanceTimersByTimeAsync(2_000); + const events = await p; + // No resend: the aborted turn's stall timer was cleared. + expect(sendCalls).toHaveLength(1); + // No spurious stall error surfaced; generator ended cleanly. + expect(events.every((e) => e.type !== "text-delta")).toBe(true); + } finally { + delete process.env.OPENCODE_CURSOR_STALL_MS; + vi.useRealTimers(); + } + }); + + it("mid-stream stall cancels the wedged run and surfaces an error (no resend)", async () => { + vi.useFakeTimers(); + try { + process.env.OPENCODE_CURSOR_STALL_MS = "1000"; + const sendCalls: Array | undefined> = []; + let cancelCalls = 0; + const agent: AgentLike = { + agentId: "agent-wd-mid", + send: async (_m: unknown, opts?: Record) => { + sendCalls.push(opts); + // Emit one delta, then wedge: wait() settles only on cancel(). + const onDelta = opts?.["onDelta"] as + | ((a: { update: { type: string; text?: string } }) => void) + | undefined; + onDelta?.({ update: { type: "text-delta", text: "partial" } }); + let cancelled = false; + return { + wait: () => + new Promise<{ status: string; result?: string }>((resolve) => { + const t = setInterval(() => { + if (cancelled) { + clearInterval(t); + resolve({ status: "cancelled" }); + } + }, 10); + }), + cancel: async () => { + cancelCalls++; + cancelled = true; + }, + } as never; + }, + close: () => {}, + } as unknown as AgentLike; + const p = collect(streamAgentTurn(agent, MESSAGE, { mode: "agent" })); + const assertion = expect(p).rejects.toThrow(/stalled/i); + await vi.advanceTimersByTimeAsync(2_000); + await assertion; + // Wedged run cancelled, not orphaned. + expect(cancelCalls).toBeGreaterThanOrEqual(1); + // No force-resend for a mid-stream stall. + expect(sendCalls).toHaveLength(1); + } finally { + delete process.env.OPENCODE_CURSOR_STALL_MS; + vi.useRealTimers(); + } + }); }); From 39c3105468bac488dff781595b5bdf493c1430eb Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 19:45:15 -0500 Subject: [PATCH 13/22] fix: capture silent-replay usage; aggregate into visible turn's finish usage --- src/provider/agent-events.ts | 39 +++++++++++--- src/provider/language-model.ts | 27 ++++++---- test/agent-events.test.ts | 33 ++++++++++-- test/language-model-interjection.test.ts | 69 +++++++++++++++++++++--- 4 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index 2a686ce..b0cd558 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -24,6 +24,24 @@ export interface StreamAgentTurnOptions { abortSignal?: AbortSignal; /** Dedupe key forwarded to every (re)send of this turn. */ idempotencyKey?: string; + /** + * Usage accumulated by preceding silent replay turns. When set, yielded + * `usage` events carry `usageBase + turn-ended` sums so the visible turn's + * reported usage includes everything spent replaying earlier messages. + */ + usageBase?: CursorUsage; +} + +/** Sum two usage reports (either may be absent). */ +export function addUsage(a?: CursorUsage, b?: CursorUsage): CursorUsage | undefined { + if (!a) return b; + if (!b) return a; + return { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens, + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens, + }; } /** @@ -121,7 +139,10 @@ export async function* streamAgentTurn( break; } case "turn-ended": - if (update.usage) push({ type: "usage", usage: update.usage as CursorUsage }); + if (update.usage) { + const summed = addUsage(options.usageBase, update.usage as CursorUsage); + if (summed) push({ type: "usage", usage: summed }); + } break; } }; @@ -319,8 +340,6 @@ export async function sendWithRecovery( * interjected messages into one visible turn. * - Serial latency: each silent turn is awaited to completion before the next * send, so an N-message interjection costs N sequential agent runs. - * - Usage undercount: no onDelta means the `turn-ended` usage update is never - * observed, so opencode slightly undercounts tokens on multi-message turns. * * Concatenating the queued messages into one Cursor message was rejected for * message fidelity: each interjection must land as a distinct user turn in the @@ -332,11 +351,16 @@ export async function sendAgentTurnSilently( agent: AgentLike, message: SDKUserMessage, options: StreamAgentTurnOptions, -): Promise { +): Promise { // Already aborted: don't start a turn just to cancel it. - if (options.abortSignal?.aborted) return; + if (options.abortSignal?.aborted) return undefined; const debug = process.env.OPENCODE_CURSOR_DEBUG === "1"; const runHolder: { run?: AgentRunLike } = {}; + // Capture only the turn-ended usage; a silent turn streams nothing else. + let usage: CursorUsage | undefined; + const onDelta = ({ update }: { update: { type: string } & Record }) => { + if (update.type === "turn-ended" && update.usage) usage = update.usage as CursorUsage; + }; const onAbort = () => { void Promise.resolve(runHolder.run?.cancel()).catch(() => {}); }; @@ -345,7 +369,7 @@ export async function sendAgentTurnSilently( const run = await sendWithRecovery( agent, message, - { mode: options.mode, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, + { mode: options.mode, onDelta, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) }, debug, ); runHolder.run = run; @@ -357,7 +381,7 @@ export async function sendAgentTurnSilently( // Our own abort cancelled the run mid-flight: expected, not a failure. // The caller's abort check stops the multi-send sequence and drops the // session record, so this partial turn is never counted as delivered. - if (options.abortSignal?.aborted) return; + if (options.abortSignal?.aborted) return undefined; // Anything else ("error", an external "cancelled", unknown states) means // the message was NOT delivered; treating it as success would leave the // session record claiming the agent saw a message it never received. @@ -365,6 +389,7 @@ export async function sendAgentTurnSilently( `Cursor run ended with status "${result.status}"${result.result ? `: ${result.result}` : ""}`, ); } + return usage; } finally { options.abortSignal?.removeEventListener("abort", onAbort); } diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index e6f71f2..a5d620d 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -24,9 +24,11 @@ import { import { extractSystemText, resolveSystemDelivery } from "./system-rule.js"; import { classifyError } from "./error-classify.js"; import { + addUsage, sendAgentTurnSilently, streamAgentTurn, type CursorEvent, + type CursorUsage, } from "./agent-events.js"; import { cursorEventsToContent, @@ -334,20 +336,26 @@ export class CursorLanguageModel implements LanguageModelV3 { let delivered = false; try { let aborted = false; + // Accumulate usage across silent replay turns so the final + // streamed turn's reported usage includes the replay cost. + let replayUsage: CursorUsage | undefined; for (let i = 0; i < multiTurns.length - 1; i++) { if (options.abortSignal?.aborted) { aborted = true; break; } - await sendAgentTurnSilently(acquired.agent, multiTurns[i]!, { - mode, - abortSignal: options.abortSignal, - idempotencyKey: sendIdempotencyKey( - sessionID, - { userHashes: [...(record?.userHashes ?? []), String(i)] }, - multiTurns[i]!.text, - ), - }); + replayUsage = addUsage( + replayUsage, + await sendAgentTurnSilently(acquired.agent, multiTurns[i]!, { + mode, + abortSignal: options.abortSignal, + idempotencyKey: sendIdempotencyKey( + sessionID, + { userHashes: [...(record?.userHashes ?? []), String(i)] }, + multiTurns[i]!.text, + ), + }), + ); } if (!aborted && !options.abortSignal?.aborted) { for await (const event of streamAgentTurn( @@ -366,6 +374,7 @@ export class CursorLanguageModel implements LanguageModelV3 { }, multiTurns[multiTurns.length - 1]!.text, ), + ...(replayUsage ? { usageBase: replayUsage } : {}), }, )) { yielded = true; diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index e3b3a72..0f05ab7 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -118,8 +118,33 @@ describe("streamAgentTurn busy-agent recovery", () => { }); }); +describe("silent-turn usage capture", () => { + it("sendAgentTurnSilently captures turn-ended usage", async () => { + const agent = fakeAgent({ + updates: [{ type: "turn-ended", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 1, cacheWriteTokens: 2 } }], + }); + const usage = await sendAgentTurnSilently(agent, MESSAGE, { mode: "agent" }); + expect(usage).toEqual({ inputTokens: 10, outputTokens: 5, cacheReadTokens: 1, cacheWriteTokens: 2 }); + }); + + it("streamAgentTurn adds usageBase to turn-ended usage", async () => { + const agent = fakeAgent({ + updates: [{ type: "turn-ended", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } }], + }); + const events = await collect(streamAgentTurn(agent, MESSAGE, { + mode: "agent", + usageBase: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 3, cacheWriteTokens: 4 }, + })); + const usage = events.find((e) => e.type === "usage"); + expect(usage).toEqual({ + type: "usage", + usage: { inputTokens: 110, outputTokens: 55, cacheReadTokens: 3, cacheWriteTokens: 4 }, + }); + }); +}); + describe("sendAgentTurnSilently", () => { - it("sends the message with no onDelta and awaits completion", async () => { + it("sends the message and awaits completion without returning text usage", async () => { const sendCalls: Array | undefined> = []; const agent = fakeAgent({ updates: [{ type: "text-delta", text: "should-not-surface" }], @@ -127,10 +152,12 @@ describe("sendAgentTurnSilently", () => { sendCalls, }); - await sendAgentTurnSilently(agent, MESSAGE, { mode: "agent" }); + // A turn with no turn-ended usage returns undefined; text deltas never + // surface (the onDelta only captures usage). + const usage = await sendAgentTurnSilently(agent, MESSAGE, { mode: "agent" }); expect(sendCalls).toHaveLength(1); - expect(sendCalls[0]?.["onDelta"]).toBeUndefined(); + expect(usage).toBeUndefined(); }); it("throws when the run ends with status 'error'", async () => { diff --git a/test/language-model-interjection.test.ts b/test/language-model-interjection.test.ts index 2ee1218..a36a609 100644 --- a/test/language-model-interjection.test.ts +++ b/test/language-model-interjection.test.ts @@ -21,7 +21,7 @@ interface SentTurn { function makeFakeAgent( agentId: string, sent: SentTurn[], - opts?: { failOnText?: string }, + opts?: { failOnText?: string; usage?: Record }, ) { return { agentId, @@ -33,9 +33,16 @@ function makeFakeAgent( const onDelta = sendOptions?.["onDelta"] as | ((a: { update: { type: string } & Record }) => void) | undefined; + // Both silent and streamed turns now receive an onDelta (silent uses it + // only to capture turn-ended usage). The streamed turn is the one whose + // text reaches the visible output stream; the silent onDelta discards + // text-delta. So `streamed` here just records that a callback was wired, + // and the caller asserts which text actually surfaces via the drained + // stream (see the streamedTexts helper below). sent.push({ text: message.text, streamed: Boolean(onDelta) }); const failed = opts?.failOnText === message.text; onDelta?.({ update: { type: "text-delta", text: message.text } }); + if (opts?.usage) onDelta?.({ update: { type: "turn-ended", usage: opts.usage } }); return { id: "run", wait: async () => @@ -99,6 +106,13 @@ async function drain( return parts; } +/** Texts that reached the visible output stream (silent turns emit none). */ +function streamedTexts(parts: Array<{ type: string }>): string[] { + return parts + .filter((p) => p.type === "text-delta") + .map((p) => (p as unknown as { delta: string }).delta); +} + const sys = { role: "system" as const, content: "S" }; const user = (text: string): LanguageModelV3Prompt[number] => ({ role: "user", @@ -126,15 +140,56 @@ describe("multi-message interjection (continuation-multi)", () => { // Turn 2: two new user messages were queued while busy -> continuation-multi. resume.mockResolvedValue(makeFakeAgent("a1", sentTurns)); sentTurns.length = 0; - await drain(model(), [sys, user("a"), assistant("x"), user("b"), user("c")]); + const parts = await drain(model(), [ + sys, + user("a"), + assistant("x"), + user("b"), + user("c"), + ]); // Resumed the pooled agent (not a fresh create). expect(resume).toHaveBeenCalledWith("a1", expect.anything()); - // Two sequential sends: "b" silent, "c" streamed. - expect(sentTurns).toEqual([ - { text: "b", streamed: false }, - { text: "c", streamed: true }, + // Two sequential sends in order: "b" then "c". + expect(sentTurns.map((t) => t.text)).toEqual(["b", "c"]); + // Only the final turn ("c") surfaces text to the visible stream; the + // silent replay of "b" streams nothing. + expect(streamedTexts(parts)).toEqual(["c"]); + }); + + it("folds silent-replay usage into the final visible turn's finish usage", async () => { + // Turn 1: pool the agent. + create.mockResolvedValue(makeFakeAgent("a1", sentTurns)); + await drain(model(), [sys, user("a")]); + + // Turn 2: continuation-multi (b silent, c streamed). Each turn reports the + // same usage; the final finish usage must equal their sum. + resume.mockResolvedValue( + makeFakeAgent("a1", sentTurns, { + usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 1, cacheWriteTokens: 2 }, + }), + ); + sentTurns.length = 0; + const parts = await drain(model(), [ + sys, + user("a"), + assistant("x"), + user("b"), + user("c"), ]); + + const finish = parts.find((p) => p.type === "finish") as unknown as { + usage: { + inputTokens: { total: number; cacheRead: number; cacheWrite: number }; + outputTokens: { total: number }; + }; + }; + expect(finish).toBeDefined(); + // b (silent) + c (streamed): 10+10, 5+5, 1+1 cacheRead, 2+2 cacheWrite. + expect(finish.usage.inputTokens.total).toBe(20); + expect(finish.usage.outputTokens.total).toBe(10); + expect(finish.usage.inputTokens.cacheRead).toBe(2); + expect(finish.usage.inputTokens.cacheWrite).toBe(4); }); it("drops the session record when a silent send fails mid-sequence, so the next turn replays fresh", async () => { @@ -158,7 +213,7 @@ describe("multi-message interjection (continuation-multi)", () => { // The failure surfaced as an error stream part, message "c" never sent. expect(parts.some((p) => p.type === "error")).toBe(true); - expect(sentTurns).toEqual([{ text: "b", streamed: false }]); + expect(sentTurns.map((t) => t.text)).toEqual(["b"]); // Record rolled back: next turn must NOT resume on top of undelivered msgs. expect(getSessionRecord(SESSION_ID)).toBeUndefined(); From a9d55446ce81c9c786ceec5f47282fa07eb07ab2 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 19:50:01 -0500 Subject: [PATCH 14/22] feat: thinking duration + compaction count in finish providerMetadata --- src/provider/agent-events.ts | 10 ++++++++ src/provider/delegate.ts | 3 +++ src/provider/stream-map.ts | 50 ++++++++++++++++++++++++++++-------- test/stream-map.test.ts | 35 +++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index b0cd558..98ad6ca 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -17,6 +17,8 @@ export type CursorEvent = | { type: "tool-call"; id: string; name: string; input: unknown } | { type: "tool-result"; id: string; name: string; result: unknown; isError: boolean } | { type: "usage"; usage: CursorUsage } + | { type: "reasoning-complete"; durationMs?: number } + | { type: "compaction" } | { type: "finish"; text?: string }; export interface StreamAgentTurnOptions { @@ -115,6 +117,14 @@ export async function* streamAgentTurn( case "thinking-delta": push({ type: "reasoning-delta", text: update.text }); break; + case "thinking-completed": + push({ type: "reasoning-complete", durationMs: update.thinkingDurationMs as number | undefined }); + break; + case "summary-started": + case "summary": + case "summary-completed": + push({ type: "compaction" }); + break; case "tool-call-started": push({ type: "tool-call", diff --git a/src/provider/delegate.ts b/src/provider/delegate.ts index 5e61c30..2c9ca88 100644 --- a/src/provider/delegate.ts +++ b/src/provider/delegate.ts @@ -99,6 +99,9 @@ export async function runDelegate( case "usage": usage = event.usage; break; + case "reasoning-complete": + case "compaction": + break; case "finish": // The aggregated result text; prefer it when deltas were absent. if (event.text && text.length === 0) text.push(event.text); diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 888ef28..5a855a2 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -942,6 +942,8 @@ export function cursorEventsToStream( let reasoningCount = 0; let usage: LanguageModelV3Usage | undefined; let streamedText = false; + let thinkingMs = 0; + let compactions = 0; // Blocks-mode tool bookkeeping (open non-edit calls + buffered edits). const toolState = newBlockToolState(); const closeDanglingToolCalls = () => { @@ -1065,6 +1067,14 @@ export function cursorEventsToStream( case "usage": usage = mapUsage(event.usage); break; + case "reasoning-complete": + closeReasoning(); + if (typeof event.durationMs === "number") + thinkingMs += event.durationMs; + break; + case "compaction": + compactions++; + break; case "finish": if (!streamedText && event.text) { controller.enqueue({ @@ -1080,22 +1090,38 @@ export function cursorEventsToStream( closeDanglingToolCalls(); closeReasoning(); closeText(); - controller.enqueue({ - type: "finish", - usage: usage ?? EMPTY_USAGE, - finishReason: FINISH_STOP, - }); + { + const cursorMeta: Record = {}; + if (thinkingMs > 0) cursorMeta["thinkingDurationMs"] = thinkingMs; + if (compactions > 0) cursorMeta["compactions"] = compactions; + controller.enqueue({ + type: "finish", + usage: usage ?? EMPTY_USAGE, + finishReason: FINISH_STOP, + ...(Object.keys(cursorMeta).length > 0 + ? { providerMetadata: { cursor: cursorMeta } } + : {}), + }); + } controller.close(); } catch (err) { controller.enqueue({ type: "error", error: err }); closeDanglingToolCalls(); closeReasoning(); closeText(); - controller.enqueue({ - type: "finish", - usage: usage ?? EMPTY_USAGE, - finishReason: FINISH_ERROR, - }); + { + const cursorMeta: Record = {}; + if (thinkingMs > 0) cursorMeta["thinkingDurationMs"] = thinkingMs; + if (compactions > 0) cursorMeta["compactions"] = compactions; + controller.enqueue({ + type: "finish", + usage: usage ?? EMPTY_USAGE, + finishReason: FINISH_ERROR, + ...(Object.keys(cursorMeta).length > 0 + ? { providerMetadata: { cursor: cursorMeta } } + : {}), + }); + } controller.close(); } }, @@ -1175,6 +1201,10 @@ export async function cursorEventsToContent( case "usage": usage = mapUsage(event.usage); break; + case "reasoning-complete": + break; + case "compaction": + break; case "finish": if (!text && event.text) text = event.text; break; diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index e798153..7977f2e 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -1378,3 +1378,38 @@ describe("mapUsage", () => { }); }); }); + +describe("finish providerMetadata", () => { + it("attaches thinking duration + compaction count to finish providerMetadata", async () => { + const events: CursorEvent[] = [ + { type: "reasoning-delta", text: "thinking…" }, + { type: "reasoning-complete", durationMs: 1234 }, + { type: "compaction" }, + { type: "text-delta", text: "answer" }, + { type: "finish", text: "answer" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + const finish = parts.find((p) => p.type === "finish") as { + providerMetadata?: Record>; + }; + expect(finish.providerMetadata?.["cursor"]?.["thinkingDurationMs"]).toBe( + 1234, + ); + expect(finish.providerMetadata?.["cursor"]?.["compactions"]).toBe(1); + }); + + it("omits providerMetadata when nothing to report", async () => { + const parts = await collect( + cursorEventsToStream( + gen([ + { type: "text-delta", text: "hi" }, + { type: "finish", text: "hi" }, + ]), + ), + ); + const finish = parts.find((p) => p.type === "finish") as { + providerMetadata?: unknown; + }; + expect(finish.providerMetadata).toBeUndefined(); + }); +}); From 36f938a555f8f9e173d8c9bc16f6912a3fd3315f Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 19:54:47 -0500 Subject: [PATCH 15/22] feat: live tool-input streaming (partial-tool-call) + live plan streaming --- src/provider/agent-events.ts | 9 +++ src/provider/delegate.ts | 2 + src/provider/stream-map.ts | 128 +++++++++++++++++++++++++++++++++-- test/stream-map.test.ts | 114 +++++++++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 4 deletions(-) diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index 98ad6ca..1bf95b2 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -14,6 +14,7 @@ export interface CursorUsage { export type CursorEvent = | { type: "text-delta"; text: string } | { type: "reasoning-delta"; text: string } + | { type: "tool-input-partial"; id: string; name: string; input: unknown } | { type: "tool-call"; id: string; name: string; input: unknown } | { type: "tool-result"; id: string; name: string; result: unknown; isError: boolean } | { type: "usage"; usage: CursorUsage } @@ -125,6 +126,14 @@ export async function* streamAgentTurn( case "summary-completed": push({ type: "compaction" }); break; + case "partial-tool-call": + push({ + type: "tool-input-partial", + id: String(update.callId), + name: toolDisplayName(update.toolCall), + input: update.toolCall?.args ?? {}, + }); + break; case "tool-call-started": push({ type: "tool-call", diff --git a/src/provider/delegate.ts b/src/provider/delegate.ts index 2c9ca88..a768407 100644 --- a/src/provider/delegate.ts +++ b/src/provider/delegate.ts @@ -89,6 +89,8 @@ export async function runDelegate( case "reasoning-delta": reasoning.push(event.text); break; + case "tool-input-partial": + break; case "tool-call": toolActivity.push({ name: event.name, isError: false }); break; diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 5a855a2..0a62490 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -756,10 +756,70 @@ interface BlockToolState { open: Map; pendingEdits: Map; dropped: Set; + partials: Map; + planText: Map; } function newBlockToolState(): BlockToolState { - return { open: new Map(), pendingEdits: new Map(), dropped: new Set() }; + return { + open: new Map(), + pendingEdits: new Map(), + dropped: new Set(), + partials: new Map(), + planText: new Map(), + }; +} + +/** + * Resolve a Cursor tool name (+ call args) to the FINAL emitted tool name + * (native registered tool via adapter, or `cursor_*` generic) plus the adapter + * used. Factored out of {@link blockToolCallParts} so the live-input streaming + * path can start a `tool-input-start` under the same name the eventual + * `tool-call` will use. + */ +function resolveToolName( + name: string, + input: unknown, +): { toolName: string; adapter?: NativeToolAdapter } { + const adapter = resolveAdapter(name, input); + return { + toolName: adapter?.tool ?? blockToolName(name), + ...(adapter ? { adapter } : {}), + }; +} + +/** + * Parts to emit for a blocks-mode `tool-input-partial` event: a + * `tool-input-start` on the first partial for an id, then a `tool-input-delta` + * carrying the JSON-suffix beyond what was already streamed. Kill-switch: + * `OPENCODE_CURSOR_TOOL_INPUT_STREAM=0` disables live input streaming entirely. + */ +function blockToolInputPartialParts( + id: string, + name: string, + input: unknown, + state: BlockToolState, +): BlockToolPart[] { + if (process.env["OPENCODE_CURSOR_TOOL_INPUT_STREAM"] === "0") return []; + const serialized = safeJsonString(input); + const prev = state.partials.get(id); + const toolName = prev?.toolName ?? resolveToolName(name, input).toolName; + state.partials.set(id, { toolName, serialized }); + const parts: BlockToolPart[] = []; + if (!prev) + parts.push({ + type: "tool-input-start", + id, + toolName, + providerExecuted: true, + dynamic: true, + } as BlockToolPart); + const delta = + prev && serialized.startsWith(prev.serialized) + ? serialized.slice(prev.serialized.length) + : serialized; + if (delta) parts.push({ type: "tool-input-delta", id, delta } as BlockToolPart); + return parts; } /** Parts to emit for a blocks-mode `tool-call` event (edits are buffered). */ @@ -853,6 +913,23 @@ function blockDanglingParts(state: BlockToolState): BlockToolPart[] { parts.push(toolResultObj(id, EDIT_TOOL_NAME, DANGLING_TOOL_RESULT, true)); } state.pendingEdits.clear(); + // Partial input streams whose tool-call never arrived (run died mid-stream): + // close the started input, then emit a valid call+result pair so the block + // isn't left dangling (invariant: exactly one terminal per started call). + for (const [id, partial] of state.partials) { + parts.push({ type: "tool-input-end", id } as BlockToolPart); + let args: unknown = {}; + try { + args = JSON.parse(partial.serialized); + } catch { + args = {}; + } + parts.push(nativeToolCall(id, partial.toolName, args)); + parts.push( + nativeToolResult(id, partial.toolName, DANGLING_TOOL_RESULT, true), + ); + } + state.partials.clear(); return parts; } @@ -1005,22 +1082,61 @@ export function cursorEventsToStream( case "reasoning-delta": reasoningLine(event.text); break; + case "tool-input-partial": + if (isCreatePlanTool(event.name)) { + const plan = createPlanContent(event.input) ?? ""; + const prevPlan = toolState.planText.get(event.id) ?? ""; + if (plan.length > prevPlan.length) { + closeReasoning(); + streamedText = true; + controller.enqueue({ + type: "text-delta", + id: ensureText(), + delta: plan.slice(prevPlan.length), + }); + } + toolState.planText.set(event.id, plan); + toolState.dropped.add(event.id); + break; + } + if (toolDisplay === "blocks" && event.name !== EDIT_TOOL_NAME) { + const parts = blockToolInputPartialParts( + event.id, + event.name, + event.input, + toolState, + ); + if (parts.length > 0) { + closeText(); + closeReasoning(); + } + for (const part of parts) controller.enqueue(part); + } + break; case "tool-call": if (isCreatePlanTool(event.name)) { - const plan = createPlanContent(event.input); - if (plan) { + const plan = createPlanContent(event.input) ?? ""; + const prevPlan = toolState.planText.get(event.id) ?? ""; + if (plan.length > prevPlan.length) { closeReasoning(); streamedText = true; controller.enqueue({ type: "text-delta", id: ensureText(), - delta: plan, + delta: plan.slice(prevPlan.length), }); } + toolState.planText.set(event.id, plan); toolState.dropped.add(event.id); break; } if (toolDisplay === "blocks") { + if (toolState.partials.delete(event.id)) { + controller.enqueue({ + type: "tool-input-end", + id: event.id, + } as LanguageModelV3StreamPart); + } const parts = blockToolCallParts( event.id, event.name, @@ -1162,6 +1278,10 @@ export async function cursorEventsToContent( case "reasoning-delta": reasoning += event.text; break; + case "tool-input-partial": + // Non-streaming path ignores partials; the final tool-call carries + // the full args. + break; case "tool-call": if (isCreatePlanTool(event.name)) { const plan = createPlanContent(event.input); diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 7977f2e..2d94b28 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -1358,6 +1358,120 @@ describe("createPlan mapping", () => { }); }); +describe("tool-input streaming (partial-tool-call)", () => { + it("streams tool-input partials then closes before tool-call", async () => { + const events: CursorEvent[] = [ + { + type: "tool-input-partial", + id: "c1", + name: "shell", + input: { command: "ls" }, + }, + { + type: "tool-input-partial", + id: "c1", + name: "shell", + input: { command: "ls -la" }, + }, + { + type: "tool-call", + id: "c1", + name: "shell", + input: { command: "ls -la" }, + }, + { + type: "tool-result", + id: "c1", + name: "shell", + result: { + status: "success", + value: { + exitCode: 0, + stdout: "ok", + stderr: "", + signal: "", + executionTime: 1, + }, + }, + isError: false, + }, + { type: "finish", text: "done" }, + ]; + const typeList = types(await collect(cursorEventsToStream(gen(events)))); + const start = typeList.indexOf("tool-input-start"); + const end = typeList.indexOf("tool-input-end"); + const call = typeList.indexOf("tool-call"); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(call).toBeGreaterThan(end); + // deltas carry the arg-JSON suffix, not the full payload twice + const deltas = (await collect(cursorEventsToStream(gen(events)))).filter( + (p) => p.type === "tool-input-delta", + ); + const joined = deltas.map((d) => (d as { delta: string }).delta).join(""); + expect(joined).toContain("ls -la"); + }); + + it("streams createPlan partials as live text suffixes", async () => { + const events: CursorEvent[] = [ + { + type: "tool-input-partial", + id: "p1", + name: "createPlan", + input: { plan: "# Plan\nstep 1" }, + }, + { + type: "tool-call", + id: "p1", + name: "createPlan", + input: { plan: "# Plan\nstep 1\nstep 2" }, + }, + { + type: "tool-result", + id: "p1", + name: "createPlan", + result: { status: "success", value: {} }, + isError: false, + }, + { type: "finish", text: "# Plan\nstep 1\nstep 2" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + const text = parts + .filter((p) => p.type === "text-delta") + .map((p) => (p as { delta: string }).delta) + .join(""); + expect(text).toContain("step 1"); + expect(text).toContain("step 2"); + // plan tool itself never becomes a block + expect( + parts.some( + (p) => + (p.type === "tool-call" || p.type === "tool-result") && + (p as { toolName?: string }).toolName?.includes("createPlan"), + ), + ).toBe(false); + }); + + it("dangling partial input streams are closed with a synthetic error", async () => { + const events: CursorEvent[] = [ + { + type: "tool-input-partial", + id: "c9", + name: "shell", + input: { command: "ls" }, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events))); + const typeList = types(parts); + expect(typeList).toContain("tool-input-end"); + const result = parts.find((p) => p.type === "tool-result") as + | { isError?: boolean } + | undefined; + expect(result?.isError).toBe(true); + }); +}); + describe("mapUsage", () => { it("maps Cursor usage into the V3 nested shape", () => { expect( From cfdddedd03c1e14c4815c9a30a9b6f10854fe12d Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:01:30 -0500 Subject: [PATCH 16/22] feat: semSearch format-only adapter --- src/provider/stream-map.ts | 25 +++++++++++++++++++++++-- test/stream-map.test.ts | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 0a62490..87033bb 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -296,8 +296,9 @@ function mapTodos(args: unknown): Array<{ content: string; status: string }> { /** * Cursor tool name → opencode native tool adapter. Cursor tools without a * natural opencode counterpart (`delete`, `mcp`, `semSearch`, `readLints`, - * `generateImage`, `recordScreen`) are intentionally absent and fall through - * to generic `cursor_*` blocks. `edit` and `createPlan` are handled separately + * `generateImage`, `recordScreen`) keep their generic `cursor_*` block (no + * native `tool` remap) but may still supply a `result` formatter to fold their + * raw JSON into readable output. `edit` and `createPlan` are handled separately * (edit: native call input depends on the diff in the result; createPlan: * emitted as assistant markdown text so opencode renders it like a normal plan). */ @@ -485,6 +486,26 @@ const NATIVE_ADAPTERS: Record = { }; }, }, + // Cursor `semSearch` has no opencode counterpart — format-only: query title + // + results body instead of the raw `{results}` JSON. + semSearch: { + input: (args) => { + const out: Record = { query: strField(args, "query") ?? "" }; + const dirs = isRecord(args) && Array.isArray(args["targetDirectories"]) ? args["targetDirectories"] : undefined; + if (dirs && dirs.length > 0) out["targetDirectories"] = dirs; + return out; + }, + result: (value, args) => { + const query = strField(args, "query") ?? ""; + const results = strField(value, "results") ?? ""; + const count = results ? results.split("\n").filter((l) => l.trim().length > 0).length : 0; + return { + title: query, + metadata: { matches: count, truncated: false }, + output: results || "No results", + }; + }, + }, // Cursor `ls` → opencode `list` (flatten the directory tree into paths). ls: { tool: "list", diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 2d94b28..654df0c 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -1186,6 +1186,27 @@ describe("native tool mapping (blocks)", () => { }); }); + it("folds semSearch results into readable output", async () => { + const { call, result } = await mapTool( + "semSearch", + { query: "auth middleware" }, + { + status: "success", + value: { + results: "src/auth.ts: middleware()\nsrc/web.ts: use(auth)", + }, + }, + ); + // No native counterpart — stays a prefixed `cursor_semSearch` block. + expect(call).toMatchObject({ toolName: "cursor_semSearch" }); + expect(result).toMatchObject({ toolName: "cursor_semSearch" }); + expect(foldedResult(result)).toMatchObject({ + title: "auth middleware", + metadata: { matches: 2, truncated: false }, + }); + expect(foldedResult(result).output).toContain("middleware()"); + }); + it("formats Cursor `delete` as a one-line `cursor_delete` confirmation", async () => { const { call, result } = await mapTool( "delete", From d7805c7af5a4d7b5c67d236efa389845ef66f007 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:05:27 -0500 Subject: [PATCH 17/22] test: shell-output-delta probe + findings (documented-ignore: not emitted for local agents) --- test/e2e/probe-shell-delta.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/e2e/probe-shell-delta.ts diff --git a/test/e2e/probe-shell-delta.ts b/test/e2e/probe-shell-delta.ts new file mode 100644 index 0000000..e81f1ea --- /dev/null +++ b/test/e2e/probe-shell-delta.ts @@ -0,0 +1,35 @@ +// Probe: enumerate ALL update types; flag shell tool-calls + shell-output-delta. Run: bun test/e2e/probe-shell-delta.ts +import { Agent, Cursor } from "@cursor/sdk"; +Cursor.configure({ local: { useHttp1ForAgent: true } }); +const agent = await Agent.create({ + apiKey: process.env.CURSOR_API_KEY, + model: { id: process.env.CURSOR_E2E_MODEL ?? "auto" }, + local: { cwd: process.cwd() }, +}); +const typeCounts: Record = {}; +let shellDeltaSamples = 0; +let shellToolCalls = 0; +const run = await agent.send( + "Use your shell tool to run this exact command and show me the output: for i in 1 2 3 4 5; do echo line$i; sleep 1; done", + { + onDelta: ({ update }) => { + typeCounts[update.type] = (typeCounts[update.type] ?? 0) + 1; + if (update.type === "shell-output-delta") { + if (shellDeltaSamples < 5) process.stdout.write(`DELTA ${JSON.stringify(update)}\n`); + shellDeltaSamples++; + } + if ( + (update.type === "tool-call-started" || update.type === "tool-call-completed") && + (update as { toolCall?: { type?: string } }).toolCall?.type === "shell" + ) { + shellToolCalls++; + if (shellToolCalls <= 2) + process.stdout.write(`SHELLTOOL ${update.type} ${JSON.stringify((update as { toolCall?: unknown }).toolCall).slice(0, 300)}\n`); + } + }, + }, +); +await run.wait(); +process.stdout.write(`\nTYPE_COUNTS ${JSON.stringify(typeCounts)}\n`); +process.stdout.write(`SHELL_TOOL_CALLS ${shellToolCalls}\n`); +process.stdout.write(`SHELL_OUTPUT_DELTAS ${shellDeltaSamples}\n`); From 43fa28560660141759fdb262d49b7d4647637966 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:07:12 -0500 Subject: [PATCH 18/22] feat: prefer SDK-authoritative model variants (displayName/isDefault) --- src/model-variants.ts | 23 ++++++++++++++++++++++- test/model-variants.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/model-variants.ts b/src/model-variants.ts index 1f696ce..8816e44 100644 --- a/src/model-variants.ts +++ b/src/model-variants.ts @@ -52,12 +52,33 @@ export function defaultModelParams(item: ModelListItem): Record * variant turns it ON) so a selection never depends on Cursor's server-side * default for an omitted param. */ +/** Slugify an SDK variant displayName for the opencode variant picker key. */ +function variantKey(displayName: string): string { + return displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "variant"; +} + export function buildModelVariants(item: ModelListItem): Record { - const out: Record = {}; // Non-reasoning boolean defaults (e.g. { fast: "false" }), pinned into every // reasoning variant so picking a reasoning level never re-enables fast. const defaults = defaultModelParams(item); + const sdkVariants = item.variants ?? []; + if (sdkVariants.length > 0) { + // Cursor-authoritative presets win: displayName + isDefault are curated + // upstream; we only pin the non-reasoning boolean floors underneath. + const out: Record = {}; + for (const v of sdkVariants) { + if (v.isDefault === true) continue; // the base model entry IS this variant + const params: Record = { ...defaults }; + for (const p of v.params ?? []) params[p.id] = p.value; + const key = variantKey(v.displayName); + out[out[key] === undefined ? key : `${key}-2`] = { params }; + } + return out; + } + + const out: Record = {}; + // Pre-pass: does any reasoning param expose a non-boolean effort enum (e.g. // ["low","medium","high","xhigh","max"])? When it does, a coexisting boolean // reasoning toggle (Cursor's `thinking=["false","true"]` on claude-* models) diff --git a/test/model-variants.test.ts b/test/model-variants.test.ts index 35deede..e4f5dcc 100644 --- a/test/model-variants.test.ts +++ b/test/model-variants.test.ts @@ -238,6 +238,34 @@ describe("buildModelVariants", () => { it("returns no variants for a model without parameters", () => { expect(buildModelVariants(model(undefined))).toEqual({}); }); + + it("uses SDK-authoritative variants when present", () => { + const item = { + id: "composer-2.5", + displayName: "Composer 2.5", + parameters: [ + { id: "fast", values: [{ value: "false" }, { value: "true" }] }, + { id: "thinking", values: [{ value: "low" }, { value: "high" }] }, + ], + variants: [ + { params: [{ id: "thinking", value: "low" }], displayName: "Thinking Low" }, + { params: [{ id: "thinking", value: "high" }, { id: "fast", value: "true" }], displayName: "Turbo" }, + { params: [], displayName: "Standard", isDefault: true }, + ], + } as unknown as ModelListItem; + const variants = buildModelVariants(item); + expect(Object.keys(variants).sort()).toEqual(["thinking-low", "turbo"]); + expect(variants["thinking-low"]?.params).toEqual({ fast: "false", thinking: "low" }); + expect(variants["turbo"]?.params).toEqual({ fast: "true", thinking: "high" }); + }); + + it("falls back to generated variants when SDK variants absent", () => { + const item = { + id: "m", displayName: "m", + parameters: [{ id: "thinking", values: [{ value: "low" }, { value: "high" }] }], + } as unknown as ModelListItem; + expect(Object.keys(buildModelVariants(item))).toEqual(["low", "high"]); + }); }); describe("defaultModelParams", () => { From 5d905eec26491bb64d8351a50e44edc9bcda9787 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:11:14 -0500 Subject: [PATCH 19/22] feat: autoReview option (classifier tool-gating middle ground) --- src/provider/index.ts | 9 +++++++++ src/provider/language-model.ts | 5 +++++ src/provider/session-pool.ts | 4 ++++ test/session-pool.test.ts | 12 ++++++++++++ 4 files changed, 30 insertions(+) diff --git a/src/provider/index.ts b/src/provider/index.ts index d604ef7..6d85251 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -55,6 +55,12 @@ export interface CursorProviderOptions { settingSources?: SettingSource[]; /** Run the agent's tools inside Cursor's sandbox. */ sandbox?: boolean; + /** + * Cursor's classifier-backed Auto review mode: gates tool calls through a + * classifier instead of running them unconditionally. Defaults to `false`. + * Best-effort tool-call gating, not a security boundary. + */ + autoReview?: boolean; /** Cursor subagent definitions (`{ description, prompt, model?, mcpServers? }`). */ agents?: Record; /** @@ -120,6 +126,9 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 { ? { settingSources: options.settingSources } : {}), ...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}), + ...(options.autoReview !== undefined + ? { autoReview: options.autoReview } + : {}), ...(options.agents ? { agents: options.agents } : {}), session: options.session ?? "auto", toolDisplay: options.toolDisplay ?? "blocks", diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index a5d620d..0521e22 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -70,6 +70,8 @@ export interface CursorModelConfig { settingSources?: SettingSource[]; /** Run the agent's tools inside Cursor's sandbox. */ sandbox?: boolean; + /** Cursor's classifier-backed Auto review mode (tool-call gating). */ + autoReview?: boolean; /** Cursor subagent definitions made available to the agent. */ agents?: Record; /** @@ -303,6 +305,9 @@ export class CursorLanguageModel implements LanguageModelV3 { ...(this.config.sandbox !== undefined ? { sandbox: this.config.sandbox } : {}), + ...(this.config.autoReview !== undefined + ? { autoReview: this.config.autoReview } + : {}), ...(mcpServers ? { mcpServers } : {}), ...(this.config.agents ? { agents: this.config.agents } : {}), ...(poolKey ? { name: `opencode/${sessionID!.slice(-8)}` } : {}), diff --git a/src/provider/session-pool.ts b/src/provider/session-pool.ts index 98332a0..fa0c59b 100644 --- a/src/provider/session-pool.ts +++ b/src/provider/session-pool.ts @@ -75,6 +75,7 @@ export interface AcquireAgentParams { cwd: string; settingSources?: SettingSource[]; sandbox?: boolean; + autoReview?: boolean; mcpServers?: Record; agents?: Record; name?: string; @@ -127,6 +128,9 @@ export async function acquireAgent( ...(params.sandbox !== undefined ? { sandboxOptions: { enabled: params.sandbox } } : {}), + ...(params.autoReview !== undefined + ? { autoReview: params.autoReview } + : {}), }, ...(params.mcpServers ? { mcpServers: params.mcpServers } : {}), ...(params.agents ? { agents: params.agents } : {}), diff --git a/test/session-pool.test.ts b/test/session-pool.test.ts index 61cd71b..fade2a8 100644 --- a/test/session-pool.test.ts +++ b/test/session-pool.test.ts @@ -183,4 +183,16 @@ describe("acquireAgent", () => { await acquireAgent({ ...base }); expect(getPooledAgentId("s1")).toBe("a1"); }); + + it("passes autoReview through to the agent's local options", async () => { + const created: unknown[] = []; + create.mockImplementation(async (opts) => { + created.push(opts); + return fakeAgent("a1"); + }); + await acquireAgent({ ...base, autoReview: true }); + expect( + (created[0] as { local?: { autoReview?: boolean } }).local?.autoReview, + ).toBe(true); + }); }); From 45e5d0b550b948821c927c7f5dcbbe53c78553c7 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:16:16 -0500 Subject: [PATCH 20/22] feat: cursor_delegate multi-root workspace (additionalCwds) --- src/plugin/cursor-tools.ts | 7 ++++++- src/provider/delegate.ts | 7 +++++-- src/provider/session-pool.ts | 3 ++- test/cursor-tools.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/plugin/cursor-tools.ts b/src/plugin/cursor-tools.ts index 7153ce1..c9fbda5 100644 --- a/src/plugin/cursor-tools.ts +++ b/src/plugin/cursor-tools.ts @@ -156,6 +156,10 @@ export function buildCursorTools(deps: CursorToolDeps): Record { expect(runDelegate.mock.calls[0]![0].cwd).toBe("/explicit"); }); + it("passes a multi-root cwd when additionalCwds are given (else a plain string)", async () => { + runDelegate.mockResolvedValue({ + agentId: "a1", + text: "x", + reasoning: "", + toolActivity: [], + usage: undefined, + }); + const tools = buildCursorTools(withKey); + + await tools.cursor_delegate!.execute( + { prompt: "p", model: "m", cwd: "/base", additionalCwds: ["/extra"] } as any, + ctx(vi.fn().mockResolvedValue(undefined)), + ); + expect(runDelegate.mock.calls[0]![0].cwd).toEqual(["/base", "/extra"]); + + await tools.cursor_delegate!.execute( + { prompt: "p", model: "m", cwd: "/base" } as any, + ctx(vi.fn().mockResolvedValue(undefined)), + ); + expect(runDelegate.mock.calls[1]![0].cwd).toBe("/base"); + }); + it("formats the cloud output with branches and progress when no PR is opened", async () => { runCloudAgent.mockResolvedValue({ agentId: "bc-9", From bef6e5aa82d2cf5d4960ab7a2fea2ad7c228145c Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:22:20 -0500 Subject: [PATCH 21/22] =?UTF-8?q?docs+release:=20v0.5.0-next.0=20=E2=80=94?= =?UTF-8?q?=20transport,=20reliability,=20streaming=20fidelity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 70 ++++++++++++++++++++++++++++++++++++++----------- install.sh | 8 +++--- package.json | 2 +- 5 files changed, 110 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5012d7a..ea3fec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.5.0] — 2026-07-23 + +Native-experience overhaul: in-process HTTP/1.1 transport under Bun, typed-error +reliability, and full streaming fidelity. + +- **HTTP/1.1 in-process transport is now the default under Bun; the Node sidecar + is a fallback.** opencode runs on Bun, whose `node:http2` client breaks the + Cursor SDK's streaming RPC (`NGHTTP2_FRAME_SIZE_ERROR`; oven-sh/bun#31499). + The SDK now runs in-process over HTTP/1.1 + SSE + (`Cursor.configure({ local: { useHttp1ForAgent: true } })`) — no Node child + process required. Three transports are selectable via the `transport` provider + option or `OPENCODE_CURSOR_TRANSPORT`: `http1` (Bun default), `http2-direct` + (Node default), and `sidecar` (rollback). Resolution order is option → + `OPENCODE_CURSOR_TRANSPORT` → legacy `OPENCODE_CURSOR_SIDECAR` + (`1`→`sidecar`, `0`→`http2-direct`) → per-runtime default. Roll back with + `OPENCODE_CURSOR_TRANSPORT=sidecar`. +- **Typed error classification with per-kind recovery.** SDK errors are + classified into `agent-not-found`, `agent-busy`, `rate-limit`, `network`, + `auth`, `config`, and `unknown` (by error `name`/`status`/`code`, never + `instanceof` — sidecar-forwarded errors arrive as plain objects). `agent-busy` + resends once with `local.force`; `rate-limit`/`network` retry with bounded + backoff on the same agent; `auth`/`config` fail fast. +- **Idempotent resends.** Every (re)send of a turn carries an idempotency key so + a retry is a server-side dedupe, not a duplicate turn. +- **Stream watchdog.** A wedged run that streams nothing is bounded by + `OPENCODE_CURSOR_STALL_MS` (default `60000`): a pre-first-event stall cancels + and force-resends once; a stall after partial output surfaces a terminal error + rather than re-emitting the already-yielded prefix. Set to `0` to disable. +- **Fixed: silent-replay turns dropped their token usage.** A multi-message + interjection replays leading messages silently and streams only the last; the + usage from the silent turns is now summed into the visible turn's reported + usage instead of being lost. +- **Live tool-input streaming.** Cursor's `partial-tool-call` updates are bridged + to incremental tool-input parts, so tool arguments stream as they arrive + instead of appearing all at once when the call completes. +- **Thinking duration and compaction metadata.** `thinking-completed` carries the + reasoning duration, and Cursor's summary/compaction updates are surfaced as + compaction events in the stream. +- **SDK-authoritative model variants.** Variant construction prefers the SDK's + own `displayName`/`isDefault` metadata rather than deriving it locally. +- **`autoReview` option and multi-root delegation.** New `autoReview` provider + option gates tool calls through Cursor's classifier-backed Auto review + (best-effort, not a security boundary). `cursor_delegate` gains + `additionalCwds` to combine extra workspace roots into a multi-root agent + workspace. +- **Node floor raised to >=22.13** (`engines.node`, from >=22.0), and only + needed for the `sidecar` fallback transport. +- **Dependency bumps.** `@cursor/sdk` 1.0.23→1.0.24, `@opencode-ai/plugin` + 1.17.14→1.18.4, `@opencode-ai/sdk` 1.17.14→1.18.4. + ## [0.4.7-next.0] — 2026-07-17 - **Fixed: subagents silently ran Cursor's server-side `fast` default (e.g. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23577da..eaf8685 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ When reporting a runtime bug, please include: ## Development setup -Requires **Node.js 22+**. +Requires **Node.js 22.13+**. ```bash npm install diff --git a/README.md b/README.md index 0d60bba..e174ef5 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ It uses the [official Cursor SDK](https://cursor.com/docs/sdk/typescript) (`@cur ## Requirements - **opencode 1.17+** -- **Node.js 22+ on your `PATH`** — opencode runs on [Bun](https://bun.sh); the plugin needs a - Node sidecar to host the Cursor SDK (see [Runtime](#runtime-bun-and-the-node-sidecar)). +- **Node.js 22.13+ on your `PATH`** (optional) — opencode runs on [Bun](https://bun.sh) and the + plugin runs the Cursor SDK in-process by default; Node is only needed for the `sidecar` + transport fallback (see [Transport](#transport)). - A **Cursor account and API key** (from the Cursor dashboard). ## Install @@ -28,7 +29,7 @@ curl -fsSL https://raw.githubusercontent.com/stablekernel/opencode-cursor/main/i ``` Registers the plugin in your global `opencode.json` (`~/.config/opencode/opencode.json`), checks -for Node.js 22+, and offers to set `CURSOR_API_KEY`. Flags: +for Node.js 22.13+, and offers to set `CURSOR_API_KEY`. Flags: - `--project` — write `./opencode.json` in the current directory instead. - `--yes` / `-y` — non-interactive. @@ -137,20 +138,24 @@ See [SECURITY.md](./SECURITY.md) for the full threat model. | `mode` | `"agent"` | Default conversation mode (`"agent"` or `"plan"`) | | `params` | — | Default model params, e.g. `{ thinking: "high" }` | | `settingSources` | — | Cursor settings layers to load: `["project","user","all",...]` — pulls in your Cursor skills, rules, and `.cursor/mcp.json` | -| `sandbox` | — | Run the agent's tools in Cursor's sandbox | +| `sandbox` | — | Run the agent's tools in [Cursor's sandbox](https://cursor.com/docs/agent/sandbox) | +| `autoReview` | `false` | Gate tool calls through Cursor's classifier-backed Auto review (best-effort, not a security boundary) | | `agents` | — | Cursor subagent definitions | | `session` | `"auto"` | Session reuse strategy — see [Session reuse](#session-reuse-session) | | `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent | | `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones | | `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) | | Environment variable | Default | Meaning | | --- | --- | --- | | `CURSOR_API_KEY` | — | API key fallback | | `OPENCODE_CURSOR_MODEL_CACHE_TTL_MS` | `86400000` | Model-list cache lifetime (ms) | | `OPENCODE_CURSOR_DEBUG` | — | Set to `1` for trace logging on stderr | -| `OPENCODE_CURSOR_SIDECAR` | — | `1` = always use Node sidecar; `0` = never | +| `OPENCODE_CURSOR_TRANSPORT` | — | Force a transport: `http1` \| `http2-direct` \| `sidecar` — see [Transport](#transport) | +| `OPENCODE_CURSOR_STALL_MS` | `60000` | Stream watchdog timeout (ms); `0` disables — see [Reliability](#reliability) | +| `OPENCODE_CURSOR_SIDECAR` | — | Legacy: `1` maps to `sidecar`, `0` maps to `http2-direct` (superseded by `OPENCODE_CURSOR_TRANSPORT`) | ### Session reuse (`session`) @@ -295,23 +300,56 @@ To force the fallback: { "provider": { "cursor": { "options": { "toolDisplay": "reasoning" } } } } ``` -## Runtime: Bun and the Node sidecar +## Transport -opencode runs on [Bun](https://bun.sh), which has an `node:http2` incompatibility with the Cursor -SDK's streaming RPC. The plugin transparently hosts the Cursor SDK in a short-lived **Node child -process** when running under Bun. Under Node it runs in-process. +opencode runs on [Bun](https://bun.sh), whose `node:http2` client is incompatible with the Cursor +SDK's long-lived streaming RPC (`NGHTTP2_FRAME_SIZE_ERROR`; see +[oven-sh/bun#31499](https://github.com/oven-sh/bun/issues/31499)). The plugin works around this by +running the SDK over HTTP/1.1 in-process — no Node child process required. The historical Node +sidecar remains as a rollback fallback. -This is why **Node.js 22+ on your `PATH`** is required. If Node isn't found, the plugin warns once -and falls back to in-process (native Cursor tools will misbehave until Node is available). +| Transport | Where it runs | When it's the default | +| --- | --- | --- | +| `http1` | in-process, HTTP/1.1 + SSE (Bun-safe) | under Bun | +| `http2-direct` | in-process, SDK's default HTTP/2 | under Node (tests, scripts, non-Bun hosts) | +| `sidecar` | spawned Node child hosting the SDK | never (rollback only) | + +Resolution order: the `transport` provider option → `OPENCODE_CURSOR_TRANSPORT` → +legacy `OPENCODE_CURSOR_SIDECAR` (`1`→`sidecar`, `0`→`http2-direct`) → the per-runtime default +above. + +If you hit a regression on the in-process path, roll back to the sidecar: + +```bash +export OPENCODE_CURSOR_TRANSPORT=sidecar # requires Node.js 22.13+ on PATH +``` + +An explicit `sidecar` request with no Node on `PATH` falls back to `http1` (Bun) or `http2-direct` +(Node) with a stderr notice. + +## Reliability + +The provider classifies Cursor SDK errors into typed kinds (`agent-not-found`, `agent-busy`, +`rate-limit`, `network`, `auth`, `config`, `unknown`) and recovers per kind: + +- **agent-busy** — a previous crash left a run wedged; the send is retried once with the SDK's + `local.force` escape hatch. +- **rate-limit / network** — bounded exponential backoff on the same agent. +- **auth / config** — fail fast (not retried). + +Sends carry an idempotency key so a retry is a server-side dedupe, not a duplicate turn. -Override with `OPENCODE_CURSOR_SIDECAR=1` (always sidecar) or `OPENCODE_CURSOR_SIDECAR=0` (never). +A **stream watchdog** guards against a wedged run that streams nothing: if no event arrives within +`OPENCODE_CURSOR_STALL_MS` (default `60000`), a pre-first-event stall cancels and force-resends +once; a stall after partial output is surfaced as a terminal error rather than re-emitting the +already-yielded prefix. Set `OPENCODE_CURSOR_STALL_MS=0` to disable. ## Troubleshooting -- **Native Cursor tools hang / "Tool execution aborted" (`NGHTTP2_FRAME_SIZE_ERROR`).** Node isn't - on your `PATH`. Install Node.js 22+, or force the sidecar with `OPENCODE_CURSOR_SIDECAR=1`. -- **"Running under Bun without a usable Node sidecar" warning.** Install Node.js 22+, or set - `OPENCODE_CURSOR_SIDECAR=0` to accept in-process behavior and silence the warning. +- **Native Cursor tools hang / "Tool execution aborted" (`NGHTTP2_FRAME_SIZE_ERROR`).** A + `http2-direct` transport was forced under Bun. Unset `OPENCODE_CURSOR_TRANSPORT` (defaults to the + Bun-safe `http1`), or roll back with `OPENCODE_CURSOR_TRANSPORT=sidecar` (needs Node.js 22.13+ on + `PATH`). - **Plugin enabled but no `cursor` provider/models appear, or you see a stale-version warning.** opencode caches the `@latest` plugin install on first use and never refreshes it. Exit opencode, delete `~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest` diff --git a/install.sh b/install.sh index 86d48fd..44130fa 100755 --- a/install.sh +++ b/install.sh @@ -11,8 +11,8 @@ # startup; @latest makes it re-resolve to the newest release). Reuses an # existing opencode.jsonc or opencode.json if present (comments preserved), # else creates opencode.json. An older/pinned entry is upgraded in place. -# 2. Verifies Node.js 22+ is on your PATH — the plugin spawns a short-lived -# Node sidecar to host the Cursor SDK (opencode itself runs on Bun). +# 2. Verifies Node.js 22.13+ is on your PATH — the plugin can spawn a +# short-lived Node sidecar to host the Cursor SDK (opencode runs on Bun). # 3. Offers to set CURSOR_API_KEY in your shell profile if it is not set. # # Flags: @@ -107,8 +107,8 @@ info "${DIM}${REPO_URL}${RESET}" info "Scope: $SCOPE" info "Config: $CONFIG_PATH" -# ---- 1. Node.js 22+ check ---------------------------------------------------- -step "Checking Node.js (>= ${MIN_NODE_MAJOR}) on PATH" +# ---- 1. Node.js 22.13+ check ------------------------------------------------- +step "Checking Node.js (>= ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}) on PATH" if command -v node >/dev/null 2>&1; then NODE_VER="$(node --version 2>/dev/null | sed 's/^v//')" NODE_MAJOR="${NODE_VER%%.*}" diff --git a/package.json b/package.json index b2751f1..2824e6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stablekernel/opencode-cursor", - "version": "0.4.7-next.0", + "version": "0.5.0-next.0", "description": "opencode provider plugin backed by the official Cursor SDK (@cursor/sdk) — adds a Cursor provider and lists its models", "type": "module", "license": "MIT", From 6e0eca17d01c279c207934d401b672ba8a09391f Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 23 Jul 2026 20:47:00 -0500 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20final-review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20legacy=20SIDECAR=3D0=20http1=20under=20Bun,=20varia?= =?UTF-8?q?nt=20collision=20loop,=20abort=20parity,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- README.md | 1 + src/model-variants.ts | 4 +++- src/provider/agent-backend.ts | 2 +- src/provider/agent-events.ts | 3 +++ src/provider/index.ts | 2 +- src/provider/language-model.ts | 1 + test/agent-events.test.ts | 41 ++++++++++++++++++++++++++++++++++ test/model-variants.test.ts | 16 +++++++++++++ test/stream-map.test.ts | 1 + test/transport.test.ts | 5 +++-- 11 files changed, 72 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3fec5..38b44a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -## [0.5.0] — 2026-07-23 +## [0.5.0-next.0] — 2026-07-23 Native-experience overhaul: in-process HTTP/1.1 transport under Bun, typed-error reliability, and full streaming fidelity. diff --git a/README.md b/README.md index e174ef5..6b31029 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ See [SECURITY.md](./SECURITY.md) for the full threat model. | `OPENCODE_CURSOR_TRANSPORT` | — | Force a transport: `http1` \| `http2-direct` \| `sidecar` — see [Transport](#transport) | | `OPENCODE_CURSOR_STALL_MS` | `60000` | Stream watchdog timeout (ms); `0` disables — see [Reliability](#reliability) | | `OPENCODE_CURSOR_SIDECAR` | — | Legacy: `1` maps to `sidecar`, `0` maps to `http2-direct` (superseded by `OPENCODE_CURSOR_TRANSPORT`) | +| `OPENCODE_CURSOR_TOOL_INPUT_STREAM` | on | Set to `0` to disable live tool-input streaming (`tool-input-start`/`-delta`/`-end` parts) | ### Session reuse (`session`) diff --git a/src/model-variants.ts b/src/model-variants.ts index 8816e44..e2e985f 100644 --- a/src/model-variants.ts +++ b/src/model-variants.ts @@ -72,7 +72,9 @@ export function buildModelVariants(item: ModelListItem): Record = { ...defaults }; for (const p of v.params ?? []) params[p.id] = p.value; const key = variantKey(v.displayName); - out[out[key] === undefined ? key : `${key}-2`] = { params }; + let candidate = key; + for (let n = 2; out[candidate] !== undefined; n++) candidate = `${key}-${n}`; + out[candidate] = { params }; } return out; } diff --git a/src/provider/agent-backend.ts b/src/provider/agent-backend.ts index 8a40006..89725cb 100644 --- a/src/provider/agent-backend.ts +++ b/src/provider/agent-backend.ts @@ -75,7 +75,7 @@ export function resolveTransport(env: BackendEnvironment): TransportKind { if (legacy === "1" || legacy === "true") { return env.nodePath ? "sidecar" : env.isBun ? "http1" : "http2-direct"; } - if (legacy === "0" || legacy === "false") return "http2-direct"; + if (legacy === "0" || legacy === "false") return env.isBun ? "http1" : "http2-direct"; return env.isBun ? DEFAULT_BUN_TRANSPORT : "http2-direct"; } diff --git a/src/provider/agent-events.ts b/src/provider/agent-events.ts index 1bf95b2..0adf74e 100644 --- a/src/provider/agent-events.ts +++ b/src/provider/agent-events.ts @@ -197,6 +197,9 @@ export async function* streamAgentTurn( ) .then(async (run) => { runHolder.run = run; + // The signal may have fired while send() was in flight (before runHolder + // was populated, so onAbort had nothing to cancel); cancel now. + if (options.abortSignal?.aborted) void Promise.resolve(run.cancel()).catch(() => {}); const result = await run.wait(); if (debug) { console.error( diff --git a/src/provider/index.ts b/src/provider/index.ts index 6d85251..68a3e9f 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -93,7 +93,7 @@ export interface CursorProviderOptions { /** * Transport for Cursor agent traffic: "http1" (in-process, Bun-safe), * "http2-direct" (in-process, Node only), "sidecar" (Node child, rollback). - * Beats OPENCODE_CURSOR_TRANSPORT. Process-global: first provider wins. + * Beats OPENCODE_CURSOR_TRANSPORT. Process-global: last provider to set it wins. */ transport?: "http1" | "http2-direct" | "sidecar"; } diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index 0521e22..c7d013d 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -369,6 +369,7 @@ export class CursorLanguageModel implements LanguageModelV3 { { mode, abortSignal: options.abortSignal, + // Key intentionally diverges from the single-turn key: the multi-replay and single-turn branches are mutually exclusive. idempotencyKey: sendIdempotencyKey( sessionID, { diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index 0f05ab7..5849b0e 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -252,6 +252,47 @@ describe("sendAgentTurnSilently", () => { await promise; expect(cancelled).toBe(true); }); + + it("streamAgentTurn cancels the run when abort fires during the in-flight send", async () => { + // Abort lands after send() is called but before runHolder.run is + // populated (onAbort has nothing to cancel). The post-assignment guard + // in startRun must still cancel the resolved run. + let cancelled = false; + const controller = new AbortController(); + let releaseSend: (() => void) | undefined; + const agent = { + agentId: "agent-inflight-abort", + send: async ( + _message: SDKUserMessage, + _sendOptions?: Record, + ) => { + // Hold send() in flight until the caller releases it (post-abort). + await new Promise((resolve) => { + releaseSend = resolve; + }); + const run: Partial = { + wait: async () => ({ status: "cancelled" }) as never, + cancel: async () => { + cancelled = true; + }, + }; + return run as Run; + }, + } as unknown as AgentLike; + + const promise = collect( + streamAgentTurn(agent, MESSAGE, { + mode: "agent", + abortSignal: controller.signal, + }), + ); + // Abort while send() is still in flight, then let send() resolve. + await new Promise((r) => setTimeout(r, 5)); + controller.abort(); + releaseSend?.(); + await promise; + expect(cancelled).toBe(true); + }); }); describe("streamAgentTurn MCP error surfacing", () => { diff --git a/test/model-variants.test.ts b/test/model-variants.test.ts index e4f5dcc..7ed03c3 100644 --- a/test/model-variants.test.ts +++ b/test/model-variants.test.ts @@ -259,6 +259,22 @@ describe("buildModelVariants", () => { expect(variants["turbo"]?.params).toEqual({ fast: "true", thinking: "high" }); }); + it("assigns distinct keys when three SDK variants slug to the same key", () => { + // Three displayNames that slugify identically must yield three distinct + // keys (key, key-2, key-3) via the collision counter — no variant lost. + const item = { + id: "m", displayName: "m", + parameters: [{ id: "thinking", values: [{ value: "low" }, { value: "high" }] }], + variants: [ + { params: [{ id: "thinking", value: "low" }], displayName: "Deep Think" }, + { params: [{ id: "thinking", value: "high" }], displayName: "Deep-Think" }, + { params: [{ id: "thinking", value: "low" }], displayName: "Deep Think!" }, + ], + } as unknown as ModelListItem; + const variants = buildModelVariants(item); + expect(Object.keys(variants).sort()).toEqual(["deep-think", "deep-think-2", "deep-think-3"]); + }); + it("falls back to generated variants when SDK variants absent", () => { const item = { id: "m", displayName: "m", diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 654df0c..c775467 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -486,6 +486,7 @@ describe("cursorEventsToStream", () => { expect(types(parts)).toContain("text-end"); const finish = parts.find((p) => p.type === "finish"); expect(finish).toMatchObject({ finishReason: { unified: "error" } }); + expect(parts.filter((p) => p.type === "finish")).toHaveLength(1); }); it("uses empty usage when no usage event arrives", async () => { diff --git a/test/transport.test.ts b/test/transport.test.ts index d7e5dfa..3dccd25 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -45,12 +45,13 @@ describe("resolveTransport", () => { expect(resolveTransport(bunNoNode)).toBe("http1"); }); - it("legacy OPENCODE_CURSOR_SIDECAR maps: 1 -> sidecar, 0 -> http2-direct", () => { + it("legacy OPENCODE_CURSOR_SIDECAR maps: 1 -> sidecar, 0 -> Bun http1 / Node http2-direct", () => { delete process.env.OPENCODE_CURSOR_TRANSPORT; process.env.OPENCODE_CURSOR_SIDECAR = "1"; expect(resolveTransport(bun)).toBe("sidecar"); process.env.OPENCODE_CURSOR_SIDECAR = "0"; - expect(resolveTransport(bun)).toBe("http2-direct"); + expect(resolveTransport(bun)).toBe("http1"); + expect(resolveTransport(node)).toBe("http2-direct"); }); it("provider option setPreferredTransport beats env", () => {