diff --git a/.changeset/short-moles-punch.md b/.changeset/short-moles-punch.md new file mode 100644 index 000000000..a7a2b2261 --- /dev/null +++ b/.changeset/short-moles-punch.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add @cursor/sdk instrumentation diff --git a/.env.example b/.env.example index c9c314bb3..bff0c17ab 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ BRAINTRUST_API_KEY= OPENAI_API_KEY= ANTHROPIC_API_KEY= GEMINI_API_KEY= +CURSOR_API_KEY= OPENROUTER_API_KEY= MISTRAL_API_KEY= HUGGINGFACE_API_KEY= diff --git a/.github/workflows/e2e-canary.yaml b/.github/workflows/e2e-canary.yaml index 12a287004..728c4f069 100644 --- a/.github/workflows/e2e-canary.yaml +++ b/.github/workflows/e2e-canary.yaml @@ -35,6 +35,7 @@ jobs: BRAINTRUST_E2E_PROJECT_NAME: ${{ vars.BRAINTRUST_E2E_PROJECT_NAME }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 7b7b88ef3..5e5cfcbbe 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -29,6 +29,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} HUGGINGFACE_API_KEY: ${{ secrets.HUGGINGFACE_API_KEY }} @@ -58,6 +59,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} @@ -110,6 +112,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} diff --git a/e2e/README.md b/e2e/README.md index b4a15d56e..3ed3d4b1b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -115,6 +115,7 @@ Non-hermetic scenarios require provider credentials in addition to the mock Brai - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` - `GEMINI_API_KEY` or `GOOGLE_API_KEY` +- `CURSOR_API_KEY` - `OPENROUTER_API_KEY` - `MISTRAL_API_KEY` - `HUGGINGFACE_API_KEY` diff --git a/e2e/config/pr-comment-scenarios.json b/e2e/config/pr-comment-scenarios.json index bee0040c5..0108b9e78 100644 --- a/e2e/config/pr-comment-scenarios.json +++ b/e2e/config/pr-comment-scenarios.json @@ -133,5 +133,11 @@ "label": "v0.2.81" } ] + }, + { + "scenarioDirName": "cursor-sdk-instrumentation", + "label": "Cursor SDK Instrumentation", + "metadataScenario": "cursor-sdk-instrumentation", + "variants": [{ "variantKey": "cursor-sdk-v1", "label": "v1" }] } ] diff --git a/e2e/helpers/scenario-installer.ts b/e2e/helpers/scenario-installer.ts index 29318aeff..9eb7b19c2 100644 --- a/e2e/helpers/scenario-installer.ts +++ b/e2e/helpers/scenario-installer.ts @@ -22,6 +22,7 @@ const INSTALL_SECRET_ENV_VARS = [ "ANTHROPIC_API_KEY", "BRAINTRUST_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GEMINI_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", @@ -37,10 +38,9 @@ let cleanupRegistered = false; type CanaryDependencyRule = { packageName: string; - query: string; + version: string; }; -const canaryVersionCache = new Map(); const HELPERS_DIR = path.dirname(fileURLToPath(import.meta.url)); const E2E_ROOT = path.resolve(HELPERS_DIR, ".."); @@ -155,32 +155,6 @@ function packageSpecifier( : `npm:${packageName}@${version}`; } -async function resolveCanaryVersion( - rule: CanaryDependencyRule, -): Promise { - const cacheKey = rule.query; - const cached = canaryVersionCache.get(cacheKey); - if (cached) { - return cached; - } - - const output = await spawnOrThrow( - PNPM_COMMAND, - ["view", rule.query, "version", "--json"], - process.cwd(), - installEnv(), - ); - const parsed = JSON.parse(output) as string | string[]; - const version = Array.isArray(parsed) ? parsed.at(-1) : parsed; - - if (typeof version !== "string") { - throw new Error(`Could not resolve canary version for ${rule.query}`); - } - - canaryVersionCache.set(cacheKey, version); - return version; -} - function parseCanaryDependencyRule( dependencyName: string, rawRule: string, @@ -195,7 +169,7 @@ function parseCanaryDependencyRule( if (rawRule === "latest") { return { packageName: dependencyName, - query: dependencyName, + version: "latest", }; } @@ -208,7 +182,7 @@ function parseCanaryDependencyRule( return { packageName: rawRule.slice(0, versionSeparator), - query: rawRule, + version: rawRule.slice(versionSeparator + 1), }; } @@ -230,11 +204,10 @@ async function rewriteManifestForCanary(scenarioDir: string): Promise { rawRule, scenarioDir, ); - const version = await resolveCanaryVersion(rule); dependencies[dependencyName] = packageSpecifier( dependencyName, rule.packageName, - version, + rule.version, ); updated = true; } diff --git a/e2e/scenarios/cursor-sdk-instrumentation/__snapshots__/cursor-sdk-v1-auto-hook.span-events.json b/e2e/scenarios/cursor-sdk-instrumentation/__snapshots__/cursor-sdk-v1-auto-hook.span-events.json new file mode 100644 index 000000000..1308581f3 --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/__snapshots__/cursor-sdk-v1-auto-hook.span-events.json @@ -0,0 +1,234 @@ +{ + "conversation": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "resume-conversation" + }, + "metric_keys": [], + "name": "cursor-sdk-resume-conversation-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.agent_id": "", + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "agent.send", + "cursor_sdk.run_id": "", + "cursor_sdk.status": "finished", + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "duration" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + } + }, + "prompt": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "prompt" + }, + "metric_keys": [], + "name": "cursor-sdk-prompt-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "Agent.prompt", + "cursor_sdk.run_id": "", + "cursor_sdk.runtime": "local", + "cursor_sdk.status": "finished", + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "duration" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + } + }, + "root": { + "has_input": false, + "has_output": false, + "metadata": { + "scenario": "cursor-sdk-instrumentation" + }, + "metric_keys": [], + "name": "cursor-sdk-root", + "root_span_id": "", + "span_id": "", + "span_parents": [], + "type": "task" + }, + "stream": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "stream" + }, + "metric_keys": [], + "name": "cursor-sdk-stream-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "subagent_task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.tool.status": "completed", + "gen_ai.tool.name": "task" + }, + "metric_keys": [], + "name": "Agent: ", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + }, + "subagent_tool": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.tool.status": "completed", + "gen_ai.tool.name": "task" + }, + "metric_keys": [], + "name": "tool: task", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "tool" + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.agent_id": "", + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "agent.send", + "cursor_sdk.run_id": "", + "cursor_sdk.status": "finished", + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "duration" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + }, + "tool": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.tool.status": "completed", + "gen_ai.tool.name": "shell" + }, + "metric_keys": [], + "name": "tool: shell", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "tool" + } + }, + "wait": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "wait" + }, + "metric_keys": [], + "name": "cursor-sdk-wait-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.agent_id": "", + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "agent.send", + "cursor_sdk.run_id": "", + "cursor_sdk.status": "finished", + "cursor_sdk.step_types": [ + "assistantMessage" + ], + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "completion_tokens", + "cursor_sdk.delta_tokens", + "cursor_sdk.step_duration_ms", + "cursor_sdk.steps", + "duration", + "prompt_cache_creation_tokens", + "prompt_cached_tokens", + "prompt_tokens", + "tokens" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + } + } +} diff --git a/e2e/scenarios/cursor-sdk-instrumentation/__snapshots__/cursor-sdk-v1-wrapped.span-events.json b/e2e/scenarios/cursor-sdk-instrumentation/__snapshots__/cursor-sdk-v1-wrapped.span-events.json new file mode 100644 index 000000000..1308581f3 --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/__snapshots__/cursor-sdk-v1-wrapped.span-events.json @@ -0,0 +1,234 @@ +{ + "conversation": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "resume-conversation" + }, + "metric_keys": [], + "name": "cursor-sdk-resume-conversation-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.agent_id": "", + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "agent.send", + "cursor_sdk.run_id": "", + "cursor_sdk.status": "finished", + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "duration" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + } + }, + "prompt": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "prompt" + }, + "metric_keys": [], + "name": "cursor-sdk-prompt-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "Agent.prompt", + "cursor_sdk.run_id": "", + "cursor_sdk.runtime": "local", + "cursor_sdk.status": "finished", + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "duration" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + } + }, + "root": { + "has_input": false, + "has_output": false, + "metadata": { + "scenario": "cursor-sdk-instrumentation" + }, + "metric_keys": [], + "name": "cursor-sdk-root", + "root_span_id": "", + "span_id": "", + "span_parents": [], + "type": "task" + }, + "stream": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "stream" + }, + "metric_keys": [], + "name": "cursor-sdk-stream-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "subagent_task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.tool.status": "completed", + "gen_ai.tool.name": "task" + }, + "metric_keys": [], + "name": "Agent: ", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + }, + "subagent_tool": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.tool.status": "completed", + "gen_ai.tool.name": "task" + }, + "metric_keys": [], + "name": "tool: task", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "tool" + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.agent_id": "", + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "agent.send", + "cursor_sdk.run_id": "", + "cursor_sdk.status": "finished", + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "duration" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + }, + "tool": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.tool.status": "completed", + "gen_ai.tool.name": "shell" + }, + "metric_keys": [], + "name": "tool: shell", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "tool" + } + }, + "wait": { + "operation": { + "has_input": false, + "has_output": false, + "metadata": { + "operation": "wait" + }, + "metric_keys": [], + "name": "cursor-sdk-wait-operation", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": null + }, + "task": { + "has_input": true, + "has_output": true, + "metadata": { + "cursor_sdk.agent_id": "", + "cursor_sdk.model": "composer-2", + "cursor_sdk.operation": "agent.send", + "cursor_sdk.run_id": "", + "cursor_sdk.status": "finished", + "cursor_sdk.step_types": [ + "assistantMessage" + ], + "model": "composer-2", + "provider": "cursor" + }, + "metric_keys": [ + "completion_tokens", + "cursor_sdk.delta_tokens", + "cursor_sdk.step_duration_ms", + "cursor_sdk.steps", + "duration", + "prompt_cache_creation_tokens", + "prompt_cached_tokens", + "prompt_tokens", + "tokens" + ], + "name": "Cursor Agent", + "root_span_id": "", + "span_id": "", + "span_parents": [ + "" + ], + "type": "task" + } + } +} diff --git a/e2e/scenarios/cursor-sdk-instrumentation/assertions.ts b/e2e/scenarios/cursor-sdk-instrumentation/assertions.ts new file mode 100644 index 000000000..85016fb83 --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/assertions.ts @@ -0,0 +1,301 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { normalizeForSnapshot, type Json } from "../../helpers/normalize"; +import type { CapturedLogEvent } from "../../helpers/mock-braintrust-server"; +import { + formatJsonFileSnapshot, + resolveFileSnapshotPath, +} from "../../helpers/file-snapshot"; +import { withScenarioHarness } from "../../helpers/scenario-harness"; +import { + findAllSpans, + findChildSpans, + findLatestSpan, +} from "../../helpers/trace-selectors"; +import { summarizeWrapperContract } from "../../helpers/wrapper-contract"; +import { ROOT_NAME, SCENARIO_NAME } from "./scenario.impl.mjs"; + +type RunCursorSDKScenario = (harness: { + runNodeScenarioDir: (options: { + entry: string; + nodeArgs: string[]; + runContext?: { variantKey: string }; + scenarioDir: string; + timeoutMs: number; + }) => Promise; + runScenarioDir: (options: { + entry: string; + runContext?: { variantKey: string }; + scenarioDir: string; + timeoutMs: number; + }) => Promise; +}) => Promise; + +const METADATA_KEYS = [ + "provider", + "model", + "operation", + "scenario", + "gen_ai.tool.name", + "cursor_sdk.model", + "cursor_sdk.operation", + "cursor_sdk.agent_id", + "cursor_sdk.run_id", + "cursor_sdk.runtime", + "cursor_sdk.status", + "cursor_sdk.duration_ms", + "cursor_sdk.step_types", + "cursor_sdk.tool.status", +] as const; + +function summarizeSpan(event: CapturedLogEvent | undefined): Json { + if (!event) { + return null; + } + const summary = summarizeWrapperContract(event, [...METADATA_KEYS]) as Record< + string, + Json + >; + if (summary.metadata && typeof summary.metadata === "object") { + const metadata = summary.metadata as Record; + if (typeof metadata["cursor_sdk.agent_id"] === "string") { + metadata["cursor_sdk.agent_id"] = ""; + } + if (typeof metadata["cursor_sdk.run_id"] === "string") { + metadata["cursor_sdk.run_id"] = ""; + } + if (typeof metadata["cursor_sdk.duration_ms"] === "number") { + metadata["cursor_sdk.duration_ms"] = 1; + } + } + if (typeof event.row.error === "string") { + summary.error = event.row.error; + } + if (typeof summary.name === "string" && summary.name.startsWith("Agent:")) { + summary.name = "Agent: "; + } + return summary; +} + +function findOperation(events: CapturedLogEvent[], name: string) { + return findLatestSpan(events, name); +} + +function findCursorTask(events: CapturedLogEvent[], operationName: string) { + const operation = findOperation(events, operationName); + return findChildSpans(events, "Cursor Agent", operation?.span.id).at(-1); +} + +function findSubagentTool( + events: CapturedLogEvent[], + parentId: string | undefined, +) { + if (!parentId) { + return undefined; + } + return [...events] + .reverse() + .find( + (event) => + event.span.type === "tool" && + event.span.parentIds.includes(parentId) && + ["tool: Agent", "tool: Task", "tool: task"].includes( + event.span.name ?? "", + ), + ); +} + +function findSubagentTask( + events: CapturedLogEvent[], + parentId: string | undefined, +) { + if (!parentId) { + return undefined; + } + return [...events] + .reverse() + .find( + (event) => + event.span.type === "task" && + event.span.parentIds.includes(parentId) && + event.span.name?.startsWith("Agent:"), + ); +} + +function outputText(event: CapturedLogEvent | undefined): string { + return typeof event?.output === "string" ? event.output : ""; +} + +function summarize(events: CapturedLogEvent[]): Json { + const promptTask = findCursorTask(events, "cursor-sdk-prompt-operation"); + const streamTask = findCursorTask(events, "cursor-sdk-stream-operation"); + const waitTask = findCursorTask(events, "cursor-sdk-wait-operation"); + const conversationTask = findCursorTask( + events, + "cursor-sdk-resume-conversation-operation", + ); + const tool = findAllSpans(events, "tool: shell").at(-1); + const subagentTool = findSubagentTool(events, streamTask?.span.id); + const subagentTask = findSubagentTask(events, subagentTool?.span.id); + + return normalizeForSnapshot({ + conversation: { + operation: summarizeSpan( + findOperation(events, "cursor-sdk-resume-conversation-operation"), + ), + task: summarizeSpan(conversationTask), + }, + prompt: { + operation: summarizeSpan( + findOperation(events, "cursor-sdk-prompt-operation"), + ), + task: summarizeSpan(promptTask), + }, + root: summarizeSpan(findLatestSpan(events, ROOT_NAME)), + stream: { + operation: summarizeSpan( + findOperation(events, "cursor-sdk-stream-operation"), + ), + subagent_task: summarizeSpan(subagentTask), + subagent_tool: summarizeSpan(subagentTool), + task: summarizeSpan(streamTask), + tool: summarizeSpan(tool), + }, + wait: { + operation: summarizeSpan( + findOperation(events, "cursor-sdk-wait-operation"), + ), + task: summarizeSpan(waitTask), + }, + } as Json); +} + +export function defineCursorSDKInstrumentationAssertions(options: { + name: string; + runScenario: RunCursorSDKScenario; + snapshotName: string; + testFileUrl: string; + timeoutMs: number; +}): void { + const snapshotPath = resolveFileSnapshotPath( + options.testFileUrl, + `${options.snapshotName}.span-events.json`, + ); + const testConfig = { timeout: options.timeoutMs }; + + describe(options.name, () => { + let events: CapturedLogEvent[] = []; + + beforeAll(async () => { + await withScenarioHarness(async (harness) => { + await options.runScenario(harness); + events = harness.events(); + }); + }, options.timeoutMs); + + test("captures the root trace", testConfig, () => { + const root = findLatestSpan(events, ROOT_NAME); + + expect(root).toBeDefined(); + expect(root?.row.metadata).toMatchObject({ scenario: SCENARIO_NAME }); + }); + + test( + "captures Cursor Agent task spans for run-producing APIs", + testConfig, + () => { + for (const operationName of [ + "cursor-sdk-prompt-operation", + "cursor-sdk-stream-operation", + "cursor-sdk-wait-operation", + "cursor-sdk-resume-conversation-operation", + ]) { + const operation = findOperation(events, operationName); + const task = findCursorTask(events, operationName); + + expect(operation).toBeDefined(); + expect(task).toBeDefined(); + expect(task?.span.parentIds).toEqual([operation?.span.id ?? ""]); + expect(task?.row.metadata).toMatchObject({ + provider: "cursor", + }); + } + }, + ); + + test( + "captures tool spans when Cursor surfaces tool calls", + testConfig, + () => { + const streamTask = findCursorTask( + events, + "cursor-sdk-stream-operation", + ); + const toolSpans = events.filter( + (event) => + event.span.type === "tool" && + event.span.parentIds.includes(streamTask?.span.id ?? ""), + ); + + expect(toolSpans.length).toBeGreaterThan(0); + expect( + toolSpans.some( + (event) => + event.input !== undefined && + event.output !== undefined && + event.metadata?.["cursor_sdk.tool.status"] === "completed", + ), + ).toBe(true); + expect( + JSON.stringify(toolSpans.map((event) => event.output)), + ).toContain("cursor_tool_ok"); + }, + ); + + test("captures subagent spans when Cursor uses agents", testConfig, () => { + const streamTask = findCursorTask(events, "cursor-sdk-stream-operation"); + const subagentTool = findSubagentTool(events, streamTask?.span.id); + const subagentTask = findSubagentTask(events, subagentTool?.span.id); + + expect(subagentTool).toBeDefined(); + expect(subagentTool?.metadata).toMatchObject({ + "cursor_sdk.tool.status": "completed", + }); + expect(subagentTask).toBeDefined(); + expect(subagentTask?.span.rootId).toBe(streamTask?.span.rootId); + expect(subagentTask?.metadata).toMatchObject({ + "cursor_sdk.tool.status": "completed", + }); + expect(subagentTask?.output).toBeDefined(); + }); + + test("preserves user onDelta/onStep callbacks", testConfig, () => { + expect(findLatestSpan(events, "cursor-sdk-user-on-delta")).toBeDefined(); + expect(findLatestSpan(events, "cursor-sdk-user-on-step")).toBeDefined(); + + const waitTask = findCursorTask(events, "cursor-sdk-wait-operation"); + expect(waitTask?.metrics).toMatchObject({ + completion_tokens: expect.any(Number), + prompt_tokens: expect.any(Number), + }); + expect(waitTask?.metrics?.["cursor_sdk.step_duration_ms"]).toEqual( + expect.any(Number), + ); + expect(outputText(waitTask)).toContain("CURSOR_WAIT_OK"); + }); + + test("captures conversation output text", testConfig, () => { + const conversationTask = findCursorTask( + events, + "cursor-sdk-resume-conversation-operation", + ); + + expect(outputText(conversationTask)).toContain("CURSOR_CONVERSATION_OK"); + }); + + test("matches the shared span snapshot", testConfig, async () => { + await expect( + formatJsonFileSnapshot(summarize(events)), + ).toMatchFileSnapshot(snapshotPath); + }); + }); +} diff --git a/e2e/scenarios/cursor-sdk-instrumentation/package.json b/e2e/scenarios/cursor-sdk-instrumentation/package.json new file mode 100644 index 000000000..44193c30f --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/package.json @@ -0,0 +1,19 @@ +{ + "name": "@braintrust/e2e-cursor-sdk-instrumentation", + "private": true, + "braintrustScenario": { + "canary": { + "dependencies": { + "cursor-sdk-v1": "@cursor/sdk@latest" + } + } + }, + "dependencies": { + "cursor-sdk-v1": "npm:@cursor/sdk@1.0.10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "sqlite3" + ] + } +} diff --git a/e2e/scenarios/cursor-sdk-instrumentation/pnpm-lock.yaml b/e2e/scenarios/cursor-sdk-instrumentation/pnpm-lock.yaml new file mode 100644 index 000000000..2917de861 --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/pnpm-lock.yaml @@ -0,0 +1,1159 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + cursor-sdk-v1: + specifier: npm:@cursor/sdk@1.0.10 + version: '@cursor/sdk@1.0.10' + +packages: + + '@bufbuild/protobuf@1.10.0': + resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} + + '@connectrpc/connect-node@1.7.0': + resolution: {integrity: sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + '@connectrpc/connect': 1.7.0 + + '@connectrpc/connect@1.7.0': + resolution: {integrity: sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + + '@cursor/sdk-darwin-arm64@1.0.10': + resolution: {integrity: sha512-uwNhyH2fyJhiSHhgWlozeuelBMyjotVN7jmqrPxaBR2Qii4JYmuhlNvo4fiNhojvLjC5EMF1pnM5tr+Uyt/G1g==} + cpu: [arm64] + os: [darwin] + + '@cursor/sdk-darwin-x64@1.0.10': + resolution: {integrity: sha512-Z0IVJB5cfyQ3lHz9MEjyH8bnmpaLRx/eh1E6MKC95lLr5K+1jPITsKgK3P9NwhIl1kc0NEA/z90mXxDOWoc2fg==} + cpu: [x64] + os: [darwin] + + '@cursor/sdk-linux-arm64@1.0.10': + resolution: {integrity: sha512-443sB9wDmlsdMDSgcGbmaNf5H+3IoIFhnmxSACFXbdFYNYj4U6e1TWxJqpl/FI/MjTodQFNQBvGbUo6SUcwj8w==} + cpu: [arm64] + os: [linux] + + '@cursor/sdk-linux-x64@1.0.10': + resolution: {integrity: sha512-elRt/lsH6xw1LyD4HcPAJINk5q7Apj4F68lmemb0UZOC01w5PfHsjUkURg7CkPWL7PmNgUjxXTaQe3EdEq8now==} + cpu: [x64] + os: [linux] + + '@cursor/sdk-win32-x64@1.0.10': + resolution: {integrity: sha512-5Fyb7aZYnSPRQPg/reHpwEw8SDhJHg1W+ARyDCByysI2II59RFqqBdlDay7iwUCKaziemuebFK5KNSVt8WlYTA==} + cpu: [x64] + os: [win32] + + '@cursor/sdk@1.0.10': + resolution: {integrity: sha512-j2y2sbDBgxMPZqXWUyCRfzatpD4h0Vg4SLvVLBV+j65A8m+e9gTdrSUK3eaUdIs9IAAZe1gngP2aOKMw6/tq+Q==} + engines: {node: '>=18'} + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@npmcli/fs@1.1.1': + resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} + + '@npmcli/move-file@1.1.2': + resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} + engines: {node: '>=10'} + deprecated: This functionality has been moved to @npmcli/fs + + '@statsig/client-core@3.31.0': + resolution: {integrity: sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ==} + + '@statsig/js-client@3.31.0': + resolution: {integrity: sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==} + + '@tootallnate/once@1.1.2': + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cacache@15.3.0: + resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} + engines: {node: '>= 10'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ip-address@10.1.1: + resolution: {integrity: sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==} + engines: {node: '>= 12'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + make-fetch-happen@9.1.0: + resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} + engines: {node: '>= 10'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@1.4.1: + resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} + engines: {node: '>=8'} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-gyp@8.4.1: + resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} + engines: {node: '>= 10.12.0'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@6.2.1: + resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} + engines: {node: '>= 10'} + + socks@2.8.8: + resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sqlite3@5.1.7: + resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} + + ssri@8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + unique-filename@1.1.1: + resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} + + unique-slug@2.0.2: + resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@bufbuild/protobuf@1.10.0': {} + + '@connectrpc/connect-node@1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0))': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) + undici: 5.29.0 + + '@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)': + dependencies: + '@bufbuild/protobuf': 1.10.0 + + '@cursor/sdk-darwin-arm64@1.0.10': + optional: true + + '@cursor/sdk-darwin-x64@1.0.10': + optional: true + + '@cursor/sdk-linux-arm64@1.0.10': + optional: true + + '@cursor/sdk-linux-x64@1.0.10': + optional: true + + '@cursor/sdk-win32-x64@1.0.10': + optional: true + + '@cursor/sdk@1.0.10': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) + '@connectrpc/connect-node': 1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)) + '@statsig/js-client': 3.31.0 + sqlite3: 5.1.7 + zod: 3.25.76 + optionalDependencies: + '@cursor/sdk-darwin-arm64': 1.0.10 + '@cursor/sdk-darwin-x64': 1.0.10 + '@cursor/sdk-linux-arm64': 1.0.10 + '@cursor/sdk-linux-x64': 1.0.10 + '@cursor/sdk-win32-x64': 1.0.10 + transitivePeerDependencies: + - bluebird + - supports-color + + '@fastify/busboy@2.1.1': {} + + '@gar/promisify@1.1.3': + optional: true + + '@npmcli/fs@1.1.1': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.7.4 + optional: true + + '@npmcli/move-file@1.1.2': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + optional: true + + '@statsig/client-core@3.31.0': {} + + '@statsig/js-client@3.31.0': + dependencies: + '@statsig/client-core': 3.31.0 + + '@tootallnate/once@1.1.2': + optional: true + + abbrev@1.1.1: + optional: true + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + optional: true + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + optional: true + + ansi-regex@5.0.1: + optional: true + + aproba@2.1.0: + optional: true + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + + balanced-match@1.0.2: + optional: true + + base64-js@1.5.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + optional: true + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cacache@15.3.0: + dependencies: + '@npmcli/fs': 1.1.1 + '@npmcli/move-file': 1.1.2 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 7.2.3 + infer-owner: 1.0.4 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 8.0.1 + tar: 6.2.1 + unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird + optional: true + + chownr@1.1.4: {} + + chownr@2.0.0: {} + + clean-stack@2.2.0: + optional: true + + color-support@1.1.3: + optional: true + + concat-map@0.0.1: + optional: true + + console-control-strings@1.1.0: + optional: true + + debug@4.4.3: + dependencies: + ms: 2.1.3 + optional: true + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + delegates@1.0.0: + optional: true + + detect-libc@2.1.2: {} + + emoji-regex@8.0.0: + optional: true + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: + optional: true + + err-code@2.0.3: + optional: true + + expand-template@2.0.3: {} + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: + optional: true + + gauge@4.0.4: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + + github-from-package@0.0.0: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + + graceful-fs@4.2.11: + optional: true + + has-unicode@2.0.1: + optional: true + + http-cache-semantics@4.2.0: + optional: true + + http-proxy-agent@4.0.1: + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ieee754@1.2.1: {} + + imurmurhash@0.1.4: + optional: true + + indent-string@4.0.0: + optional: true + + infer-owner@1.0.4: + optional: true + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + optional: true + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ip-address@10.1.1: + optional: true + + is-fullwidth-code-point@3.0.0: + optional: true + + is-lambda@1.0.1: + optional: true + + isexe@2.0.0: + optional: true + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + optional: true + + make-fetch-happen@9.1.0: + dependencies: + agentkeepalive: 4.6.0 + cacache: 15.3.0 + http-cache-semantics: 4.2.0 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 1.4.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 6.2.1 + ssri: 8.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + + mimic-response@3.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + optional: true + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-fetch@1.4.1: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + optional: true + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + optional: true + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp-classic@0.5.3: {} + + mkdirp@1.0.4: {} + + ms@2.1.3: + optional: true + + napi-build-utils@2.0.0: {} + + negotiator@0.6.4: + optional: true + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-addon-api@7.1.1: {} + + node-gyp@8.4.1: + dependencies: + env-paths: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 9.1.0 + nopt: 5.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.7.4 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + optional: true + + path-is-absolute@1.0.1: + optional: true + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + promise-inflight@1.0.1: + optional: true + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + optional: true + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + retry@0.12.0: + optional: true + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + optional: true + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: + optional: true + + semver@7.7.4: {} + + set-blocking@2.0.0: + optional: true + + signal-exit@3.0.7: + optional: true + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + smart-buffer@4.2.0: + optional: true + + socks-proxy-agent@6.2.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + socks: 2.8.8 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.8: + dependencies: + ip-address: 10.1.1 + smart-buffer: 4.2.0 + optional: true + + sqlite3@5.1.7: + dependencies: + bindings: 1.5.0 + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + tar: 6.2.1 + optionalDependencies: + node-gyp: 8.4.1 + transitivePeerDependencies: + - bluebird + - supports-color + + ssri@8.0.1: + dependencies: + minipass: 3.3.6 + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + optional: true + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + optional: true + + strip-json-comments@2.0.1: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + unique-filename@1.1.1: + dependencies: + unique-slug: 2.0.2 + optional: true + + unique-slug@2.0.2: + dependencies: + imurmurhash: 0.1.4 + optional: true + + util-deprecate@1.0.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + optional: true + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + optional: true + + wrappy@1.0.2: {} + + yallist@4.0.0: {} + + zod@3.25.76: {} diff --git a/e2e/scenarios/cursor-sdk-instrumentation/scenario.cursor-sdk-v1.mjs b/e2e/scenarios/cursor-sdk-instrumentation/scenario.cursor-sdk-v1.mjs new file mode 100644 index 000000000..d7df96baa --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/scenario.cursor-sdk-v1.mjs @@ -0,0 +1,5 @@ +import * as cursorSDK from "cursor-sdk-v1"; +import { runMain } from "../../helpers/provider-runtime.mjs"; +import { runAutoCursorSDKInstrumentation } from "./scenario.impl.mjs"; + +runMain(async () => runAutoCursorSDKInstrumentation(cursorSDK)); diff --git a/e2e/scenarios/cursor-sdk-instrumentation/scenario.cursor-sdk-v1.ts b/e2e/scenarios/cursor-sdk-instrumentation/scenario.cursor-sdk-v1.ts new file mode 100644 index 000000000..4fb25222d --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/scenario.cursor-sdk-v1.ts @@ -0,0 +1,5 @@ +import * as cursorSDK from "cursor-sdk-v1"; +import { runMain } from "../../helpers/provider-runtime.mjs"; +import { runWrappedCursorSDKInstrumentation } from "./scenario.impl.mjs"; + +runMain(async () => runWrappedCursorSDKInstrumentation(cursorSDK)); diff --git a/e2e/scenarios/cursor-sdk-instrumentation/scenario.impl.mjs b/e2e/scenarios/cursor-sdk-instrumentation/scenario.impl.mjs new file mode 100644 index 000000000..4dc909eb9 --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/scenario.impl.mjs @@ -0,0 +1,136 @@ +import { traced, wrapCursorSDK } from "braintrust"; +import { + collectAsync, + runOperation, + runTracedScenario, +} from "../../helpers/provider-runtime.mjs"; + +const CURSOR_MODEL = "composer-2"; + +export const ROOT_NAME = "cursor-sdk-root"; +export const SCENARIO_NAME = "cursor-sdk-instrumentation"; + +function cursorOptions() { + return { + apiKey: process.env.CURSOR_API_KEY, + local: { + cwd: process.cwd(), + sandboxOptions: { enabled: false }, + }, + model: { id: CURSOR_MODEL }, + }; +} + +async function disposeAgent(agent) { + if (agent?.[Symbol.asyncDispose]) { + await agent[Symbol.asyncDispose](); + } else if (agent?.close) { + agent.close(); + } +} + +async function runCursorSDKScenario({ decorateSDK, sdk }) { + if (!process.env.CURSOR_API_KEY) { + throw new Error( + "CURSOR_API_KEY is required for cursor-sdk-instrumentation", + ); + } + + const instrumentedSDK = decorateSDK ? decorateSDK(sdk) : sdk; + const { Agent } = instrumentedSDK; + let reusableAgent; + + await runTracedScenario({ + callback: async () => { + await runOperation("cursor-sdk-prompt-operation", "prompt", async () => { + await Agent.prompt( + "Reply with exactly: CURSOR_PROMPT_OK. Do not modify files.", + cursorOptions(), + ); + }); + + await runOperation("cursor-sdk-stream-operation", "stream", async () => { + reusableAgent = await Agent.create({ + ...cursorOptions(), + agents: { + reviewer: { + description: "Reads the request and replies briefly.", + model: "inherit", + prompt: "Reply concisely. Do not modify files.", + }, + }, + }); + const run = await reusableAgent.send( + "First use the reviewer subagent to confirm exactly CURSOR_SUBAGENT_OK. Then run the shell command `printf cursor_tool_ok` and report the output. Do not edit files.", + ); + await collectAsync(run.stream()); + }); + + await runOperation("cursor-sdk-wait-operation", "wait", async () => { + const agent = await Agent.create(cursorOptions()); + try { + const run = await agent.send( + "Reply with exactly: CURSOR_WAIT_OK. Do not modify files.", + { + onDelta: async ({ update }) => { + await traced(async () => update.type, { + name: "cursor-sdk-user-on-delta", + }); + }, + onStep: async ({ step }) => { + await traced(async () => step.type, { + name: "cursor-sdk-user-on-step", + }); + }, + }, + ); + await run.wait(); + } finally { + await disposeAgent(agent); + } + }); + + await runOperation( + "cursor-sdk-resume-conversation-operation", + "resume-conversation", + async () => { + const agentId = reusableAgent?.agentId; + if (!agentId) { + throw new Error("Expected reusable Cursor agent id"); + } + const agent = await Agent.resume(agentId, cursorOptions()); + try { + const run = await agent.send( + "Reply with exactly: CURSOR_CONVERSATION_OK. Do not modify files.", + ); + await run.conversation(); + } finally { + await disposeAgent(agent); + } + }, + ); + }, + flushCount: 2, + flushDelayMs: 250, + metadata: { + scenario: SCENARIO_NAME, + }, + projectNameBase: "e2e-cursor-sdk-instrumentation", + rootName: ROOT_NAME, + }); + + await disposeAgent(reusableAgent); +} + +export async function runWrappedCursorSDKInstrumentation(sdk) { + await runCursorSDKScenario({ + decorateSDK: wrapCursorSDK, + sdk, + }); +} + +export async function runAutoCursorSDKInstrumentation(sdk) { + await runCursorSDKScenario({ + sdk, + }); +} diff --git a/e2e/scenarios/cursor-sdk-instrumentation/scenario.test.ts b/e2e/scenarios/cursor-sdk-instrumentation/scenario.test.ts new file mode 100644 index 000000000..6b5d497d9 --- /dev/null +++ b/e2e/scenarios/cursor-sdk-instrumentation/scenario.test.ts @@ -0,0 +1,56 @@ +import { describe } from "vitest"; +import { + prepareScenarioDir, + readInstalledPackageVersion, + resolveScenarioDir, +} from "../../helpers/scenario-harness"; +import { defineCursorSDKInstrumentationAssertions } from "./assertions"; + +const scenarioDir = await prepareScenarioDir({ + scenarioDir: resolveScenarioDir(import.meta.url), +}); +const TIMEOUT_MS = 240_000; +const cursorSDKScenario = { + autoEntry: "scenario.cursor-sdk-v1.mjs", + autoSnapshotName: "cursor-sdk-v1-auto-hook", + dependencyName: "cursor-sdk-v1", + version: await readInstalledPackageVersion(scenarioDir, "cursor-sdk-v1"), + wrapperEntry: "scenario.cursor-sdk-v1.ts", + wrapperSnapshotName: "cursor-sdk-v1-wrapped", + variantKey: "cursor-sdk-v1", +}; + +describe("wrapped instrumentation", () => { + defineCursorSDKInstrumentationAssertions({ + name: `cursor sdk ${cursorSDKScenario.version}`, + runScenario: async ({ runScenarioDir }) => { + await runScenarioDir({ + entry: cursorSDKScenario.wrapperEntry, + runContext: { variantKey: cursorSDKScenario.variantKey }, + scenarioDir, + timeoutMs: TIMEOUT_MS, + }); + }, + snapshotName: cursorSDKScenario.wrapperSnapshotName, + testFileUrl: import.meta.url, + timeoutMs: TIMEOUT_MS, + }); +}); + +describe("auto-hook instrumentation", () => { + defineCursorSDKInstrumentationAssertions({ + name: `cursor sdk ${cursorSDKScenario.version}`, + runScenario: async ({ runNodeScenarioDir }) => { + await runNodeScenarioDir({ + entry: cursorSDKScenario.autoEntry, + nodeArgs: ["--import", "braintrust/hook.mjs"], + runContext: { variantKey: cursorSDKScenario.variantKey }, + scenarioDir, + timeoutMs: TIMEOUT_MS, + }); + }, + snapshotName: cursorSDKScenario.autoSnapshotName, + testFileUrl: import.meta.url, + timeoutMs: TIMEOUT_MS, + }); +}); diff --git a/e2e/scripts/run-canary-tests-docker.mjs b/e2e/scripts/run-canary-tests-docker.mjs index 0635ba044..9b75bb175 100644 --- a/e2e/scripts/run-canary-tests-docker.mjs +++ b/e2e/scripts/run-canary-tests-docker.mjs @@ -19,6 +19,7 @@ const ALLOWED_ENV_KEYS = [ "GEMINI_API_KEY", "GOOGLE_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", diff --git a/js/src/auto-instrumentations/bundler/plugin.ts b/js/src/auto-instrumentations/bundler/plugin.ts index 3202d3dbc..2462c5faf 100644 --- a/js/src/auto-instrumentations/bundler/plugin.ts +++ b/js/src/auto-instrumentations/bundler/plugin.ts @@ -24,6 +24,7 @@ import { openaiConfigs } from "../configs/openai"; import { anthropicConfigs } from "../configs/anthropic"; import { aiSDKConfigs } from "../configs/ai-sdk"; import { claudeAgentSDKConfigs } from "../configs/claude-agent-sdk"; +import { cursorSDKConfigs } from "../configs/cursor-sdk"; import { googleGenAIConfigs } from "../configs/google-genai"; import { huggingFaceConfigs } from "../configs/huggingface"; import { openRouterAgentConfigs } from "../configs/openrouter-agent"; @@ -76,6 +77,7 @@ export const unplugin = createUnplugin((options = {}) => { ...anthropicConfigs, ...aiSDKConfigs, ...claudeAgentSDKConfigs, + ...cursorSDKConfigs, ...googleGenAIConfigs, ...huggingFaceConfigs, ...openRouterConfigs, diff --git a/js/src/auto-instrumentations/bundler/webpack-loader.ts b/js/src/auto-instrumentations/bundler/webpack-loader.ts index 5d6c7e20b..966c659aa 100644 --- a/js/src/auto-instrumentations/bundler/webpack-loader.ts +++ b/js/src/auto-instrumentations/bundler/webpack-loader.ts @@ -33,6 +33,7 @@ import { openaiConfigs } from "../configs/openai"; import { anthropicConfigs } from "../configs/anthropic"; import { aiSDKConfigs } from "../configs/ai-sdk"; import { claudeAgentSDKConfigs } from "../configs/claude-agent-sdk"; +import { cursorSDKConfigs } from "../configs/cursor-sdk"; import { googleGenAIConfigs } from "../configs/google-genai"; import { huggingFaceConfigs } from "../configs/huggingface"; import { openRouterAgentConfigs } from "../configs/openrouter-agent"; @@ -71,6 +72,7 @@ function getMatcher(options: BundlerPluginOptions): InstrumentationMatcher { ...anthropicConfigs, ...aiSDKConfigs, ...claudeAgentSDKConfigs, + ...cursorSDKConfigs, ...googleGenAIConfigs, ...huggingFaceConfigs, ...openRouterConfigs, diff --git a/js/src/auto-instrumentations/configs/cursor-sdk.ts b/js/src/auto-instrumentations/configs/cursor-sdk.ts new file mode 100644 index 000000000..4aa8ed631 --- /dev/null +++ b/js/src/auto-instrumentations/configs/cursor-sdk.ts @@ -0,0 +1,49 @@ +import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import { cursorSDKChannels } from "../../instrumentation/plugins/cursor-sdk-channels"; + +const cursorSDKVersionRange = ">=1.0.7 <2.0.0"; + +const cursorSDKEntrypoints = ["dist/esm/index.js", "dist/cjs/index.js"]; + +export const cursorSDKConfigs: InstrumentationConfig[] = + cursorSDKEntrypoints.flatMap((filePath) => [ + { + channelName: cursorSDKChannels.create.channelName, + module: { + name: "@cursor/sdk", + versionRange: cursorSDKVersionRange, + filePath, + }, + functionQuery: { + className: "Agent", + methodName: "create", + kind: "Async", + }, + }, + { + channelName: cursorSDKChannels.resume.channelName, + module: { + name: "@cursor/sdk", + versionRange: cursorSDKVersionRange, + filePath, + }, + functionQuery: { + className: "Agent", + methodName: "resume", + kind: "Async", + }, + }, + { + channelName: cursorSDKChannels.prompt.channelName, + module: { + name: "@cursor/sdk", + versionRange: cursorSDKVersionRange, + filePath, + }, + functionQuery: { + className: "Agent", + methodName: "prompt", + kind: "Async", + }, + }, + ]); diff --git a/js/src/auto-instrumentations/hook.mts b/js/src/auto-instrumentations/hook.mts index 2c3622600..46fe0f0d2 100644 --- a/js/src/auto-instrumentations/hook.mts +++ b/js/src/auto-instrumentations/hook.mts @@ -18,6 +18,7 @@ import { openaiConfigs } from "./configs/openai.js"; import { anthropicConfigs } from "./configs/anthropic.js"; import { aiSDKConfigs } from "./configs/ai-sdk.js"; import { claudeAgentSDKConfigs } from "./configs/claude-agent-sdk.js"; +import { cursorSDKConfigs } from "./configs/cursor-sdk.js"; import { googleGenAIConfigs } from "./configs/google-genai.js"; import { huggingFaceConfigs } from "./configs/huggingface.js"; import { openRouterAgentConfigs } from "./configs/openrouter-agent.js"; @@ -68,6 +69,9 @@ const allConfigs = [ ...(isDisabled(disabledIntegrations, "claudeagentsdk", "claude-agent-sdk") ? [] : claudeAgentSDKConfigs), + ...(isDisabled(disabledIntegrations, "cursor", "cursor-sdk") + ? [] + : cursorSDKConfigs), ...(isDisabled(disabledIntegrations, "google", "google-genai") ? [] : googleGenAIConfigs), diff --git a/js/src/auto-instrumentations/index.ts b/js/src/auto-instrumentations/index.ts index 03fda75a1..bdac954ab 100644 --- a/js/src/auto-instrumentations/index.ts +++ b/js/src/auto-instrumentations/index.ts @@ -32,6 +32,7 @@ export { openaiConfigs } from "./configs/openai"; export { anthropicConfigs } from "./configs/anthropic"; export { aiSDKConfigs } from "./configs/ai-sdk"; export { claudeAgentSDKConfigs } from "./configs/claude-agent-sdk"; +export { cursorSDKConfigs } from "./configs/cursor-sdk"; export { googleGenAIConfigs } from "./configs/google-genai"; export { huggingFaceConfigs } from "./configs/huggingface"; export { openRouterAgentConfigs } from "./configs/openrouter-agent"; diff --git a/js/src/exports.ts b/js/src/exports.ts index 93a12bb05..02dadbf66 100644 --- a/js/src/exports.ts +++ b/js/src/exports.ts @@ -177,6 +177,7 @@ export { export { wrapAnthropic } from "./wrappers/anthropic"; export { wrapMastraAgent } from "./wrappers/mastra"; export { wrapClaudeAgentSDK } from "./wrappers/claude-agent-sdk/claude-agent-sdk"; +export { wrapCursorSDK } from "./wrappers/cursor-sdk"; export { wrapGoogleGenAI } from "./wrappers/google-genai"; export { wrapGoogleADK } from "./wrappers/google-adk"; export { wrapHuggingFace } from "./wrappers/huggingface"; diff --git a/js/src/instrumentation/braintrust-plugin.ts b/js/src/instrumentation/braintrust-plugin.ts index 485d2d9bc..5db01b441 100644 --- a/js/src/instrumentation/braintrust-plugin.ts +++ b/js/src/instrumentation/braintrust-plugin.ts @@ -3,6 +3,7 @@ import { OpenAIPlugin } from "./plugins/openai-plugin"; import { AnthropicPlugin } from "./plugins/anthropic-plugin"; import { AISDKPlugin } from "./plugins/ai-sdk-plugin"; import { ClaudeAgentSDKPlugin } from "./plugins/claude-agent-sdk-plugin"; +import { CursorSDKPlugin } from "./plugins/cursor-sdk-plugin"; import { GoogleGenAIPlugin } from "./plugins/google-genai-plugin"; import { HuggingFacePlugin } from "./plugins/huggingface-plugin"; import { OpenRouterAgentPlugin } from "./plugins/openrouter-agent-plugin"; @@ -22,6 +23,8 @@ export interface BraintrustPluginConfig { googleGenAI?: boolean; huggingface?: boolean; claudeAgentSDK?: boolean; + cursor?: boolean; + cursorSDK?: boolean; openrouter?: boolean; openrouterAgent?: boolean; mistral?: boolean; @@ -53,6 +56,7 @@ export class BraintrustPlugin extends BasePlugin { private anthropicPlugin: AnthropicPlugin | null = null; private aiSDKPlugin: AISDKPlugin | null = null; private claudeAgentSDKPlugin: ClaudeAgentSDKPlugin | null = null; + private cursorSDKPlugin: CursorSDKPlugin | null = null; private googleGenAIPlugin: GoogleGenAIPlugin | null = null; private huggingFacePlugin: HuggingFacePlugin | null = null; private openRouterPlugin: OpenRouterPlugin | null = null; @@ -95,6 +99,11 @@ export class BraintrustPlugin extends BasePlugin { this.claudeAgentSDKPlugin.enable(); } + if (integrations.cursorSDK !== false && integrations.cursor !== false) { + this.cursorSDKPlugin = new CursorSDKPlugin(); + this.cursorSDKPlugin.enable(); + } + // Enable Google GenAI integration (default: true) // Support both 'googleGenAI' and legacy 'google' config keys if (integrations.googleGenAI !== false && integrations.google !== false) { @@ -160,6 +169,11 @@ export class BraintrustPlugin extends BasePlugin { this.claudeAgentSDKPlugin = null; } + if (this.cursorSDKPlugin) { + this.cursorSDKPlugin.disable(); + this.cursorSDKPlugin = null; + } + if (this.googleGenAIPlugin) { this.googleGenAIPlugin.disable(); this.googleGenAIPlugin = null; diff --git a/js/src/instrumentation/plugins/cursor-sdk-channels.ts b/js/src/instrumentation/plugins/cursor-sdk-channels.ts new file mode 100644 index 000000000..b38f7246a --- /dev/null +++ b/js/src/instrumentation/plugins/cursor-sdk-channels.ts @@ -0,0 +1,47 @@ +import { channel, defineChannels } from "../core/channel-definitions"; +import type { + CursorSDKAgent, + CursorSDKAgentOptions, + CursorSDKRun, + CursorSDKRunResult, + CursorSDKSendOptions, + CursorSDKUserMessage, +} from "../../vendor-sdk-types/cursor-sdk"; + +export const cursorSDKChannels = defineChannels("@cursor/sdk", { + create: channel< + [CursorSDKAgentOptions], + CursorSDKAgent, + Record + >({ + channelName: "Agent.create", + kind: "async", + }), + resume: channel< + [string, Partial | undefined], + CursorSDKAgent, + Record + >({ + channelName: "Agent.resume", + kind: "async", + }), + prompt: channel< + [string | CursorSDKUserMessage, CursorSDKAgentOptions | undefined], + CursorSDKRunResult, + Record + >({ + channelName: "Agent.prompt", + kind: "async", + }), + send: channel< + [string | CursorSDKUserMessage, CursorSDKSendOptions | undefined], + CursorSDKRun, + { + agent?: CursorSDKAgent; + operation?: "send"; + } + >({ + channelName: "agent.send", + kind: "async", + }), +}); diff --git a/js/src/instrumentation/plugins/cursor-sdk-plugin.test.ts b/js/src/instrumentation/plugins/cursor-sdk-plugin.test.ts new file mode 100644 index 000000000..909fceb02 --- /dev/null +++ b/js/src/instrumentation/plugins/cursor-sdk-plugin.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockStartSpan } = vi.hoisted(() => ({ + mockStartSpan: vi.fn(), +})); + +vi.mock("../../isomorph", () => ({ + default: { + newTracingChannel: vi.fn(), + }, +})); + +vi.mock("../../logger", () => ({ + startSpan: (...args: unknown[]) => mockStartSpan(...args), +})); + +import iso from "../../isomorph"; +import { CursorSDKPlugin } from "./cursor-sdk-plugin"; + +const mockNewTracingChannel = iso.newTracingChannel as ReturnType; + +describe("CursorSDKPlugin", () => { + let handlersByName: Map; + let spans: Array<{ + end: ReturnType; + export: ReturnType; + log: ReturnType; + name?: string; + }>; + + beforeEach(() => { + handlersByName = new Map(); + spans = []; + mockNewTracingChannel.mockImplementation((name: string) => ({ + subscribe: vi.fn((handlers) => handlersByName.set(name, handlers)), + tracePromise: vi.fn((fn) => fn()), + unsubscribe: vi.fn(), + })); + mockStartSpan.mockImplementation((args: any) => { + const span = { + end: vi.fn(), + export: vi.fn(async () => `${args.name}-export-${spans.length}`), + log: vi.fn(), + name: args.name, + }; + if (args.event) { + span.log(args.event); + } + spans.push(span); + return span; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("subscribes to Cursor SDK channels", () => { + const plugin = new CursorSDKPlugin(); + + plugin.enable(); + + expect(handlersByName.has("orchestrion:@cursor/sdk:Agent.create")).toBe( + true, + ); + expect(handlersByName.has("orchestrion:@cursor/sdk:Agent.resume")).toBe( + true, + ); + expect(handlersByName.has("orchestrion:@cursor/sdk:Agent.prompt")).toBe( + true, + ); + expect(handlersByName.has("orchestrion:@cursor/sdk:agent.send")).toBe(true); + }); + + it("patches agents returned by Agent.create and traces send/wait", async () => { + const plugin = new CursorSDKPlugin(); + plugin.enable(); + + const createHandlers = handlersByName.get( + "orchestrion:@cursor/sdk:Agent.create", + ); + const sendHandlers = handlersByName.get( + "orchestrion:@cursor/sdk:agent.send", + ); + const run = makeRun(); + const originalSend = vi.fn(async () => run); + const agent = { + agentId: "agent-1", + send: originalSend, + }; + + createHandlers.asyncEnd({ + arguments: [{ local: { cwd: "/tmp/repo" } }], + result: agent, + }); + + const patchedRun = await agent.send("use a tool", {}); + expect(patchedRun).toBe(run); + expect(originalSend).toHaveBeenCalledTimes(1); + + const sendEvent = { + agent, + arguments: ["use a tool", {}], + result: run, + }; + sendHandlers.start(sendEvent); + sendHandlers.asyncEnd(sendEvent); + + await run.wait(); + + const rootSpan = spans.find((span) => span.name === "Cursor Agent"); + expect(rootSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + input: "use a tool", + metadata: expect.objectContaining({ + "cursor_sdk.agent_id": "agent-1", + provider: "cursor", + }), + }), + ); + expect(rootSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + output: "done", + metadata: expect.objectContaining({ + "cursor_sdk.run_id": "run-1", + "cursor_sdk.status": "finished", + }), + }), + ); + expect(rootSpan?.end).toHaveBeenCalledTimes(1); + }); + + it("captures stream tool calls and usage", async () => { + const plugin = new CursorSDKPlugin(); + plugin.enable(); + + const sendHandlers = handlersByName.get( + "orchestrion:@cursor/sdk:agent.send", + ); + const run = makeRun([ + { + type: "tool_call", + call_id: "call-1", + name: "shell", + status: "running", + args: { command: "echo hi" }, + }, + { + type: "tool_call", + call_id: "call-1", + name: "shell", + status: "completed", + result: { stdout: "hi\n" }, + }, + { + type: "assistant", + message: { content: [{ type: "text", text: "done" }] }, + }, + ]); + const event = { + agent: { agentId: "agent-1" }, + arguments: [ + "hello", + { + onDelta: vi.fn(), + }, + ], + result: run, + }; + + sendHandlers.start(event); + await event.arguments[1].onDelta({ + update: { + type: "turn-ended", + usage: { + inputTokens: 3, + outputTokens: 4, + cacheReadTokens: 1, + cacheWriteTokens: 2, + }, + }, + }); + sendHandlers.asyncEnd(event); + + const chunks = []; + for await (const chunk of run.stream()) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(3); + const toolSpan = spans.find((span) => span.name === "tool: shell"); + expect(toolSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + input: { command: "echo hi" }, + }), + ); + expect(toolSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + output: { stdout: "hi\n" }, + }), + ); + const rootSpan = spans.find((span) => span.name === "Cursor Agent"); + expect(rootSpan?.log).toHaveBeenCalledWith( + expect.objectContaining({ + metrics: expect.objectContaining({ + completion_tokens: 4, + prompt_cache_creation_tokens: 2, + prompt_cached_tokens: 1, + prompt_tokens: 3, + }), + output: "done", + }), + ); + }); + + it("traces Agent.prompt without a nested send span", () => { + const plugin = new CursorSDKPlugin(); + plugin.enable(); + + const promptHandlers = handlersByName.get( + "orchestrion:@cursor/sdk:Agent.prompt", + ); + const sendHandlers = handlersByName.get( + "orchestrion:@cursor/sdk:agent.send", + ); + const promptEvent = { arguments: ["hello", { local: { cwd: "/tmp" } }] }; + + promptHandlers.start(promptEvent); + sendHandlers.start({ arguments: ["nested", {}] }); + promptHandlers.asyncEnd({ + ...promptEvent, + result: { id: "run-1", result: "done", status: "finished" }, + }); + + expect(spans.filter((span) => span.name === "Cursor Agent")).toHaveLength( + 1, + ); + }); +}); + +function makeRun(messages: unknown[] = []) { + return { + agentId: "agent-1", + async conversation() { + return []; + }, + id: "run-1", + result: "done", + status: "finished", + stream: async function* () { + for (const message of messages) { + yield message; + } + }, + async wait() { + return { id: "run-1", result: "done", status: "finished" }; + }, + }; +} diff --git a/js/src/instrumentation/plugins/cursor-sdk-plugin.ts b/js/src/instrumentation/plugins/cursor-sdk-plugin.ts new file mode 100644 index 000000000..5f45bf440 --- /dev/null +++ b/js/src/instrumentation/plugins/cursor-sdk-plugin.ts @@ -0,0 +1,1179 @@ +import { BasePlugin } from "../core"; +import type { ChannelMessage } from "../core/channel-definitions"; +import type { IsoChannelHandlers } from "../../isomorph"; +import { debugLogger } from "../../debug-logger"; +import { startSpan } from "../../logger"; +import type { Span } from "../../logger"; +import { getCurrentUnixTimestamp } from "../../util"; +import { SpanTypeAttribute } from "../../../util/index"; +import { cursorSDKChannels } from "./cursor-sdk-channels"; +import type { + CursorSDKAgent, + CursorSDKAgentOptions, + CursorSDKConversationStep, + CursorSDKConversationTurn, + CursorSDKInteractionUpdate, + CursorSDKMessage, + CursorSDKModelSelection, + CursorSDKRun, + CursorSDKRunGitInfo, + CursorSDKRunResult, + CursorSDKSendOptions, + CursorSDKToolCall, + CursorSDKToolUseMessage, + CursorSDKUsage, + CursorSDKUserMessage, +} from "../../vendor-sdk-types/cursor-sdk"; + +const PATCHED_AGENT = Symbol.for("braintrust.cursor-sdk.auto-patched-agent"); +const PATCHED_RUN = Symbol.for("braintrust.cursor-sdk.patched-run"); +const WRAPPED_AGENT = Symbol.for("braintrust.cursor-sdk.wrapped-agent"); + +type ToolState = { + span: Span; + subAgentSpan?: Span; +}; + +type CursorRunState = { + activeToolSpans: Map; + agent?: CursorSDKAgent; + deltaText: string[]; + streamText: string[]; + stepText: string[]; + conversationOutput?: unknown; + conversationText: string[]; + finalized: boolean; + input: string | CursorSDKUserMessage; + lastResult?: CursorSDKRunResult; + metadata: Record; + metrics: Record; + run?: CursorSDKRun; + span: Span; + startTime: number; + streamMessages: CursorSDKMessage[]; + taskText: string[]; +}; + +type PromptState = { + metadata: Record; + span: Span; + startTime: number; +}; + +export class CursorSDKPlugin extends BasePlugin { + private promptDepth = 0; + + protected onEnable(): void { + this.subscribeToAgentFactories(); + this.subscribeToPrompt(); + this.subscribeToSend(); + } + + protected onDisable(): void { + for (const unsubscribe of this.unsubscribers) { + unsubscribe(); + } + this.unsubscribers = []; + this.promptDepth = 0; + } + + private subscribeToAgentFactories(): void { + this.subscribeToAgentFactory(cursorSDKChannels.create); + this.subscribeToAgentFactory(cursorSDKChannels.resume); + } + + private subscribeToAgentFactory( + channel: typeof cursorSDKChannels.create | typeof cursorSDKChannels.resume, + ): void { + const tracingChannel = channel.tracingChannel(); + const handlers: IsoChannelHandlers> = { + asyncEnd: (event) => { + patchCursorAgentInPlace(event.result); + }, + error: () => {}, + }; + + tracingChannel.subscribe(handlers); + this.unsubscribers.push(() => { + tracingChannel.unsubscribe(handlers); + }); + } + + private subscribeToPrompt(): void { + const channel = cursorSDKChannels.prompt.tracingChannel(); + const states = new WeakMap(); + + const handlers: IsoChannelHandlers< + ChannelMessage + > = { + start: (event) => { + this.promptDepth += 1; + const message = event.arguments[0]; + const options = event.arguments[1]; + const metadata = { + ...extractAgentOptionsMetadata(options), + "cursor_sdk.operation": "Agent.prompt", + provider: "cursor", + ...(event.moduleVersion + ? { "cursor_sdk.version": event.moduleVersion } + : {}), + }; + const span = startSpan({ + name: "Cursor Agent", + spanAttributes: { type: SpanTypeAttribute.TASK }, + }); + const startTime = getCurrentUnixTimestamp(); + safeLog(span, { + input: sanitizeUserMessage(message), + metadata, + }); + states.set(event, { metadata, span, startTime }); + }, + asyncEnd: (event) => { + this.promptDepth = Math.max(0, this.promptDepth - 1); + const state = states.get(event); + if (!state) { + return; + } + try { + safeLog(state.span, { + metadata: { + ...state.metadata, + ...extractRunResultMetadata(event.result), + }, + metrics: buildDurationMetrics(state.startTime), + output: event.result?.result ?? event.result, + }); + } finally { + state.span.end(); + states.delete(event); + } + }, + error: (event) => { + this.promptDepth = Math.max(0, this.promptDepth - 1); + const state = states.get(event); + if (!state || !event.error) { + return; + } + safeLog(state.span, { error: event.error.message }); + state.span.end(); + states.delete(event); + }, + }; + + channel.subscribe(handlers); + this.unsubscribers.push(() => { + channel.unsubscribe(handlers); + }); + } + + private subscribeToSend(): void { + const channel = cursorSDKChannels.send.tracingChannel(); + const states = new WeakMap(); + + const handlers: IsoChannelHandlers< + ChannelMessage + > = { + start: (event) => { + if (this.promptDepth > 0) { + return; + } + + const message = event.arguments[0]; + const sendOptions = event.arguments[1]; + const agent = event.agent; + const metadata = { + ...extractSendMetadata(sendOptions), + ...(agent ? extractAgentMetadata(agent) : {}), + "cursor_sdk.operation": "agent.send", + provider: "cursor", + ...(event.moduleVersion + ? { "cursor_sdk.version": event.moduleVersion } + : {}), + }; + const span = startSpan({ + name: "Cursor Agent", + spanAttributes: { type: SpanTypeAttribute.TASK }, + }); + const startTime = getCurrentUnixTimestamp(); + safeLog(span, { + input: sanitizeUserMessage(message), + metadata, + }); + + const state: CursorRunState = { + activeToolSpans: new Map(), + agent, + conversationText: [], + deltaText: [], + finalized: false, + input: message, + metadata, + metrics: {}, + span, + startTime, + streamMessages: [], + streamText: [], + stepText: [], + taskText: [], + }; + + if (hasCursorCallbacks(sendOptions)) { + event.arguments[1] = wrapSendOptionsCallbacks(sendOptions, state); + } + states.set(event, state); + }, + asyncEnd: (event) => { + const state = states.get(event); + if (!state) { + return; + } + + if (!event.result) { + return; + } + state.run = event.result; + state.metadata = { + ...state.metadata, + ...extractRunMetadata(event.result), + }; + patchCursorRun(event.result, state); + }, + error: (event) => { + const state = states.get(event); + if (!state || !event.error) { + return; + } + safeLog(state.span, { error: event.error.message }); + endOpenToolSpans(state, event.error.message); + state.span.end(); + state.finalized = true; + states.delete(event); + }, + }; + + channel.subscribe(handlers); + this.unsubscribers.push(() => { + channel.unsubscribe(handlers); + }); + } +} + +function patchCursorAgentInPlace(agent: unknown): void { + if (!agent || typeof agent !== "object") { + return; + } + const agentRecord = agent as CursorSDKAgent & Record; + if ( + agentRecord[PATCHED_AGENT] || + agentRecord[WRAPPED_AGENT] || + typeof agentRecord.send !== "function" + ) { + return; + } + + const originalSend = agentRecord.send.bind(agentRecord); + try { + Object.defineProperty(agentRecord, PATCHED_AGENT, { + configurable: false, + enumerable: false, + value: true, + }); + Object.defineProperty(agentRecord, "send", { + configurable: true, + value( + message: string | CursorSDKUserMessage, + options?: CursorSDKSendOptions, + ) { + const args = [message, options] as [ + string | CursorSDKUserMessage, + CursorSDKSendOptions | undefined, + ]; + return cursorSDKChannels.send.tracePromise( + () => originalSend(...args), + { + agent: agentRecord, + arguments: args, + operation: "send", + } as never, + ); + }, + writable: true, + }); + } catch { + // Frozen/sealed agents cannot be patched. Leave user behavior untouched. + } +} + +function wrapSendOptionsCallbacks( + options: CursorSDKSendOptions, + state: CursorRunState, +): CursorSDKSendOptions { + const originalOnDelta = options.onDelta; + const originalOnStep = options.onStep; + + return { + ...options, + async onDelta(args) { + try { + await handleInteractionUpdate(state, args.update); + } catch (error) { + logInstrumentationError("Cursor SDK onDelta", error); + } + if (originalOnDelta) { + return originalOnDelta(args); + } + }, + async onStep(args) { + try { + handleStepUpdate(state, args.step); + } catch (error) { + logInstrumentationError("Cursor SDK onStep", error); + } + if (originalOnStep) { + return originalOnStep(args); + } + }, + }; +} + +function hasCursorCallbacks( + options: CursorSDKSendOptions | undefined, +): options is CursorSDKSendOptions { + return ( + !!options && + (typeof options.onDelta === "function" || + typeof options.onStep === "function") + ); +} + +function patchCursorRun(run: CursorSDKRun, state: CursorRunState): void { + if (!run || typeof run !== "object") { + return; + } + const runRecord = run as CursorSDKRun & Record; + if (runRecord[PATCHED_RUN]) { + return; + } + + try { + Object.defineProperty(runRecord, PATCHED_RUN, { + configurable: false, + enumerable: false, + value: true, + }); + + if (typeof runRecord.stream === "function") { + const originalStream = runRecord.stream.bind(runRecord); + Object.defineProperty(runRecord, "stream", { + configurable: true, + value() { + const stream = originalStream(); + return patchCursorStream(stream, state); + }, + writable: true, + }); + } + + if (typeof runRecord.wait === "function") { + const originalWait = runRecord.wait.bind(runRecord); + Object.defineProperty(runRecord, "wait", { + configurable: true, + async value() { + try { + const result = await originalWait(); + state.lastResult = result; + await finalizeCursorRun(state, { result }); + return result; + } catch (error) { + await finalizeCursorRun(state, { error }); + throw error; + } + }, + writable: true, + }); + } + + if (typeof runRecord.conversation === "function") { + const originalConversation = runRecord.conversation.bind(runRecord); + Object.defineProperty(runRecord, "conversation", { + configurable: true, + async value() { + try { + const conversation = await originalConversation(); + await handleConversation(state, conversation); + await finalizeCursorRun(state); + return conversation; + } catch (error) { + await finalizeCursorRun(state, { error }); + throw error; + } + }, + writable: true, + }); + } + } catch { + // If the Run object is not patchable, finish the span with available data. + void finalizeCursorRun(state, { output: run }); + } +} + +async function* patchCursorStream( + stream: AsyncGenerator, + state: CursorRunState, +): AsyncGenerator { + try { + for await (const message of stream) { + try { + await handleStreamMessage(state, message); + } catch (error) { + logInstrumentationError("Cursor SDK stream", error); + } + yield message; + } + await finalizeCursorRun(state); + } catch (error) { + await finalizeCursorRun(state, { error }); + throw error; + } +} + +async function handleInteractionUpdate( + state: CursorRunState, + update: CursorSDKInteractionUpdate, +): Promise { + switch (update.type) { + case "text-delta": + if (typeof update.text === "string") { + state.deltaText.push(update.text); + } + return; + case "token-delta": + if (typeof update.tokens === "number") { + state.metrics["cursor_sdk.delta_tokens"] = + (state.metrics["cursor_sdk.delta_tokens"] ?? 0) + update.tokens; + } + return; + case "tool-call-started": + case "partial-tool-call": + case "tool-call-completed": + await handleToolUpdate( + state, + update as Extract< + CursorSDKInteractionUpdate, + { + type: + | "tool-call-started" + | "partial-tool-call" + | "tool-call-completed"; + } + >, + ); + return; + case "turn-ended": + addUsageMetrics( + state.metrics, + (update as { usage?: CursorSDKUsage }).usage, + ); + return; + case "summary": + if (typeof update.summary === "string") { + state.taskText.push(update.summary); + } + return; + case "step-completed": + if (typeof update.stepDurationMs === "number") { + state.metrics["cursor_sdk.step_duration_ms"] = + (state.metrics["cursor_sdk.step_duration_ms"] ?? 0) + + update.stepDurationMs; + } + state.metrics["cursor_sdk.steps"] = + (state.metrics["cursor_sdk.steps"] ?? 0) + 1; + return; + default: + return; + } +} + +async function handleToolUpdate( + state: CursorRunState, + update: Extract< + CursorSDKInteractionUpdate, + { type: "tool-call-started" | "partial-tool-call" | "tool-call-completed" } + >, +): Promise { + const callId = update.callId; + if (!callId) { + return; + } + + const toolCall = update.toolCall; + const name = extractToolName(toolCall); + const args = extractToolArgs(toolCall); + const result = extractToolResult(toolCall); + + if ( + update.type === "tool-call-started" || + update.type === "partial-tool-call" + ) { + if (!state.activeToolSpans.has(callId)) { + state.activeToolSpans.set( + callId, + await startToolSpan(state, { + args, + callId, + name, + status: "running", + toolCall, + }), + ); + } + return; + } + + const toolState = + state.activeToolSpans.get(callId) ?? + (await startToolSpan(state, { + args, + callId, + name, + status: "completed", + toolCall, + })); + + finishToolSpan(toolState, { + error: toolCall?.status === "error" ? stringifyUnknown(result) : undefined, + metadata: { + "cursor_sdk.tool.status": toolCall?.status ?? "completed", + }, + output: result, + }); + state.activeToolSpans.delete(callId); +} + +async function handleStreamMessage( + state: CursorRunState, + message: CursorSDKMessage, +): Promise { + state.streamMessages.push(message); + if (message.type === "system") { + const systemMessage = message as Extract< + CursorSDKMessage, + { type: "system" } + >; + state.metadata = { + ...state.metadata, + ...extractModelMetadata(systemMessage.model), + ...(systemMessage.agent_id + ? { "cursor_sdk.agent_id": systemMessage.agent_id } + : {}), + ...(systemMessage.run_id + ? { "cursor_sdk.run_id": systemMessage.run_id } + : {}), + ...(systemMessage.tools + ? { "cursor_sdk.tools": systemMessage.tools } + : {}), + }; + return; + } + + if (message.type === "assistant") { + const assistantMessage = message as Extract< + CursorSDKMessage, + { type: "assistant" } + >; + for (const block of assistantMessage.message?.content ?? []) { + if (block?.type === "text" && typeof block.text === "string") { + state.streamText.push(block.text); + } else if (block?.type === "tool_use" && block.id) { + state.activeToolSpans.set( + block.id, + await startToolSpan(state, { + args: block.input, + callId: block.id, + name: block.name, + status: "running", + }), + ); + } + } + return; + } + + if (message.type === "tool_call") { + await handleToolMessage( + state, + message as Extract, + ); + return; + } + + if (message.type === "task" && typeof message.text === "string") { + state.taskText.push(message.text); + return; + } + + if (message.type === "status" && message.status) { + state.metadata["cursor_sdk.status"] = message.status; + } +} + +async function handleToolMessage( + state: CursorRunState, + message: CursorSDKToolUseMessage, +): Promise { + const callId = message.call_id; + if (!callId) { + return; + } + + if (message.status === "running") { + if (!state.activeToolSpans.has(callId)) { + state.activeToolSpans.set( + callId, + await startToolSpan(state, { + args: message.args, + callId, + name: message.name, + status: message.status, + truncated: message.truncated, + }), + ); + } + return; + } + + const toolState = + state.activeToolSpans.get(callId) ?? + (await startToolSpan(state, { + args: message.args, + callId, + name: message.name, + status: message.status, + truncated: message.truncated, + })); + finishToolSpan(toolState, { + error: + message.status === "error" ? stringifyUnknown(message.result) : undefined, + metadata: { + "cursor_sdk.tool.status": message.status, + }, + output: message.result, + }); + state.activeToolSpans.delete(callId); +} + +async function handleConversation( + state: CursorRunState, + turns: CursorSDKConversationTurn[], +): Promise { + state.conversationOutput = turns; + for (const turn of turns) { + if (turn.type === "agentConversationTurn") { + for (const step of turn.turn?.steps ?? []) { + await handleConversationStep(state, step); + } + } else if (turn.type === "shellConversationTurn") { + const command = turn.turn?.shellCommand?.command; + if (command) { + const callId = `shell:${state.activeToolSpans.size}:${command}`; + const toolState = await startToolSpan(state, { + args: turn.turn?.shellCommand, + callId, + name: "shell", + status: "completed", + }); + finishToolSpan(toolState, { + metadata: { "cursor_sdk.tool.status": "completed" }, + output: turn.turn?.shellOutput, + }); + } + } + } +} + +async function handleConversationStep( + state: CursorRunState, + step: CursorSDKConversationStep, +): Promise { + if ( + step.type === "assistantMessage" && + typeof step.message?.text === "string" + ) { + state.conversationText.push(step.message.text); + return; + } + + if (step.type !== "toolCall") { + return; + } + + const toolCall = step.message; + const callId = + typeof toolCall?.callId === "string" + ? toolCall.callId + : `conversation-tool:${state.activeToolSpans.size}`; + const toolState = await startToolSpan(state, { + args: extractToolArgs(toolCall), + callId, + name: extractToolName(toolCall), + status: toolCall?.status, + toolCall, + }); + finishToolSpan(toolState, { + error: + toolCall?.status === "error" + ? stringifyUnknown(toolCall.result) + : undefined, + metadata: { + "cursor_sdk.tool.status": toolCall?.status ?? "completed", + }, + output: extractToolResult(toolCall), + }); +} + +function handleStepUpdate( + state: CursorRunState, + step: CursorSDKConversationStep, +): void { + state.metrics["cursor_sdk.steps"] = + (state.metrics["cursor_sdk.steps"] ?? 0) + 1; + if (step.type) { + const stepTypes = state.metadata["cursor_sdk.step_types"]; + if (Array.isArray(stepTypes)) { + if (!stepTypes.includes(step.type)) { + stepTypes.push(step.type); + } + } else { + state.metadata["cursor_sdk.step_types"] = [step.type]; + } + } + if ( + step.type === "assistantMessage" && + typeof step.message?.text === "string" + ) { + state.stepText.push(step.message.text); + } +} + +async function startToolSpan( + state: CursorRunState, + args: { + args?: unknown; + callId: string; + name?: string; + status?: string; + toolCall?: CursorSDKToolCall; + truncated?: { args?: boolean; result?: boolean }; + }, +): Promise { + const name = args.name || "unknown"; + const metadata: Record = { + "cursor_sdk.tool.status": args.status, + "gen_ai.tool.call.id": args.callId, + "gen_ai.tool.name": name, + }; + if (args.truncated?.args !== undefined) { + metadata["cursor_sdk.tool.args_truncated"] = args.truncated.args; + } + if (args.truncated?.result !== undefined) { + metadata["cursor_sdk.tool.result_truncated"] = args.truncated.result; + } + + const span = startSpan({ + event: { + input: args.args, + metadata, + }, + name: `tool: ${name}`, + parent: await state.span.export(), + spanAttributes: { type: SpanTypeAttribute.TOOL }, + }); + + let subAgentSpan: Span | undefined; + if (isSubAgentToolName(name)) { + subAgentSpan = startSpan({ + event: { + input: args.args, + metadata: { + "cursor_sdk.subagent.tool_call_id": args.callId, + "gen_ai.tool.name": name, + }, + }, + name: formatSubAgentSpanName(args.toolCall, args.args), + parent: await span.export(), + spanAttributes: { type: SpanTypeAttribute.TASK }, + }); + } + + return { span, subAgentSpan }; +} + +function finishToolSpan( + toolState: ToolState, + result: { + error?: string; + metadata?: Record; + output?: unknown; + }, +): void { + try { + if (result.error) { + safeLog(toolState.span, { + error: result.error, + metadata: result.metadata, + output: result.output, + }); + if (toolState.subAgentSpan) { + safeLog(toolState.subAgentSpan, { + error: result.error, + metadata: result.metadata, + output: result.output, + }); + } + } else { + safeLog(toolState.span, { + metadata: result.metadata, + output: result.output, + }); + if (toolState.subAgentSpan) { + safeLog(toolState.subAgentSpan, { + metadata: result.metadata, + output: result.output, + }); + } + } + } finally { + toolState.subAgentSpan?.end(); + toolState.span.end(); + } +} + +async function finalizeCursorRun( + state: CursorRunState, + params: { + error?: unknown; + output?: unknown; + result?: CursorSDKRunResult; + } = {}, +): Promise { + if (state.finalized) { + return; + } + state.finalized = true; + + const error = params.error; + const result = params.result ?? state.lastResult; + const output = + params.output ?? + result?.result ?? + state.run?.result ?? + (state.conversationText.length > 0 + ? state.conversationText.join("\n") + : undefined) ?? + state.conversationOutput ?? + (state.streamText.length > 0 ? state.streamText.join("") : undefined) ?? + (state.deltaText.length > 0 ? state.deltaText.join("") : undefined) ?? + (state.stepText.length > 0 ? state.stepText.join("\n") : undefined) ?? + (state.taskText.length > 0 ? state.taskText.join("\n") : undefined); + + try { + const metadata = { + ...state.metadata, + ...(state.run ? extractRunMetadata(state.run) : {}), + ...(result ? extractRunResultMetadata(result) : {}), + }; + if (error) { + safeLog(state.span, { + error: error instanceof Error ? error.message : String(error), + metadata, + metrics: { + ...cleanMetrics(state.metrics), + ...buildDurationMetrics(state.startTime), + }, + output, + }); + } else { + safeLog(state.span, { + metadata, + metrics: { + ...cleanMetrics(state.metrics), + ...buildDurationMetrics(state.startTime), + }, + output, + }); + } + } finally { + endOpenToolSpans(state); + state.span.end(); + } +} + +function endOpenToolSpans(state: CursorRunState, error?: string): void { + for (const [, toolState] of state.activeToolSpans) { + finishToolSpan(toolState, { error }); + } + state.activeToolSpans.clear(); +} + +function sanitizeUserMessage( + message: string | CursorSDKUserMessage | undefined, +): unknown { + if (typeof message === "string" || message === undefined) { + return message; + } + return { + ...message, + images: message.images?.map((image) => { + const imageRecord = image as Record; + return { + ...(typeof imageRecord.url === "string" + ? { url: imageRecord.url } + : {}), + ...(typeof imageRecord.mimeType === "string" + ? { mimeType: imageRecord.mimeType } + : {}), + ...(image.dimension ? { dimension: image.dimension } : {}), + hasData: typeof imageRecord.data === "string", + }; + }), + }; +} + +function extractAgentOptionsMetadata( + options: CursorSDKAgentOptions | Partial | undefined, +): Record { + if (!options) { + return {}; + } + + return { + ...extractModelMetadata(options.model), + ...(options.agentId ? { "cursor_sdk.agent_id": options.agentId } : {}), + ...(options.name ? { "cursor_sdk.agent_name": options.name } : {}), + ...(options.local + ? { + "cursor_sdk.runtime": "local", + "cursor_sdk.local.cwd": Array.isArray(options.local.cwd) + ? options.local.cwd.join(",") + : options.local.cwd, + } + : {}), + ...(options.cloud + ? { + "cursor_sdk.runtime": "cloud", + "cursor_sdk.cloud.auto_create_pr": options.cloud.autoCreatePR, + "cursor_sdk.cloud.env_type": options.cloud.env?.type, + "cursor_sdk.cloud.env_name": options.cloud.env?.name, + "cursor_sdk.cloud.repos": options.cloud.repos + ?.map((repo) => repo.url) + .filter((url): url is string => typeof url === "string"), + } + : {}), + }; +} + +function extractSendMetadata( + options: CursorSDKSendOptions | undefined, +): Record { + if (!options) { + return {}; + } + return { + ...extractModelMetadata(options.model), + ...(options.local?.force !== undefined + ? { "cursor_sdk.local.force": options.local.force } + : {}), + }; +} + +function extractAgentMetadata(agent: CursorSDKAgent): Record { + return { + ...(agent.agentId ? { "cursor_sdk.agent_id": agent.agentId } : {}), + ...extractModelMetadata(agent.model), + }; +} + +function extractRunMetadata( + run: CursorSDKRun | undefined, +): Record { + if (!run) { + return {}; + } + return { + ...(run.id ? { "cursor_sdk.run_id": run.id } : {}), + ...(run.agentId ? { "cursor_sdk.agent_id": run.agentId } : {}), + ...(run.status ? { "cursor_sdk.status": run.status } : {}), + ...(run.durationMs !== undefined + ? { "cursor_sdk.duration_ms": run.durationMs } + : {}), + ...extractModelMetadata(run.model), + ...extractGitMetadata(run.git), + }; +} + +function extractRunResultMetadata( + result: CursorSDKRunResult | undefined, +): Record { + if (!result) { + return {}; + } + return { + ...(result.id ? { "cursor_sdk.run_id": result.id } : {}), + ...(result.status ? { "cursor_sdk.status": result.status } : {}), + ...(result.durationMs !== undefined + ? { "cursor_sdk.duration_ms": result.durationMs } + : {}), + ...extractModelMetadata(result.model), + ...extractGitMetadata(result.git), + }; +} + +function extractGitMetadata( + git: CursorSDKRunGitInfo | undefined, +): Record { + const branches = git?.branches; + if (!branches || branches.length === 0) { + return {}; + } + return { + "cursor_sdk.git.branches": branches.map((branch) => ({ + branch: branch.branch, + prUrl: branch.prUrl, + repoUrl: branch.repoUrl, + })), + }; +} + +function extractModelMetadata( + model: CursorSDKModelSelection | undefined, +): Record { + if (!model?.id) { + return {}; + } + return { + model: model.id, + "cursor_sdk.model": model.id, + ...(model.params ? { "cursor_sdk.model.params": model.params } : {}), + }; +} + +function addUsageMetrics( + metrics: Record, + usage: CursorSDKUsage | undefined, +): void { + if (!usage) { + return; + } + if (usage.inputTokens !== undefined) { + metrics.prompt_tokens = (metrics.prompt_tokens ?? 0) + usage.inputTokens; + } + if (usage.outputTokens !== undefined) { + metrics.completion_tokens = + (metrics.completion_tokens ?? 0) + usage.outputTokens; + } + if (usage.cacheReadTokens !== undefined) { + metrics.prompt_cached_tokens = + (metrics.prompt_cached_tokens ?? 0) + usage.cacheReadTokens; + } + if (usage.cacheWriteTokens !== undefined) { + metrics.prompt_cache_creation_tokens = + (metrics.prompt_cache_creation_tokens ?? 0) + usage.cacheWriteTokens; + } + metrics.tokens = + (metrics.prompt_tokens ?? 0) + + (metrics.completion_tokens ?? 0) + + (metrics.prompt_cached_tokens ?? 0) + + (metrics.prompt_cache_creation_tokens ?? 0); +} + +function buildDurationMetrics(startTime: number): Record { + const end = getCurrentUnixTimestamp(); + return { + duration: end - startTime, + end, + start: startTime, + }; +} + +function extractToolName(toolCall: CursorSDKToolCall | undefined): string { + if (!toolCall) { + return "unknown"; + } + if (typeof toolCall.name === "string") { + return toolCall.name; + } + if (typeof toolCall.type === "string") { + return toolCall.type; + } + return "unknown"; +} + +function extractToolArgs(toolCall: CursorSDKToolCall | undefined): unknown { + return toolCall && "args" in toolCall ? toolCall.args : undefined; +} + +function extractToolResult(toolCall: CursorSDKToolCall | undefined): unknown { + return toolCall && "result" in toolCall ? toolCall.result : undefined; +} + +function isSubAgentToolName(name: string): boolean { + return name === "Agent" || name === "Task" || name === "task"; +} + +function formatSubAgentSpanName( + toolCall: CursorSDKToolCall | undefined, + args: unknown, +): string { + const details = (toolCall ?? args) as Record | undefined; + const description = + getString(details, "description") ?? + getString(details, "subagent_type") ?? + getString(details, "type") ?? + getString(details, "name"); + return description ? `Agent: ${description}` : "Agent: sub-agent"; +} + +function getString( + obj: Record | undefined, + key: string, +): string | undefined { + const value = obj?.[key]; + return typeof value === "string" ? value : undefined; +} + +function stringifyUnknown(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function safeLog(span: Span, event: Parameters[0]): void { + try { + span.log(event); + } catch (error) { + logInstrumentationError("Cursor SDK span log", error); + } +} + +function logInstrumentationError(context: string, error: unknown): void { + debugLogger.error(`Error processing ${context}:`, error); +} + +function cleanMetrics(metrics: Record): Record { + const cleaned: Record = {}; + for (const [key, value] of Object.entries(metrics)) { + if (value !== undefined && Number.isFinite(value)) { + cleaned[key] = value; + } + } + return cleaned; +} diff --git a/js/src/instrumentation/registry.ts b/js/src/instrumentation/registry.ts index 0d3111db4..8e09f214a 100644 --- a/js/src/instrumentation/registry.ts +++ b/js/src/instrumentation/registry.ts @@ -21,6 +21,8 @@ export interface InstrumentationConfig { google?: boolean; huggingface?: boolean; claudeAgentSDK?: boolean; + cursor?: boolean; + cursorSDK?: boolean; openrouter?: boolean; openrouterAgent?: boolean; mistral?: boolean; @@ -111,6 +113,8 @@ class PluginRegistry { google: true, huggingface: true, claudeAgentSDK: true, + cursor: true, + cursorSDK: true, openrouter: true, openrouterAgent: true, mistral: true, @@ -133,7 +137,11 @@ class PluginRegistry { .filter((s) => s.length > 0); for (const sdk of disabled) { - integrations[sdk] = false; + if (sdk === "cursor-sdk") { + integrations.cursorSDK = false; + } else { + integrations[sdk] = false; + } } } diff --git a/js/src/vendor-sdk-types/cursor-sdk.ts b/js/src/vendor-sdk-types/cursor-sdk.ts new file mode 100644 index 000000000..a8bf1bb70 --- /dev/null +++ b/js/src/vendor-sdk-types/cursor-sdk.ts @@ -0,0 +1,337 @@ +/** + * Vendored types for @cursor/sdk used by Braintrust instrumentation. + * + * Keep this surface intentionally narrow. These types are not exported to SDK + * users and should only cover fields we read, wrap, or log. + */ + +export interface CursorSDKModule { + Agent: CursorSDKAgentClass; + [key: string]: unknown; +} + +export interface CursorSDKAgentClass { + create(options: CursorSDKAgentOptions): Promise; + resume( + agentId: string, + options?: Partial, + ): Promise; + prompt( + message: string | CursorSDKUserMessage, + options?: CursorSDKAgentOptions, + ): Promise; + [key: string]: unknown; +} + +export interface CursorSDKAgent { + readonly agentId?: string; + readonly model?: CursorSDKModelSelection; + send( + message: string | CursorSDKUserMessage, + options?: CursorSDKSendOptions, + ): Promise; + close?: () => void; + reload?: () => Promise; + [Symbol.asyncDispose]?: () => Promise; + [key: string | symbol]: unknown; +} + +export interface CursorSDKAgentOptions { + agentId?: string; + apiKey?: string; + model?: CursorSDKModelSelection; + name?: string; + local?: { + cwd?: string | string[]; + settingSources?: string[]; + sandboxOptions?: { enabled?: boolean }; + }; + cloud?: { + env?: { type?: "cloud" | "pool" | "machine"; name?: string }; + repos?: Array<{ url?: string; startingRef?: string; prUrl?: string }>; + autoCreatePR?: boolean; + workOnCurrentBranch?: boolean; + skipReviewerRequest?: boolean; + }; + mcpServers?: Record; + agents?: Record; + [key: string]: unknown; +} + +export interface CursorSDKAgentDefinition { + description?: string; + prompt?: string; + model?: CursorSDKModelSelection | "inherit"; + mcpServers?: unknown[]; +} + +export interface CursorSDKSendOptions { + model?: CursorSDKModelSelection; + mcpServers?: Record; + onDelta?: (args: { + update: CursorSDKInteractionUpdate; + }) => void | Promise; + onStep?: (args: { step: CursorSDKConversationStep }) => void | Promise; + local?: { + force?: boolean; + }; + [key: string]: unknown; +} + +export interface CursorSDKModelSelection { + id?: string; + params?: Array<{ id?: string; value?: string }>; +} + +export interface CursorSDKUserMessage { + text?: string; + images?: CursorSDKImage[]; + [key: string]: unknown; +} + +export type CursorSDKImage = + | { + url?: string; + dimension?: CursorSDKImageDimension; + [key: string]: unknown; + } + | { + data?: string; + mimeType?: string; + dimension?: CursorSDKImageDimension; + [key: string]: unknown; + }; + +export interface CursorSDKImageDimension { + width?: number; + height?: number; +} + +export type CursorSDKRunStatus = "running" | "finished" | "error" | "cancelled"; + +export type CursorSDKRunOperation = + | "stream" + | "wait" + | "cancel" + | "conversation"; + +export interface CursorSDKRun { + readonly id?: string; + readonly agentId?: string; + readonly status?: CursorSDKRunStatus; + readonly result?: string; + readonly model?: CursorSDKModelSelection; + readonly durationMs?: number; + readonly git?: CursorSDKRunGitInfo; + readonly createdAt?: number; + stream(): AsyncGenerator; + wait(): Promise; + conversation(): Promise; + cancel?: () => Promise; + supports?: (operation: CursorSDKRunOperation) => boolean; + unsupportedReason?: (operation: CursorSDKRunOperation) => string | undefined; + onDidChangeStatus?: ( + listener: (status: CursorSDKRunStatus) => void, + ) => () => void; + [key: string]: unknown; +} + +export interface CursorSDKRunResult { + id?: string; + status?: Exclude; + result?: string; + model?: CursorSDKModelSelection; + durationMs?: number; + git?: CursorSDKRunGitInfo; + [key: string]: unknown; +} + +export interface CursorSDKRunGitInfo { + branches?: Array<{ repoUrl?: string; branch?: string; prUrl?: string }>; +} + +export type CursorSDKMessage = + | CursorSDKSystemMessage + | CursorSDKUserMessageEvent + | CursorSDKAssistantMessage + | CursorSDKThinkingMessage + | CursorSDKToolUseMessage + | CursorSDKStatusMessage + | CursorSDKTaskMessage + | CursorSDKRequestMessage + | { type?: string; [key: string]: unknown }; + +export interface CursorSDKSystemMessage { + type: "system"; + subtype?: "init"; + agent_id?: string; + run_id?: string; + model?: CursorSDKModelSelection; + tools?: string[]; +} + +export interface CursorSDKUserMessageEvent { + type: "user"; + agent_id?: string; + run_id?: string; + message?: { role?: "user"; content?: CursorSDKTextBlock[] }; +} + +export interface CursorSDKAssistantMessage { + type: "assistant"; + agent_id?: string; + run_id?: string; + message?: { + role?: "assistant"; + content?: Array; + }; +} + +export interface CursorSDKThinkingMessage { + type: "thinking"; + agent_id?: string; + run_id?: string; + text?: string; + thinking_duration_ms?: number; +} + +export interface CursorSDKToolUseMessage { + type: "tool_call"; + agent_id?: string; + run_id?: string; + call_id?: string; + name?: string; + status?: "running" | "completed" | "error"; + args?: unknown; + result?: unknown; + truncated?: { args?: boolean; result?: boolean }; +} + +export interface CursorSDKStatusMessage { + type: "status"; + agent_id?: string; + run_id?: string; + status?: string; + message?: string; +} + +export interface CursorSDKTaskMessage { + type: "task"; + agent_id?: string; + run_id?: string; + status?: string; + text?: string; +} + +export interface CursorSDKRequestMessage { + type: "request"; + agent_id?: string; + run_id?: string; + request_id?: string; +} + +export interface CursorSDKTextBlock { + type?: "text"; + text?: string; +} + +export interface CursorSDKToolUseBlock { + type?: "tool_use"; + id?: string; + name?: string; + input?: unknown; +} + +export type CursorSDKInteractionUpdate = + | { + type: "text-delta" | "thinking-delta"; + text?: string; + [key: string]: unknown; + } + | { + type: "thinking-completed"; + thinkingDurationMs?: number; + [key: string]: unknown; + } + | { + type: "tool-call-started" | "partial-tool-call" | "tool-call-completed"; + callId?: string; + toolCall?: CursorSDKToolCall; + modelCallId?: string; + status?: string; + [key: string]: unknown; + } + | { + type: "token-delta"; + tokens?: number; + [key: string]: unknown; + } + | { + type: "turn-ended"; + usage?: CursorSDKUsage; + [key: string]: unknown; + } + | { + type: + | "step-started" + | "step-completed" + | "user-message-appended" + | "summary" + | "summary-started" + | "summary-completed" + | "shell-output-delta"; + [key: string]: unknown; + } + | { type?: string; [key: string]: unknown }; + +export interface CursorSDKUsage { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +} + +export interface CursorSDKToolCall { + type?: string; + name?: string; + args?: unknown; + result?: unknown; + truncated?: { args?: boolean; result?: boolean }; + status?: "running" | "completed" | "error"; + [key: string]: unknown; +} + +export type CursorSDKConversationTurn = + | { + type?: "agentConversationTurn"; + turn?: { + userMessage?: { text?: string }; + steps?: CursorSDKConversationStep[]; + }; + [key: string]: unknown; + } + | { + type?: "shellConversationTurn"; + turn?: { + shellCommand?: { command?: string; workingDirectory?: string }; + shellOutput?: { stdout?: string; stderr?: string; exitCode?: number }; + }; + [key: string]: unknown; + }; + +export type CursorSDKConversationStep = + | { + type?: "assistantMessage"; + message?: { text?: string }; + [key: string]: unknown; + } + | { + type?: "thinkingMessage"; + message?: { text?: string; thinkingDurationMs?: number }; + [key: string]: unknown; + } + | { + type?: "toolCall"; + message?: CursorSDKToolCall; + [key: string]: unknown; + }; diff --git a/js/src/wrappers/cursor-sdk.test.ts b/js/src/wrappers/cursor-sdk.test.ts new file mode 100644 index 000000000..28ef5a0fe --- /dev/null +++ b/js/src/wrappers/cursor-sdk.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { tracePromise } = vi.hoisted(() => ({ + tracePromise: vi.fn((fn: () => Promise) => fn()), +})); + +vi.mock("../isomorph", () => ({ + default: { + newTracingChannel: vi.fn(() => ({ + subscribe: vi.fn(), + tracePromise, + unsubscribe: vi.fn(), + })), + }, +})); + +import { wrapCursorSDK } from "./cursor-sdk"; + +describe("wrapCursorSDK", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns invalid modules unchanged", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const sdk = { Cursor: {} }; + + expect(wrapCursorSDK(sdk)).toBe(sdk); + expect(warnSpy).toHaveBeenCalledWith( + "Unsupported Cursor SDK. Not wrapping.", + ); + + warnSpy.mockRestore(); + }); + + it("wraps Agent.create and returned agent.send", async () => { + const run = makeRun(); + const agent = { + agentId: "agent-1", + send: vi.fn(async () => run), + }; + const sdk = { + Agent: class { + static async create() { + return agent; + } + }, + }; + + const wrapped = wrapCursorSDK(sdk); + const created = await wrapped.Agent.create({ local: { cwd: "/tmp/repo" } }); + const result = await created.send("hello", { + onDelta: vi.fn(), + onStep: vi.fn(), + }); + + expect(result).toBe(run); + expect(agent.send).toHaveBeenCalledWith("hello", expect.any(Object)); + expect(tracePromise).toHaveBeenCalledTimes(2); + }); + + it("wraps Agent.resume and preserves private-field-safe method binding", async () => { + class PrivateAgent { + #run = makeRun(); + agentId = "agent-2"; + + async send() { + return this.#run; + } + + async [Symbol.asyncDispose]() { + return undefined; + } + } + + const sdk = { + Agent: class { + static async resume() { + return new PrivateAgent(); + } + }, + }; + + const wrapped = wrapCursorSDK(sdk); + const agent = await wrapped.Agent.resume("agent-2"); + + await expect(agent.send("hello")).resolves.toMatchObject({ id: "run-1" }); + await expect(agent[Symbol.asyncDispose]()).resolves.toBeUndefined(); + }); + + it("wraps Agent.prompt", async () => { + const sdk = { + Agent: class { + static async prompt(message: string) { + return { id: "run-1", result: message, status: "finished" }; + } + }, + }; + + const wrapped = wrapCursorSDK(sdk); + await expect(wrapped.Agent.prompt("hello")).resolves.toMatchObject({ + result: "hello", + }); + expect(tracePromise).toHaveBeenCalledTimes(1); + }); + + it("handles module namespace-like objects", async () => { + const Agent = class { + static async prompt() { + return { status: "finished" }; + } + }; + const sdk = Object.defineProperty({}, "Agent", { + configurable: false, + enumerable: true, + value: Agent, + writable: false, + }); + + const wrapped = wrapCursorSDK(sdk as { Agent: typeof Agent }); + + await expect(wrapped.Agent.prompt("hello")).resolves.toMatchObject({ + status: "finished", + }); + }); +}); + +function makeRun() { + return { + agentId: "agent-1", + async conversation() { + return []; + }, + id: "run-1", + stream: async function* () { + yield { + type: "assistant", + message: { content: [{ text: "hello", type: "text" }] }, + }; + }, + async wait() { + return { id: "run-1", result: "hello", status: "finished" }; + }, + }; +} diff --git a/js/src/wrappers/cursor-sdk.ts b/js/src/wrappers/cursor-sdk.ts new file mode 100644 index 000000000..a35d0f090 --- /dev/null +++ b/js/src/wrappers/cursor-sdk.ts @@ -0,0 +1,182 @@ +import { cursorSDKChannels } from "../instrumentation/plugins/cursor-sdk-channels"; +import type { + CursorSDKAgent, + CursorSDKAgentClass, + CursorSDKAgentOptions, + CursorSDKModule, + CursorSDKRunResult, + CursorSDKSendOptions, + CursorSDKUserMessage, +} from "../vendor-sdk-types/cursor-sdk"; + +const WRAPPED_AGENT = Symbol.for("braintrust.cursor-sdk.wrapped-agent"); + +/** + * Wraps the Cursor TypeScript SDK with Braintrust tracing. The wrapper emits + * diagnostics-channel events; the Cursor SDK plugin owns span lifecycle. + */ +export function wrapCursorSDK(sdk: T): T { + if (!sdk || typeof sdk !== "object") { + return sdk; + } + + const maybeSDK = sdk as Record; + if (!maybeSDK.Agent || typeof maybeSDK.Agent !== "function") { + // eslint-disable-next-line no-restricted-properties -- preserving intentional console usage. + console.warn("Unsupported Cursor SDK. Not wrapping."); + return sdk; + } + + const target = isModuleNamespace(sdk) + ? Object.setPrototypeOf({}, sdk) + : (sdk as Record); + + return new Proxy(target, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (prop === "Agent" && typeof value === "function") { + return wrapCursorAgentClass(value as unknown as CursorSDKAgentClass); + } + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }) as T; +} + +function isModuleNamespace(obj: unknown): boolean { + if (!obj || typeof obj !== "object") { + return false; + } + if (obj.constructor?.name === "Module") { + return true; + } + const keys = Object.keys(obj); + if (keys.length === 0) { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(obj, keys[0]); + return descriptor ? !descriptor.configurable && !descriptor.writable : false; +} + +function wrapCursorAgentClass(Agent: CursorSDKAgentClass): CursorSDKAgentClass { + const cache = new Map(); + + return new Proxy(Agent, { + get(target, prop, receiver) { + if (cache.has(prop)) { + return cache.get(prop); + } + + const value = Reflect.get(target, prop, receiver); + if (prop === "create" && typeof value === "function") { + const wrapped = async function ( + options: CursorSDKAgentOptions, + ): Promise { + const args = [options] as [CursorSDKAgentOptions]; + return cursorSDKChannels.create.tracePromise( + async () => + wrapCursorAgent(await Reflect.apply(value, target, args)), + { arguments: args } as never, + ); + }; + cache.set(prop, wrapped); + return wrapped; + } + + if (prop === "resume" && typeof value === "function") { + const wrapped = async function ( + agentId: string, + options?: Partial, + ): Promise { + const args = [agentId, options] as [ + string, + Partial | undefined, + ]; + return cursorSDKChannels.resume.tracePromise( + async () => + wrapCursorAgent(await Reflect.apply(value, target, args)), + { arguments: args } as never, + ); + }; + cache.set(prop, wrapped); + return wrapped; + } + + if (prop === "prompt" && typeof value === "function") { + const wrapped = async function ( + message: string | CursorSDKUserMessage, + options?: CursorSDKAgentOptions, + ): Promise { + const args = [message, options] as [ + string | CursorSDKUserMessage, + CursorSDKAgentOptions | undefined, + ]; + return cursorSDKChannels.prompt.tracePromise( + () => Reflect.apply(value, target, args), + { arguments: args } as never, + ); + }; + cache.set(prop, wrapped); + return wrapped; + } + + if (typeof value === "function") { + const bound = value.bind(target); + cache.set(prop, bound); + return bound; + } + + return value; + }, + }) as CursorSDKAgentClass; +} + +function wrapCursorAgent(agent: CursorSDKAgent): CursorSDKAgent { + if (!agent || typeof agent !== "object") { + return agent; + } + if ((agent as Record)[WRAPPED_AGENT]) { + return agent; + } + + const proxy = new Proxy(agent, { + get(target, prop, receiver) { + if (prop === WRAPPED_AGENT) { + return true; + } + + const value = Reflect.get(target, prop, receiver); + if (prop === "send" && typeof value === "function") { + return function ( + message: string | CursorSDKUserMessage, + options?: CursorSDKSendOptions, + ) { + const args = [message, options] as [ + string | CursorSDKUserMessage, + CursorSDKSendOptions | undefined, + ]; + return cursorSDKChannels.send.tracePromise( + () => Reflect.apply(value, target, args), + { + agent: target, + arguments: args, + operation: "send", + } as never, + ); + }; + } + + if (typeof value === "function") { + return value.bind(target); + } + + return value; + }, + }); + + return proxy as CursorSDKAgent; +} + +export type { CursorSDKModule }; diff --git a/turbo.json b/turbo.json index bad5b66ae..809a67834 100644 --- a/turbo.json +++ b/turbo.json @@ -6,6 +6,7 @@ "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", @@ -27,6 +28,7 @@ "BRAINTRUST_API_KEY", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", @@ -46,6 +48,7 @@ "BRAINTRUST_E2E_RUN_CONTEXT_DIR", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", @@ -70,6 +73,7 @@ "BRAINTRUST_E2E_RUN_CONTEXT_DIR", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", @@ -89,6 +93,7 @@ "BRAINTRUST_E2E_RUN_CONTEXT_DIR", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", @@ -122,6 +127,7 @@ "BRAINTRUST_API_KEY", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", @@ -137,6 +143,7 @@ "BRAINTRUST_API_KEY", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", @@ -152,6 +159,7 @@ "BRAINTRUST_API_KEY", "GEMINI_API_KEY", "COHERE_API_KEY", + "CURSOR_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY",