Files
2026-07-13 13:39:12 +08:00

529 lines
18 KiB
TypeScript

/**
* 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<Uint8Array>).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
// (</think>, <|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<Uint8Array>).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}`
);
}
);