/** * Cursor end-to-end integration test. * * Skipped unless `CURSOR_E2E_TOKEN` env var is set. Exercises the full * OpenAI-compatible flow against cursor's real `agent.v1.AgentService/Run` * endpoint, across both auto/claude (tests 1-4) and composer-2.5 (tests 5-9): * * 1. Single-turn plain chat * 2. System prompt biasing * 3. Tool-use single-turn (request → tool_calls) * 4. Streaming SSE incremental delivery * 5. composer-2.5 plain chat (+ usage present) * 6. composer-2.5 reasoning surfaced as reasoning_content (no marker leakage) * 7. composer-2.5 multi-turn tool round-trip (inline h2 session reuse) * 8. composer-2.5 cold-resume fallback (no live session) * 9. composer-2.5 streaming (+ usage chunk) * * The composer model id is overridable with CURSOR_E2E_MODEL (e.g. * composer-2.5-fast). * * To run: * CURSOR_E2E_TOKEN=$(cat ~/.cursor/access-token) \ * node --import tsx --test tests/integration/cursor-e2e.test.ts * * Capturing wire fixtures (separate workflow): * CURSOR_TOKEN=... node scripts/ad-hoc/cursor-tap.cjs single-turn-chat "say PING" */ import test from "node:test"; import assert from "node:assert/strict"; import zlib from "node:zlib"; const TOKEN = process.env.CURSOR_E2E_TOKEN; const skipReason = TOKEN ? undefined : "CURSOR_E2E_TOKEN not set"; // Model used by the composer-specific regression tests below. Override with // CURSOR_E2E_MODEL to exercise composer-2.5-fast or another id. const COMPOSER_MODEL = process.env.CURSOR_E2E_MODEL || "composer-2.5"; // Vision-capable model for the image tests. composer-2.5 and the claude/gpt // ids all accept inline images; default to a gpt id, override with // CURSOR_E2E_VISION_MODEL. A public solid-color image service backs the // URL-image test (override with CURSOR_E2E_IMAGE_URL). const VISION_MODEL = process.env.CURSOR_E2E_VISION_MODEL || "gpt-5.2"; const RED_IMAGE_URL = process.env.CURSOR_E2E_IMAGE_URL || "https://dummyimage.com/80x80/ff0000/ff0000.png"; // Build a valid solid-color PNG (size x size, truecolor) with no deps — used // to prove a vision model actually reads the inline image bytes. function solidColorPng(size: number, rgb: [number, number, number]): Buffer { const crc32 = (buf: Buffer): number => { let c = ~0; for (let i = 0; i < buf.length; i++) { c ^= buf[i]; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1; } return (~c) >>> 0; }; const chunk = (type: string, data: Buffer): Buffer => { const t = Buffer.from(type, "ascii"); const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); const body = Buffer.concat([t, data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0); return Buffer.concat([len, body, crc]); }; const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 2; // color type 2 = truecolor RGB const row = Buffer.concat([ Buffer.from([0]), Buffer.concat(Array.from({ length: size }, () => Buffer.from(rgb))), ]); const raw = Buffer.concat(Array.from({ length: size }, () => row)); const idat = zlib.deflateSync(raw); return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]); } const weatherTools = [ { type: "function", function: { name: "get_weather", description: "Get current weather for a city", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"], }, }, }, ]; test( "[cursor-e2e] single-turn plain chat returns assistant text", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: "auto", body: { messages: [{ role: "user", content: "say only PING" }] }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.equal(json.choices[0].finish_reason, "stop"); assert.match(json.choices[0].message.content, /PING/i); } ); test("[cursor-e2e] system prompt biases the response", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: "auto", body: { messages: [ { role: "system", content: "Reply with exactly the word HAIKU and nothing else." }, { role: "user", content: "hi" }, ], }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.match(json.choices[0].message.content, /HAIKU/); }); test("[cursor-e2e] tool-use single-turn returns tool_calls", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: "claude-4.6-sonnet-medium", body: { messages: [{ role: "user", content: "What's the weather in Paris? Use the tool." }], tools: [ { type: "function", function: { name: "get_weather", description: "Get current weather for a city", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"], }, }, }, ], }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.equal(json.choices[0].finish_reason, "tool_calls"); const toolCall = json.choices[0].message.tool_calls?.[0]; assert.ok(toolCall, "expected a tool_call"); assert.equal(toolCall.function.name, "get_weather"); assert.match(toolCall.function.arguments, /Paris/); }); test( "[cursor-e2e] streaming SSE delivers chunks before the upstream closes", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: "auto", body: { messages: [{ role: "user", content: "count from 1 to 5" }] }, stream: true, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const reader = (result.response.body as ReadableStream).getReader(); const decoder = new TextDecoder(); let chunks = 0; let totalText = ""; let firstChunkTime: number | null = null; const startTime = Date.now(); while (true) { const { done, value } = await reader.read(); if (done) break; if (firstChunkTime == null) firstChunkTime = Date.now(); const text = decoder.decode(value); chunks++; totalText += text; } void firstChunkTime; void startTime; // Multiple SSE chunks (not one big buffered blob) proves we're streaming // emit-as-decoded. The exact latency ratio depends on cursor's pacing // and isn't worth asserting tightly. assert.ok(chunks > 1, `expected multiple chunks; got ${chunks}`); assert.match(totalText, /data: \[DONE\]/); } ); // ─── composer-2.5 regression coverage ────────────────────────────────────── // // The four tests above cover auto/claude single-turn. These add coverage for // the composer model specifically plus the two highest-value untested paths: // the multi-turn tool round-trip (inline h2 session reuse) and the cold-resume // fallback. All were validated end-to-end against the live endpoint. test("[cursor-e2e] composer-2.5 plain chat returns assistant text", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: COMPOSER_MODEL, body: { messages: [{ role: "user", content: "Say only the word PING and nothing else." }] }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.equal(json.choices[0].finish_reason, "stop"); assert.match(json.choices[0].message.content, /PING/i); // Usage is always present on the success path (OpenAI contract). assert.equal(typeof json.usage?.total_tokens, "number"); }); test( "[cursor-e2e] composer-2.5 surfaces reasoning as reasoning_content", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: COMPOSER_MODEL, body: { messages: [ { role: "user", content: "Think step by step: what is 17 * 23? Then give the answer." }, ], }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); // The final answer is plain text — reasoning must NOT leak control tokens // (, <|final|>, <|tool_calls_begin|>) into the visible content. const content = json.choices[0].message.content || ""; assert.match(content, /391/); assert.doesNotMatch(content, /<\|?tool_calls_begin|<\/think>|<\|?final\|?>/); } ); test( "[cursor-e2e] composer-2.5 multi-turn tool round-trip reuses the h2 session", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const { cursorSessionManager } = await import( "../../open-sse/services/cursorSessionManager.ts" ); const exec = new CursorExecutor(); const conversationId = `e2e-rt-${Date.now()}`; // Turn 1: declare a tool → expect tool_calls + a retained session. const r1 = await exec.execute({ model: COMPOSER_MODEL, body: { conversation_id: conversationId, messages: [{ role: "user", content: "What's the weather in Paris? Call get_weather." }], tools: weatherTools, }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); const j1 = await r1.response.json(); assert.equal(j1.choices[0].finish_reason, "tool_calls"); const toolCall = j1.choices[0].message.tool_calls?.[0]; assert.ok(toolCall, "expected a tool_call on turn 1"); assert.equal(toolCall.function.name, "get_weather"); assert.ok( cursorSessionManager.has(conversationId), "session should be retained for inline resume" ); // Turn 2: same conversation_id, append the tool result → final answer. const r2 = await exec.execute({ model: COMPOSER_MODEL, body: { conversation_id: conversationId, messages: [ { role: "user", content: "What's the weather in Paris? Call get_weather." }, { role: "assistant", content: null, tool_calls: [toolCall] }, { role: "tool", tool_call_id: toolCall.id, name: "get_weather", content: '{"temp_c": 19, "condition": "sunny"}', }, ], tools: weatherTools, }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); const j2 = await r2.response.json(); assert.equal(r2.response.status, 200); assert.match(j2.choices[0].message.content || "", /19|sunny/i); } ); test( "[cursor-e2e] composer-2.5 cold-resume incorporates a tool result without a live session", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); // Brand-new conversation_id with a fabricated prior tool call/result and no // session ever opened → acquire() misses, exercising the cold-resume path // (fresh RunRequest with full history flattened into UserText). const result = await exec.execute({ model: COMPOSER_MODEL, body: { conversation_id: `e2e-cold-${Date.now()}`, messages: [ { role: "user", content: "What's the weather in Tokyo? Call get_weather." }, { role: "assistant", content: null, tool_calls: [ { id: "call_cold_1", type: "function", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' }, }, ], }, { role: "tool", tool_call_id: "call_cold_1", name: "get_weather", content: '{"temp_c": 8, "condition": "rainy"}', }, ], tools: weatherTools, }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.match(json.choices[0].message.content || "", /8|rainy/i); } ); test( "[cursor-e2e] composer-2.5 honors response_format json_object", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: COMPOSER_MODEL, body: { messages: [ { role: "user", content: "Give me a fake user profile with fields name, age, and city." }, ], response_format: { type: "json_object" }, }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); const content = (json.choices[0].message.content || "").trim(); // cursor's agent endpoint has no native response_format; the OUTPUT // CONSTRAINTS prompt injection is what makes the model return raw JSON. const parsed = JSON.parse(content); assert.equal(typeof parsed, "object"); } ); test( "[cursor-e2e] composer-2.5 streaming delivers incremental chunks", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: COMPOSER_MODEL, body: { messages: [{ role: "user", content: "Count from 1 to 5, one number per line." }] }, stream: true, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const reader = (result.response.body as ReadableStream).getReader(); const decoder = new TextDecoder(); let chunks = 0; let sawUsage = false; let totalText = ""; while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value); chunks++; totalText += text; if (text.includes('"usage"')) sawUsage = true; } assert.ok(chunks > 1, `expected multiple chunks; got ${chunks}`); assert.match(totalText, /data: \[DONE\]/); assert.ok(sawUsage, "streaming response should include a usage chunk"); } ); test( "[cursor-e2e] base64 image_url reaches a vision model (sees the color)", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const png = solidColorPng(64, [255, 0, 0]); // solid red const dataUri = `data:image/png;base64,${png.toString("base64")}`; const result = await exec.execute({ model: VISION_MODEL, body: { messages: [ { role: "user", content: [ { type: "text", text: "What single color is this square? Answer with just the color name.", }, { type: "image_url", image_url: { url: dataUri } }, ], }, ], }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.equal(json.choices[0].finish_reason, "stop"); assert.match( json.choices[0].message.content, /red/i, `vision model should report red; got: ${json.choices[0].message.content}` ); } ); test( "[cursor-e2e] remote image_url is fetched and reaches a vision model", { skip: skipReason }, async () => { const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); const exec = new CursorExecutor(); const result = await exec.execute({ model: VISION_MODEL, body: { messages: [ { role: "user", content: [ { type: "text", text: "What single color is this square? Answer with just the color name.", }, { type: "image_url", image_url: { url: RED_IMAGE_URL } }, ], }, ], }, stream: false, credentials: { accessToken: TOKEN }, signal: undefined, log: () => {}, upstreamExtraHeaders: undefined, }); assert.equal(result.response.status, 200); const json = await result.response.json(); assert.match( json.choices[0].message.content, /red/i, `vision model should report red; got: ${json.choices[0].message.content}` ); } );