diff --git a/CHANGELOG.md b/CHANGELOG.md index 5012d7a..38b44a4 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-next.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..6b31029 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,25 @@ 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`) | +| `OPENCODE_CURSOR_TOOL_INPUT_STREAM` | on | Set to `0` to disable live tool-input streaming (`tool-input-start`/`-delta`/`-end` parts) | ### Session reuse (`session`) @@ -295,23 +301,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 3fb4ed4..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: @@ -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 @@ -106,22 +107,24 @@ 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%%.*}" - 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..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", @@ -14,7 +14,7 @@ "url": "https://github.com/stablekernel/opencode-cursor/issues" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" }, "keywords": [ "opencode", @@ -52,12 +52,13 @@ "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": { "@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 +71,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", diff --git a/src/model-variants.ts b/src/model-variants.ts index 1f696ce..e2e985f 100644 --- a/src/model-variants.ts +++ b/src/model-variants.ts @@ -52,12 +52,35 @@ 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); + let candidate = key; + for (let n = 2; out[candidate] !== undefined; n++) candidate = `${key}-${n}`; + out[candidate] = { 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/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 OPENCODE_CURSOR_TRANSPORT + * -> legacy OPENCODE_CURSOR_SIDECAR (1=sidecar, 0=http2-direct) -> default + * (Bun: DEFAULT_BUN_TRANSPORT, post-gate "http1"; Node: http2-direct). */ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; @@ -18,8 +22,26 @@ 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. 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 = "http1"; + +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 +54,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 env.isBun ? "http1" : "http2-direct"; + return env.isBun ? DEFAULT_BUN_TRANSPORT : "http2-direct"; } function detectNode(): string | undefined { @@ -59,15 +98,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 +157,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/agent-events.ts b/src/provider/agent-events.ts index aee3546..0adf74e 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 } from "./agent-backend.js"; +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 { @@ -13,14 +14,37 @@ 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 } + | { type: "reasoning-complete"; durationMs?: number } + | { type: "compaction" } | { type: "finish"; text?: string }; export interface StreamAgentTurnOptions { mode: AgentModeOption; 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, + }; } /** @@ -61,10 +85,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 }) => { @@ -76,6 +118,22 @@ 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 "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", @@ -100,50 +158,125 @@ 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; } }; 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); - const sendTurn = (): Promise => - sendWithBusyRetry(agent, message, { mode: options.mode, onDelta }, debug); - // 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}` : ""}`, - ); + // 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 } : {}), + ...(force ? { local: { force: true } } : {}), + }, + debug, + ) + .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( + `[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; + }); + }; + + const onStall = async (): Promise => { + if (finished) return; + // 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 */ } - // 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(() => { + 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) { + await failTerminal(`Cursor run stalled twice (no events for ${stallMs}ms)`); + 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) { @@ -160,34 +293,53 @@ 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); } } +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: { - mode: AgentModeOption; - onDelta?: (args: { update: { type: string } & Record }) => void; - }, + 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; } } @@ -210,8 +362,6 @@ async function sendWithBusyRetry( * 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 @@ -223,17 +373,27 @@ 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(() => {}); }; options.abortSignal?.addEventListener("abort", onAbort); try { - const run = await sendWithBusyRetry(agent, message, { mode: options.mode }, debug); + const run = await sendWithRecovery( + agent, + message, + { mode: options.mode, onDelta, ...(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. @@ -243,7 +403,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. @@ -251,6 +411,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/delegate.ts b/src/provider/delegate.ts index 5e61c30..a996829 100644 --- a/src/provider/delegate.ts +++ b/src/provider/delegate.ts @@ -14,8 +14,11 @@ export interface DelegateParams { mode?: AgentModeOption; /** Convenience for the Cursor `thinking` model param (e.g. "high"). */ thinking?: string; - /** Working directory the local agent operates in. */ - cwd: string; + /** + * Working directory the local agent operates in. An array supplies + * additional workspace roots, giving the agent multi-root access. + */ + cwd: string | string[]; /** Run the agent's tools inside Cursor's sandbox. */ sandbox?: boolean; /** Resume a specific Cursor agent by id instead of creating a fresh one. */ @@ -89,6 +92,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; @@ -99,6 +104,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/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/index.ts b/src/provider/index.ts index af3cdab..68a3e9f 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, @@ -54,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; /** @@ -83,6 +90,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: last provider to set it wins. + */ + transport?: "http1" | "http2-direct" | "sidecar"; } /** @@ -94,6 +107,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 @@ -112,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 2b47981..c7d013d 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -22,10 +22,13 @@ import { type SystemPromptMode, } from "./message-map.js"; 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, @@ -42,6 +45,7 @@ import { classifyTurn, fingerprint, mcpServersFingerprint, + sendIdempotencyKey, } from "./transcript-fingerprint.js"; export interface CursorModelConfig { @@ -66,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; /** @@ -265,6 +271,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 @@ -292,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)}` } : {}), @@ -325,21 +341,47 @@ 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, - }); + 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( acquired.agent, multiTurns[multiTurns.length - 1]!, - { mode, abortSignal: options.abortSignal }, + { + 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, + { + userHashes: [ + ...(record?.userHashes ?? []), + String(multiTurns.length - 1), + ], + }, + multiTurns[multiTurns.length - 1]!.text, + ), + ...(replayUsage ? { usageBase: replayUsage } : {}), + }, )) { yielded = true; yield event; @@ -360,25 +402,51 @@ export class CursorLanguageModel implements LanguageModelV3 { for await (const event of streamAgentTurn(acquired.agent, message, { mode, abortSignal: options.abortSignal, + idempotencyKey, })) { yielded = true; 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", @@ -412,6 +480,7 @@ export class CursorLanguageModel implements LanguageModelV3 { yield* streamAgentTurn(retry.agent, replay, { mode, abortSignal: options.abortSignal, + idempotencyKey, }); } finally { retry.release(); diff --git a/src/provider/session-pool.ts b/src/provider/session-pool.ts index 98332a0..c7c08b8 100644 --- a/src/provider/session-pool.ts +++ b/src/provider/session-pool.ts @@ -72,9 +72,11 @@ export interface AcquireAgentParams { apiKey: string; modelSelection: ModelSelection; mode: AgentModeOption; - cwd: string; + /** SDK `local.cwd` accepts a single root or several (multi-root workspace). */ + cwd: string | string[]; settingSources?: SettingSource[]; sandbox?: boolean; + autoReview?: boolean; mcpServers?: Record; agents?: Record; name?: string; @@ -127,6 +129,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/src/provider/sidecar-client.ts b/src/provider/sidecar-client.ts index d4eb955..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). */ @@ -50,9 +51,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; } @@ -238,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/stream-map.ts b/src/provider/stream-map.ts index 888ef28..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", @@ -756,10 +777,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 +934,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; } @@ -942,6 +1040,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 = () => { @@ -1003,22 +1103,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, @@ -1065,6 +1204,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 +1227,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(); } }, @@ -1136,6 +1299,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); @@ -1175,6 +1342,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/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 311cece..a2ddcb9 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) }; } @@ -68,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-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/agent-events.test.ts b/test/agent-events.test.ts index 0826cd6..5849b0e 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, @@ -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 () => { @@ -225,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", () => { @@ -254,3 +322,194 @@ 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"); + }); +}); + +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); + }); +}); + +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(); + } + }); + + 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(); + } + }); +}); diff --git a/test/cursor-tools.test.ts b/test/cursor-tools.test.ts index ee80a58..9bfe874 100644 --- a/test/cursor-tools.test.ts +++ b/test/cursor-tools.test.ts @@ -189,6 +189,29 @@ describe("buildCursorTools", () => { 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", diff --git a/test/e2e/driver.ts b/test/e2e/driver.ts new file mode 100644 index 0000000..01f292f --- /dev/null +++ b/test/e2e/driver.ts @@ -0,0 +1,120 @@ +/** + * 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; + payloadChars: 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; + let payloadChars = 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") { + 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, payloadChars, parts, error: err instanceof Error ? err.message : String(err) }; + } + const summary: RunSummary = { ev: "run", deltaChars, payloadChars, 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": + // 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( + "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("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()}`; + 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/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`); 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!); + }); +}); 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"); + }); +}); 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/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(); 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 diff --git a/test/model-variants.test.ts b/test/model-variants.test.ts index 35deede..7ed03c3 100644 --- a/test/model-variants.test.ts +++ b/test/model-variants.test.ts @@ -238,6 +238,50 @@ 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("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", + parameters: [{ id: "thinking", values: [{ value: "low" }, { value: "high" }] }], + } as unknown as ModelListItem; + expect(Object.keys(buildModelVariants(item))).toEqual(["low", "high"]); + }); }); describe("defaultModelParams", () => { 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); + }); }); 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([ diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index e798153..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 () => { @@ -1186,6 +1187,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", @@ -1358,6 +1380,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( @@ -1378,3 +1514,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(); + }); +}); 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"), + ); + }); +}); diff --git a/test/transport.test.ts b/test/transport.test.ts new file mode 100644 index 0000000..3dccd25 --- /dev/null +++ b/test/transport.test.ts @@ -0,0 +1,62 @@ +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 -> http1 (post-gate), Node -> http2-direct", () => { + delete process.env.OPENCODE_CURSOR_TRANSPORT; + delete process.env.OPENCODE_CURSOR_SIDECAR; + expect(resolveTransport(bun)).toBe("http1"); + expect(resolveTransport(node)).toBe("http2-direct"); + expect(DEFAULT_BUN_TRANSPORT).toBe("http1"); + }); + + 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 -> 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("http1"); + expect(resolveTransport(node)).toBe("http2-direct"); + }); + + it("provider option setPreferredTransport beats env", () => { + process.env.OPENCODE_CURSOR_TRANSPORT = "sidecar"; + setPreferredTransport("http1"); + expect(resolveTransport(bun)).toBe("http1"); + }); +}); 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, + }, +});