diff --git a/cursor-sdk/skills/cursor-sdk/SKILL.md b/cursor-sdk/skills/cursor-sdk/SKILL.md index bb070b338..d5f30a096 100644 --- a/cursor-sdk/skills/cursor-sdk/SKILL.md +++ b/cursor-sdk/skills/cursor-sdk/SKILL.md @@ -69,10 +69,12 @@ Use for fire-and-forget scripts, GitHub Actions steps, or any "send this prompt, ### 2. `Agent.create(...)` + `agent.send(...)` — durable with follow-ups +`Agent.create()` and `Agent.resume()` return promises. Await the handle before using it. With explicit resource management, use `await using agent = await Agent.create(...)`: `await using` awaits disposal, not the initializer. + ```typescript import { Agent } from "@cursor/sdk"; -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: process.cwd() }, @@ -102,7 +104,7 @@ Use when you need streaming, multi-turn conversation, or lifecycle operations (c ### 3. `Agent.resume(...)` — pick up an existing agent later ```typescript -const agent = Agent.resume(previousAgentId, { +const agent = await Agent.resume(previousAgentId, { apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: process.cwd() }, @@ -149,7 +151,7 @@ try { The SDK holds handles to local executors, persisted run stores, and cloud API clients. Not disposing means leaked child processes, open databases, and in long-running services, memory growth. Always dispose in a `finally`, or use `Agent.prompt()` (disposes for you), or use the `await using` syntax if your tsconfig targets it: ```typescript -await using agent = Agent.create({ /* ... */ }); +await using agent = await Agent.create({ /* ... */ }); ``` ### 4. Streaming is optional but `wait()` is (almost) required diff --git a/cursor-sdk/skills/cursor-sdk/references/advanced.md b/cursor-sdk/skills/cursor-sdk/references/advanced.md index 0ae42f243..068362ea3 100644 --- a/cursor-sdk/skills/cursor-sdk/references/advanced.md +++ b/cursor-sdk/skills/cursor-sdk/references/advanced.md @@ -9,7 +9,7 @@ Sub-agents are **cloud-only at v1**. The `agents:` field on `AgentOptions` is wi Define named sub-agents that the main agent can spawn via the `Agent` tool: ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, cloud: { @@ -47,7 +47,7 @@ The key-name (`"code-reviewer"`) is how the main agent refers to the sub-agent. Cloud sub-agents reference parent MCP servers by **name**, not inline config. Inline `McpServerConfig` entries are rejected at v1 with `ConfigurationError("cloud custom subagents only support string references in v1.")`. Configure the server on the parent's `mcpServers` map and reference it by key from the sub-agent: ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, cloud: { repos: [{ url: "https://github.com/your-org/your-repo", startingRef: "main" }] }, @@ -75,7 +75,7 @@ The parent's `mcpServers` is the truth; sub-agents pick by name from that map. Agent IDs persist. Resume later from any process with the right options: ```typescript -const agent = Agent.resume(agentId, { +const agent = await Agent.resume(agentId, { apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: process.cwd() }, @@ -220,8 +220,8 @@ export class MyAgentOrchestrator { }, }; await using agent = agentId - ? Agent.resume(agentId, options) - : Agent.create(options); + ? await Agent.resume(agentId, options) + : await Agent.create(options); const run = await agent.send(prompt); const result = await run.wait(); diff --git a/cursor-sdk/skills/cursor-sdk/references/auth.md b/cursor-sdk/skills/cursor-sdk/references/auth.md index 5306b6285..f3eda934f 100644 --- a/cursor-sdk/skills/cursor-sdk/references/auth.md +++ b/cursor-sdk/skills/cursor-sdk/references/auth.md @@ -35,7 +35,7 @@ export CURSOR_API_KEY="cursor_..." ```typescript import { Agent } from "@cursor/sdk"; -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: process.cwd() }, @@ -84,8 +84,8 @@ For service-account keys, the service account needs GitHub access configured jus Totally supported — each `Agent.create` / `Agent.prompt` call is independent. Pass `apiKey` explicitly so you don't accidentally fall back to an env var the caller wasn't thinking about: ```typescript -const userAgent = Agent.create({ apiKey: userKey, /* ... */ }); -const botAgent = Agent.create({ apiKey: botKey, /* ... */ }); +const userAgent = await Agent.create({ apiKey: userKey, /* ... */ }); +const botAgent = await Agent.create({ apiKey: botKey, /* ... */ }); ``` ## Local development diff --git a/cursor-sdk/skills/cursor-sdk/references/error-handling.md b/cursor-sdk/skills/cursor-sdk/references/error-handling.md index cc59da7aa..05512abf4 100644 --- a/cursor-sdk/skills/cursor-sdk/references/error-handling.md +++ b/cursor-sdk/skills/cursor-sdk/references/error-handling.md @@ -18,7 +18,7 @@ Always handle both. A try/catch alone won't catch a failed run; checking `result import { Agent, CursorAgentError } from "@cursor/sdk"; async function runOnce(): Promise { - await using agent = Agent.create({ /* ... */ }); + await using agent = await Agent.create({ /* ... */ }); try { const run = await agent.send(prompt); const result = await run.wait(); @@ -103,20 +103,25 @@ if (run.supports("cancel")) { Keep retries small (≤3) for agent startup; agents are expensive to re-launch. If the first attempt fails `RateLimitError` with `isRetryable === true`, back off at least 30 seconds. ```typescript -import { Agent, CursorAgentError, RateLimitError, NetworkError } from "@cursor/sdk"; +import { CursorAgentError, RateLimitError, NetworkError, type SDKAgent } from "@cursor/sdk"; -async function createWithRetry(options: Parameters[0]) { +async function sendWithRetry( + agent: SDKAgent, + message: Parameters[0], + options?: Parameters[1], +) { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return Agent.create(options); + return await agent.send(message, options); } catch (err) { const retryable = err instanceof CursorAgentError && err.isRetryable && (err instanceof NetworkError || err instanceof RateLimitError); if (!retryable || attempt === maxAttempts) throw err; - const backoffMs = 2 ** attempt * 1000 + Math.random() * 500; + const minimumMs = err instanceof RateLimitError ? 30_000 : 2 ** attempt * 1000; + const backoffMs = minimumMs + Math.random() * 500; await new Promise(r => setTimeout(r, backoffMs)); } } @@ -124,7 +129,7 @@ async function createWithRetry(options: Parameters[0]) { } ``` -`Agent.create` is lazy — it doesn't hit the backend until `send()`. Most "startup" errors surface there. Wrap `agent.send(...)` with retries, not just `Agent.create(...)`. +Pass an already-created agent to `sendWithRetry`. The helper awaits each `send()` inside the `try` block so rejected startup promises reach the retry policy. Handle agent creation errors separately; call `run.wait()` outside this helper so a run that already executed is not automatically repeated. ## `RunResult.status === "error"` — What To Do diff --git a/cursor-sdk/skills/cursor-sdk/references/mcp.md b/cursor-sdk/skills/cursor-sdk/references/mcp.md index 775fa4921..5ff0e458e 100644 --- a/cursor-sdk/skills/cursor-sdk/references/mcp.md +++ b/cursor-sdk/skills/cursor-sdk/references/mcp.md @@ -21,7 +21,7 @@ The shape looks similar in both runtimes; what changes is the execution location ## Local Runtime — MCP Config ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: process.cwd() }, @@ -49,7 +49,7 @@ const agent = Agent.create({ ## Cloud Runtime — MCP Config ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, cloud: { @@ -85,7 +85,7 @@ Dashboard-configured MCP servers are also respected on cloud. Users configure th ```typescript const baseMcp = { /* ... */ }; -const agent = Agent.resume(agentId, { +const agent = await Agent.resume(agentId, { apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: process.cwd() }, @@ -100,7 +100,7 @@ Dashboard-configured servers do persist (they're keyed to the user, not the agen By default, local SDK agents do **not** load ambient user/team/project MCP config. Opt in via `settingSources`: ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey, model: { id: "composer-2" }, local: { diff --git a/cursor-sdk/skills/cursor-sdk/references/patterns.md b/cursor-sdk/skills/cursor-sdk/references/patterns.md index 484b153d3..aabc39f02 100644 --- a/cursor-sdk/skills/cursor-sdk/references/patterns.md +++ b/cursor-sdk/skills/cursor-sdk/references/patterns.md @@ -28,7 +28,7 @@ async function main() { process.exit(1); } - await using agent = Agent.create({ + await using agent = await Agent.create({ apiKey: CURSOR_API_KEY, model: { id: "composer-2" }, cloud: { @@ -66,13 +66,15 @@ for concrete issues. No praise-only comments. If nothing to flag, say so.`; const result = await run.wait(); if (result.status !== "finished") { console.error(`[review] run ${result.id} ended as ${result.status}`); - process.exit(2); + process.exitCode = 2; + return; } console.log(`[review] done: ${result.durationMs}ms`); } catch (err) { if (err instanceof CursorAgentError) { console.error(`[review] startup failed: ${err.message}`); - process.exit(err.isRetryable ? 75 : 1); // EX_TEMPFAIL for transient + process.exitCode = err.isRetryable ? 75 : 1; // EX_TEMPFAIL for transient + return; } throw err; } @@ -85,6 +87,7 @@ Why this shape: - Cloud runtime (needs to post GitHub comments; works independently of the runner). - `skipReviewerRequest: true` keeps the action quiet in CI. +- Set `process.exitCode` and return so `await using` finishes asynchronous cleanup before the process exits. - Exit codes: `0` finished, `1` permanent startup failure, `2` run finished with status `error`, `75` transient retryable failure. --- @@ -95,7 +98,8 @@ Goal: every morning, resume yesterday's triage agent and ask it to triage today' ```typescript import { Agent, CursorAgentError } from "@cursor/sdk"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, rename, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; const STATE_PATH = "/var/lib/triage/state.json"; @@ -111,13 +115,13 @@ async function main() { const state = await readState(); await using agent = state.agentId - ? Agent.resume(state.agentId, { + ? await Agent.resume(state.agentId, { apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, cloud: { repos: [{ url: process.env.REPO_URL!, startingRef: "main" }] }, mcpServers, // must re-pass on resume }) - : Agent.create({ + : await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, cloud: { repos: [{ url: process.env.REPO_URL!, startingRef: "main" }] }, @@ -146,10 +150,19 @@ async function main() { async function readState(): Promise<{ agentId?: string; lastRunId?: string }> { try { return JSON.parse(await readFile(STATE_PATH, "utf-8")); } - catch { return {}; } + catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; // damaged or unreadable state must not silently create a new agent + } } async function writeState(s: { agentId: string; lastRunId: string }) { - await writeFile(STATE_PATH, JSON.stringify(s)); + const temporary = `${STATE_PATH}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify(s)); + await rename(temporary, STATE_PATH); // replace only a completely written state file + } finally { + await rm(temporary, { force: true }).catch(() => {}); + } } main(); @@ -223,8 +236,8 @@ app.post("/agents/:userId/send", async (req, res) => { try { await using agent = existing - ? Agent.resume(existing, options) - : Agent.create(options); + ? await Agent.resume(existing, options) + : await Agent.create(options); const run = await agent.send(prompt); const result = await run.wait(); @@ -271,7 +284,7 @@ Goal: spin off a cloud agent per repo in a list, collect results. import { Agent, CursorAgentError } from "@cursor/sdk"; async function dispatchOne(repoUrl: string, apiKey: string, prompt: string) { - await using agent = Agent.create({ + await using agent = await Agent.create({ apiKey, model: { id: "composer-2" }, cloud: { @@ -312,14 +325,14 @@ Why this shape: - Per-agent `await using` so each completes cleanup even when others are still running. - `autoCreatePR: false` because we want to review results first; run a follow-up to open PRs once you've picked which ones pass the audit. -**Rate-limit awareness**: fanning out 100 agents at once will hit backend limits. For large N, batch: +**Rate-limit awareness**: fanning out 100 agents at once will hit backend limits. For large N, batch while retaining the same settled outcomes, in input order, so one failed repo does not skip later batches: ```typescript async function runInBatches(items: T[], size: number, fn: (item: T) => Promise) { - const results: R[] = []; + const results: PromiseSettledResult[] = []; for (let i = 0; i < items.length; i += size) { const batch = items.slice(i, i + size); - results.push(...await Promise.all(batch.map(fn))); + results.push(...await Promise.allSettled(batch.map(fn))); } return results; } diff --git a/cursor-sdk/skills/cursor-sdk/references/runtime-choice.md b/cursor-sdk/skills/cursor-sdk/references/runtime-choice.md index c6117d47b..e4e11c982 100644 --- a/cursor-sdk/skills/cursor-sdk/references/runtime-choice.md +++ b/cursor-sdk/skills/cursor-sdk/references/runtime-choice.md @@ -34,7 +34,7 @@ If two points pull opposite ways, Cloud is usually the safer pick for production ## Local Runtime — How It Actually Works ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, local: { cwd: "/absolute/or/relative/path/to/repo" }, @@ -56,7 +56,7 @@ When to prefer local: ## Cloud Runtime — How It Actually Works ```typescript -const agent = Agent.create({ +const agent = await Agent.create({ apiKey: process.env.CURSOR_API_KEY!, model: { id: "composer-2" }, cloud: { @@ -93,8 +93,8 @@ When to prefer cloud: Perfectly fine, common pattern (e.g., local inspection + cloud PR). Each agent is independent; dispose both. ```typescript -const localAgent = Agent.create({ /* ... local */ }); -const cloudAgent = Agent.create({ /* ... cloud */ }); +const localAgent = await Agent.create({ /* ... local */ }); +const cloudAgent = await Agent.create({ /* ... cloud */ }); try { const summary = await Agent.prompt("Summarize the diff on HEAD", { apiKey, diff --git a/cursor-sdk/skills/cursor-sdk/references/streaming.md b/cursor-sdk/skills/cursor-sdk/references/streaming.md index b9f2eee05..7bf93ce87 100644 --- a/cursor-sdk/skills/cursor-sdk/references/streaming.md +++ b/cursor-sdk/skills/cursor-sdk/references/streaming.md @@ -156,13 +156,27 @@ Prefer `run.stream()` for most consumers. Reach for `onDelta` / `onStep` only wh ## Cancellation ```typescript +let cancelTimer: ReturnType | undefined; if (run.supports("cancel")) { - setTimeout(() => run.cancel(), 30_000); + cancelTimer = setTimeout(() => { + void run.cancel().catch(err => console.error("Cancellation failed:", err)); + }, 30_000); +} + +try { + for await (const event of run.stream()) { + // Handle each event as in the canonical consumer above, including after cancel. + } + const result = await run.wait(); +} finally { + if (cancelTimer !== undefined) clearTimeout(cancelTimer); } ``` `run.cancel()` is supported on both local and cloud runs. For cloud it POSTs to the server's cancel endpoint and reconciles local status from the server's authoritative response. Guard with `run.supports("cancel")` anyway — detached/replayed run handles (`Agent.getRun(...)`) may not have a live cancellation channel, and the guard is the right defensive posture. +Clear the deadline when consumption finishes or fails, so a completed run does not keep the process alive or receive a late cancel. Handle the cancellation promise inside the timer callback so cancellation failures do not become unhandled rejections. + After cancel, continue consuming the stream until it ends; you'll see a terminal `status` event and `run.wait()` will resolve with `status: "cancelled"`. ## Status Listener (stream-free observation) diff --git a/cursor-sdk/tests/agent-handles.test.cjs b/cursor-sdk/tests/agent-handles.test.cjs new file mode 100644 index 000000000..b9e3b483e --- /dev/null +++ b/cursor-sdk/tests/agent-handles.test.cjs @@ -0,0 +1,41 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { stripTypeScriptTypes } = require('node:module'); +const test = require('node:test'); +const vm = require('node:vm'); + +const root = path.join(__dirname, '../skills/cursor-sdk'); +const files = ['SKILL.md', ...fs.readdirSync(path.join(root, 'references')).filter((f) => f.endsWith('.md')).map((f) => `references/${f}`)]; +for (const file of files) { + const markdown = fs.readFileSync(path.join(root, file), 'utf8'); + let index = 0; + for (const block of markdown.matchAll(/```typescript\n([\s\S]*?)\n```/g)) { + for (const declaration of block[1].matchAll(/(?:const|await using)\s+(\w+)\s*=[\s\S]*?;(?=[ \t]*(?:\/\/[^\n]*)?$)/gm)) { + if (!/Agent\.(?:create|resume)\(/.test(declaration[0])) continue; + const name = declaration[1], source = declaration[0]; + for (const resume of /\?\s*Agent\.resume/.test(source) || /\?\s*await Agent\.resume/.test(source) ? [false, true] : [false]) { + test(`${file} declaration ${++index}: resolved agent handle (${resume ? 'resume' : 'create'})`, async () => { + let disposed = 0, sends = 0; + const handle = { agentId: 'offline-agent', async send() { sends++; return { id: 'offline-run' }; }, + async [Symbol.asyncDispose]() { disposed++; } }; + // SDK 1.0.12 public declarations: create/resume return Promise. + const context = vm.createContext({ + Agent: { async create() { return handle; }, async resume() { return handle; } }, + process: { env: {}, cwd: () => '/offline' }, + previousAgentId: 'offline-agent', agentId: resume ? 'offline-agent' : undefined, + existing: resume ? 'offline-agent' : undefined, state: { agentId: resume ? 'offline-agent' : undefined }, + apiKey: 'offline', userKey: 'offline', botKey: 'offline', CURSOR_API_KEY: 'offline', + REPO_URL: 'https://example.com/offline', HEAD_REF: 'main', GITHUB_TOKEN: undefined, + repoUrl: 'https://example.com/offline', mcpServers: {}, baseMcp: {}, sameInlineMcpYouUsedBefore: {}, options: {}, + }); + const js = stripTypeScriptTypes(`${source}\nassertHandle(${name}); await ${name}.send('offline');`); + context.assertHandle = (agent) => assert.equal(agent.agentId, 'offline-agent'); + await vm.runInContext(`(async () => { ${js} })()`, context); + assert.equal(sends, 1); + assert.equal(disposed, source.startsWith('await using') ? 1 : 0); + }); + } + } + } +} diff --git a/cursor-sdk/tests/batch-settled.test.cjs b/cursor-sdk/tests/batch-settled.test.cjs new file mode 100644 index 000000000..4c485e980 --- /dev/null +++ b/cursor-sdk/tests/batch-settled.test.cjs @@ -0,0 +1,52 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { stripTypeScriptTypes } = require('node:module'); +const test = require('node:test'); +const vm = require('node:vm'); +function helper() { + const markdown = fs.readFileSync(path.join(__dirname, '../skills/cursor-sdk/references/patterns.md'), 'utf8'); + const snippet = markdown.match(/```typescript\n(async function runInBatches[\s\S]*?)\n```/)[1]; + const context = vm.createContext({}); + vm.runInContext(stripTypeScriptTypes(snippet), context); + return context.runInBatches; +} + +test('one rejected repo preserves its reason and does not prevent later batches', async () => { + const seen = [], error = new Error('one repo failed'); + const results = await helper()([0, 1, 2, 3, 4], 2, async (item) => { + seen.push(item); if (item === 1) throw error; return item * 10; + }); + assert.deepEqual(seen, [0, 1, 2, 3, 4]); + assert.equal(results.length, 5); + assert.equal(results[1].status, 'rejected'); assert.equal(results[1].reason, error); + assert.equal(results[4].status, 'fulfilled'); assert.equal(results[4].value, 40); +}); + +test('successful batches expose settled outcomes consistently with the unbatched example', async () => { + const results = await helper()([1, 2, 3], 2, async (item) => item * 2); + assert.deepEqual(Array.from(results, (result) => [result.status, result.value]), + [['fulfilled', 2], ['fulfilled', 4], ['fulfilled', 6]]); +}); + +test('input order is retained when tasks finish out of order', async () => { + let release; + const first = new Promise((resolve) => { release = resolve; }); + const pending = helper()([0, 1], 2, async (item) => item === 0 ? first : 'second'); + release('first'); const results = await pending; + assert.deepEqual(Array.from(results, (result) => result.value), ['first', 'second']); +}); + +test('empty input returns no results and launches no work', async () => { + let calls = 0; const results = await helper()([], 2, async () => calls++); + assert.equal(results.length, 0); assert.equal(calls, 0); +}); + +test('batching still caps simultaneous work at the requested size', async () => { + let active = 0, maxActive = 0; + await helper()([0, 1, 2, 3, 4], 2, async (item) => { + active++; maxActive = Math.max(maxActive, active); + await Promise.resolve(); active--; return item; + }); + assert.equal(maxActive, 2); assert.equal(active, 0); +}); diff --git a/cursor-sdk/tests/cancellation-timer.test.cjs b/cursor-sdk/tests/cancellation-timer.test.cjs new file mode 100644 index 000000000..33b1b8bba --- /dev/null +++ b/cursor-sdk/tests/cancellation-timer.test.cjs @@ -0,0 +1,66 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { stripTypeScriptTypes } = require('node:module'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); +const vm = require('node:vm'); +function snippet() { + const markdown = fs.readFileSync(path.join(__dirname, '../skills/cursor-sdk/references/streaming.md'), 'utf8'); + return stripTypeScriptTypes(markdown.split('## Cancellation')[1].match(/```typescript\n([\s\S]*?)\n```/)[1]); +} +function setup({ wait = async () => ({}), streamError, cancelError, supports = true } = {}) { + const timers = [], errors = []; let cancels = 0, drained = 0; + const run = { supports: () => supports, + async cancel() { cancels++; if (cancelError) throw cancelError; }, + async *stream() { if (streamError) throw streamError; drained++; }, wait, + }; + const context = vm.createContext({ run, console: { error: (...args) => errors.push(args) }, + setTimeout(fn, delay) { const timer = { fn, delay, active: true }; timers.push(timer); return timer; }, + clearTimeout(timer) { if (timer) timer.active = false; }, + }); + const pending = vm.runInContext(`(async () => { ${snippet()} })()`, context); + return { pending, timers, errors, cancels: () => cancels, drained: () => drained }; +} + +test('completed runs clear the timer after consuming the stream and waiting', async () => { + const s = setup(); await s.pending; + assert.equal(s.timers.filter((timer) => timer.active).length, 0); + assert.equal(s.drained(), 1); assert.equal(s.cancels(), 0); +}); + +test('wait rejection also clears the timer and preserves the failure', async () => { + const error = new Error('wait failed'); const s = setup({ wait: async () => { throw error; } }); + await assert.rejects(s.pending, (actual) => actual === error); + assert.equal(s.timers.filter((timer) => timer.active).length, 0); +}); + +test('stream failure also clears the timer and preserves the failure', async () => { + const error = new Error('stream failed'); const s = setup({ streamError: error }); + await assert.rejects(s.pending, (actual) => actual === error); + assert.equal(s.timers.filter((timer) => timer.active).length, 0); +}); + +test('the deadline still cancels an active run and reports cancellation rejection', async () => { + let finish; const wait = new Promise((resolve) => { finish = resolve; }); + const error = new Error('cancel endpoint unavailable'); const s = setup({ wait: () => wait, cancelError: error }); + const result = s.timers[0].fn(); + // Contain the old snippet's returned rejected promise in this regression probe. + if (result?.catch) await result.catch(() => {}); + await Promise.resolve(); await Promise.resolve(); + assert.equal(s.cancels(), 1); assert.equal(s.errors.length, 1); + assert.ok(s.errors[0].includes(error)); + finish({}); await s.pending; +}); + +test('unsupported cancellation does not schedule a deadline', async () => { + const s = setup({ supports: false }); await s.pending; + assert.equal(s.timers.length, 0); assert.equal(s.cancels(), 0); +}); + +test('a completed run lets a real Node child exit without waiting thirty seconds', () => { + const code = `const run = { supports: () => true, cancel: async () => {}, stream: async function*(){}, wait: async () => ({}) }; (async () => { ${snippet()} })();`; + const child = spawnSync(process.execPath, ['-e', code], { timeout: 1500, encoding: 'utf8' }); + assert.equal(child.error, undefined, 'completed example left a live thirty-second timer'); + assert.equal(child.status, 0, child.stderr); +}); diff --git a/cursor-sdk/tests/review-disposal.test.ts b/cursor-sdk/tests/review-disposal.test.ts new file mode 100644 index 000000000..bf5508909 --- /dev/null +++ b/cursor-sdk/tests/review-disposal.test.ts @@ -0,0 +1,59 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const patterns = readFileSync(new URL("../skills/cursor-sdk/references/patterns.md", import.meta.url), "utf8"); +const example = patterns.match(/```typescript\n([\s\S]*?)\n```/)![1]; + +for (const [scenario, exitCode] of [ + ["finished", 0], ["error", 2], ["cancelled", 2], + ["retryable", 75], ["permanent", 1], ["unexpected", 1], +] as const) { + test(`review ${scenario} exits ${exitCode} after disposing its handle`, () => { + const directory = mkdtempSync(join(tmpdir(), "sdk-review-disposal-")); + try { + const sentinel = join(directory, "disposed"); + writeFileSync(join(directory, "sdk.ts"), ` +import { writeFile } from "node:fs/promises"; +export class CursorAgentError extends Error { + isRetryable = process.env.SCENARIO === "retryable"; +} +export const Agent = { + create() { + return { + agentId: "fixture-agent", + async [Symbol.asyncDispose]() { + await new Promise(resolve => setTimeout(resolve, 10)); + await writeFile(process.env.SENTINEL!, "disposed"); + }, + async send() { + const scenario = process.env.SCENARIO; + if (scenario === "unexpected") throw new Error("fixture unexpected"); + if (scenario === "retryable" || scenario === "permanent") throw new CursorAgentError("fixture startup"); + return { + id: "fixture-run", + async *stream() {}, + async wait() { return { id: "fixture-run", status: scenario, durationMs: 1 }; }, + }; + }, + }; + }, +}; +`); + writeFileSync(join(directory, "review.ts"), example.replace('"@cursor/sdk"', '"./sdk.ts"')); + const child = Bun.spawnSync([process.execPath, join(directory, "review.ts")], { + env: { ...process.env, SCENARIO: scenario, SENTINEL: sentinel, + CURSOR_API_KEY: "fixture", REPO_URL: "fixture", HEAD_REF: "fixture" }, + }); + expect(child.exitCode).toBe(exitCode); + expect(existsSync(sentinel)).toBe(true); + expect(readFileSync(sentinel, "utf8")).toBe("disposed"); + if (scenario === "error" || scenario === "cancelled") { + expect(child.stdout.toString()).not.toContain("[review] done:"); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +} diff --git a/cursor-sdk/tests/send-retry.test.cjs b/cursor-sdk/tests/send-retry.test.cjs new file mode 100644 index 000000000..426fe1f37 --- /dev/null +++ b/cursor-sdk/tests/send-retry.test.cjs @@ -0,0 +1,73 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { stripTypeScriptTypes } = require('node:module'); +const test = require('node:test'); +const vm = require('node:vm'); +// Offline stand-ins for the documented SDK class hierarchy; no SDK/native runtime needed. +class CursorAgentError extends Error { + constructor(message, { isRetryable = false } = {}) { super(message); this.isRetryable = isRetryable; } +} +class NetworkError extends CursorAgentError {} +class RateLimitError extends CursorAgentError {} +class AuthenticationError extends CursorAgentError {} + +function setup(errors) { + const reference = fs.readFileSync(path.join(__dirname, '../skills/cursor-sdk/references/error-handling.md'), 'utf8'); + const snippet = reference.split('## Retry Patterns')[1].match(/```typescript\n([\s\S]*?)\n```/)[1]; + const delays = [], messages = [], options = []; + const run = { id: 'offline-run', wait() { throw new Error('wait must remain outside startup retries'); } }; + let calls = 0; + const agent = { async send(message, sendOptions) { + messages.push(message); options.push(sendOptions); + const error = errors[calls++]; if (error) throw error; + return run; + } }; + const context = vm.createContext({ + Agent: { async create() { return agent; } }, CursorAgentError, NetworkError, RateLimitError, + Math: { random: () => 0 }, setTimeout(resolve, delay) { delays.push(delay); resolve(); }, + }); + vm.runInContext(stripTypeScriptTypes(snippet.replace(/^import .*;\n/gm, '')), context); + const sendOptions = { model: 'offline-model' }; + // Follow each documented helper's public contract; old helper creates then caller sends. + const send = () => context.sendWithRetry ? context.sendWithRetry(agent, 'prompt', sendOptions) + : context.createWithRetry({}).then((created) => created.send('prompt', sendOptions)); + return { send, run, delays, messages, options, sendOptions, calls: () => calls }; +} + +test('retryable send startup errors retry the same message and return the run', async () => { + const s = setup([new NetworkError('temporary', { isRetryable: true })]); + assert.equal(await s.send(), s.run); + assert.deepEqual(s.messages, ['prompt', 'prompt']); + assert.ok(s.options.every((options) => options === s.sendOptions)); + assert.deepEqual(s.delays, [2000]); +}); + +test('retryable rate limiting waits at least thirty seconds', async () => { + const s = setup([new RateLimitError('slow down', { isRetryable: true })]); + assert.equal(await s.send(), s.run); + assert.equal(s.delays.length, 1); + assert.ok(s.delays[0] >= 30_000); +}); + +test('retries stop at three attempts and preserve the last error', async () => { + const error = new NetworkError('still unavailable', { isRetryable: true }); + const s = setup([error, error, error]); + await assert.rejects(s.send(), (actual) => actual === error); + assert.equal(s.calls(), 3); + assert.deepEqual(s.delays, [2000, 4000]); +}); + +for (const error of [new AuthenticationError('bad key', { isRetryable: true }), + new NetworkError('terminal', { isRetryable: false }), new Error('application error')]) { + test(`${error.constructor.name}/${error.message} is not retried`, async () => { + const s = setup([error]); + await assert.rejects(s.send(), (actual) => actual === error); + assert.equal(s.calls(), 1); assert.deepEqual(s.delays, []); + }); +} + +test('first-attempt success returns the run without waiting or retrying', async () => { + const s = setup([]); assert.equal(await s.send(), s.run); + assert.equal(s.calls(), 1); assert.deepEqual(s.delays, []); +}); diff --git a/cursor-sdk/tests/triage-state.test.cjs b/cursor-sdk/tests/triage-state.test.cjs new file mode 100644 index 000000000..a3efa6160 --- /dev/null +++ b/cursor-sdk/tests/triage-state.test.cjs @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { randomUUID } = require('node:crypto'); +const { stripTypeScriptTypes } = require('node:module'); +const vm = require('node:vm'); +const test = require('node:test'); + +async function fixture(t, overrides = {}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sdk-triage-state-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const statePath = path.join(dir, 'state.json'); + const doc = await fs.readFile(path.join(__dirname, '../skills/cursor-sdk/references/patterns.md'), 'utf8'); + const start = doc.indexOf('async function readState()'); + const source = doc.slice(start, doc.indexOf('\nmain();', start)); + const ctx = vm.createContext({ STATE_PATH: statePath, randomUUID, readFile: fs.readFile, writeFile: fs.writeFile, rename: fs.rename, rm: fs.rm, ...overrides }); + vm.runInContext(stripTypeScriptTypes(source), ctx); + return { ctx, statePath, dir }; +} + +test('first invocation with no state starts empty', async t => { + const s = await fixture(t); + assert.deepEqual(JSON.parse(JSON.stringify(await s.ctx.readState())), {}); +}); + +test('corrupted persisted agent identity is surfaced instead of silently forgotten', async t => { + const s = await fixture(t); await fs.writeFile(s.statePath, '{"agentId":'); + await assert.rejects(s.ctx.readState()); +}); + +test('partial write failure preserves the last durable agent identity', async t => { + const failure = Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + const s = await fixture(t, { writeFile: async (p, content) => { await fs.writeFile(p, content.slice(0, 5)); throw failure; } }); + const before = '{"agentId":"previous","lastRunId":"run-old"}'; + await fs.writeFile(s.statePath, before); + await assert.rejects(s.ctx.writeState({ agentId: 'next', lastRunId: 'run-new' }), error => error === failure); + assert.equal(await fs.readFile(s.statePath, 'utf8'), before); + assert.deepEqual(await fs.readdir(s.dir), ['state.json']); +}); + +test('successful persistence can be read on the next invocation', async t => { + const s = await fixture(t); const next = { agentId: 'new', lastRunId: 'run-new' }; + await s.ctx.writeState(next); + assert.deepEqual(JSON.parse(JSON.stringify(await s.ctx.readState())), next); + assert.deepEqual(await fs.readdir(s.dir), ['state.json']); +});