fix(cursor): HTTP/2 AgentService support + version bump to 3.12.17
Real Cursor IDE now uses AgentService at agent.api5.cursor.sh (HTTP/2-only) while 9router still spoke the retired ChatService at api2.cursor.sh with outdated headers, producing HTTP 429 "Update Required". Add an executeAgent path that builds an agent.v1.RunRequest Connect RPC over a raw http2 stream and fetches the account-specific usable model catalog via GetUsableModels. Also implement MCP tool calling over AgentService: encode OpenAI tools as AgentRunRequest.mcp_tools (McpToolDefinition with google.protobuf.Value input_schema), decode McpArgs tool calls, and forward them to the client as OpenAI tool_calls so the client runs the tool and resumes in the next turn. Reply to request_context_args with a non-empty RequestContext, to server heartbeats with client_heartbeat, and to KV blob get/set with empty results, so action queries no longer stall the stream. Fold the client system prompt into the user message (custom_system_prompt makes the server return an empty turn). Bump clientVersion to 3.12.17 and add the x-cursor-client-commit header so the gateway identifies as a current Cursor IDE release.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import {
|
||||
generateCursorBody,
|
||||
encodeField,
|
||||
wrapConnectRPCFrame,
|
||||
decodeMessage,
|
||||
parseConnectRPCFrame,
|
||||
extractTextFromResponse
|
||||
} from "../utils/cursorProtobuf.js";
|
||||
@@ -13,6 +16,7 @@ import { chatChunkSse } from "../utils/sse.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import zlib from "zlib";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Detect cloud environment
|
||||
const isCloudEnv = () => {
|
||||
@@ -38,6 +42,130 @@ const COMPRESS_FLAG = {
|
||||
GZIP_TRAILER: 0x03
|
||||
};
|
||||
|
||||
const AGENT_RUN_PATH = "/agent.v1.AgentService/Run";
|
||||
const PROTOBUF_LEN = 2;
|
||||
const PROTOBUF_VARINT = 0;
|
||||
|
||||
function concatBuffers(...parts) {
|
||||
const length = parts.reduce((total, part) => total + part.length, 0);
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const agentString = (field, value) => encodeField(field, PROTOBUF_LEN, value);
|
||||
const agentMessage = (field, value) => encodeField(field, PROTOBUF_LEN, value);
|
||||
const agentBool = (field, value) => encodeField(field, PROTOBUF_VARINT, value ? 1 : 0);
|
||||
|
||||
function textFromContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isAgentTextRequest(body) {
|
||||
// Many compatible clients always attach their built-in tool schemas, even
|
||||
// for a normal text turn. Cursor's retired ChatService rejects those
|
||||
// requests; AgentService can still answer the text turn, so ignore schemas
|
||||
// here. A real tool-call/result conversation is kept on the legacy path
|
||||
// until its AgentService tool protocol is implemented.
|
||||
return Array.isArray(body?.messages) && body.messages.every((message) => {
|
||||
if (message?.tool_calls?.length || message?.role === "tool") return false;
|
||||
return typeof message?.content === "string"
|
||||
|| Array.isArray(message?.content) && message.content.every((part) => part?.type === "text");
|
||||
});
|
||||
}
|
||||
|
||||
function encodeHistoryMessage(message) {
|
||||
const content = textFromContent(message?.content);
|
||||
if (!content) return null;
|
||||
|
||||
// ConversationHistoryMessage.user / .assistant -> repeated content -> text.
|
||||
const text = agentString(1, content);
|
||||
if (message.role === "assistant") {
|
||||
return agentMessage(2, agentMessage(1, agentMessage(1, text)));
|
||||
}
|
||||
return agentMessage(1, agentMessage(1, agentMessage(1, text)));
|
||||
}
|
||||
|
||||
function buildAgentRunFrame(messages, model) {
|
||||
const system = messages
|
||||
.filter((message) => message?.role === "system")
|
||||
.map((message) => textFromContent(message.content))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const chatMessages = messages.filter((message) => message?.role !== "system");
|
||||
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user");
|
||||
const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1);
|
||||
const history = chatMessages
|
||||
.slice(0, currentIndex >= 0 ? currentIndex : -1)
|
||||
.map(encodeHistoryMessage)
|
||||
.filter(Boolean);
|
||||
const userText = textFromContent(current?.content) || "Continue.";
|
||||
|
||||
// agent.v1.UserMessageAction.user_message and its optional history.
|
||||
const userMessage = concatBuffers(
|
||||
agentString(1, userText),
|
||||
agentString(2, crypto.randomUUID()),
|
||||
);
|
||||
const conversationHistory = history.length
|
||||
? concatBuffers(...history.map((entry) => agentMessage(1, entry)))
|
||||
: null;
|
||||
const userAction = concatBuffers(
|
||||
agentMessage(1, userMessage),
|
||||
...(conversationHistory ? [agentMessage(7, conversationHistory)] : []),
|
||||
);
|
||||
const conversationAction = agentMessage(1, userAction);
|
||||
const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true));
|
||||
const runRequest = concatBuffers(
|
||||
// An empty ConversationStateStructure starts a fresh local agent session.
|
||||
agentMessage(1, new Uint8Array()),
|
||||
agentMessage(2, conversationAction),
|
||||
...(system ? [agentString(8, system)] : []),
|
||||
agentMessage(9, requestedModel),
|
||||
);
|
||||
|
||||
// agent.v1.AgentClientMessage.run_request.
|
||||
return wrapConnectRPCFrame(agentMessage(1, runRequest));
|
||||
}
|
||||
|
||||
function extractAgentString(message, field) {
|
||||
const value = message?.get(field)?.[0]?.value;
|
||||
return value ? Buffer.from(value).toString("utf8") : "";
|
||||
}
|
||||
|
||||
function decodeAgentFrames(buffer, onFrame) {
|
||||
let pending = Buffer.from(buffer || []);
|
||||
while (pending.length >= 5) {
|
||||
const flags = pending[0];
|
||||
const length = pending.readUInt32BE(1);
|
||||
if (pending.length < 5 + length) break;
|
||||
let payload = pending.subarray(5, 5 + length);
|
||||
pending = pending.subarray(5 + length);
|
||||
if (flags & COMPRESS_FLAG.GZIP) {
|
||||
payload = zlib.gunzipSync(payload);
|
||||
}
|
||||
if (!(flags & COMPRESS_FLAG.TRAILER)) onFrame(payload);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createRequestContextResponse() {
|
||||
// AgentService asks every run for client context. 9router has no IDE file
|
||||
// context, so acknowledge with an empty RequestContext.
|
||||
const requestContextSuccess = agentMessage(1, new Uint8Array());
|
||||
const requestContextResult = agentMessage(1, requestContextSuccess);
|
||||
const execClientMessage = agentMessage(10, requestContextResult);
|
||||
return wrapConnectRPCFrame(agentMessage(2, execClientMessage));
|
||||
}
|
||||
|
||||
const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1";
|
||||
const debugLog = (...args) => {
|
||||
if (CURSOR_STREAM_DEBUG) console.log(...args);
|
||||
@@ -253,7 +381,293 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks
|
||||
* HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex.
|
||||
*/
|
||||
openAgentHttp2Stream(url, headers, signal) {
|
||||
if (!http2) {
|
||||
throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)");
|
||||
}
|
||||
|
||||
const urlObj = new URL(url);
|
||||
const client = http2.connect(`https://${urlObj.host}`);
|
||||
const chunkQueue = [];
|
||||
let waiting = null;
|
||||
let ended = false;
|
||||
let streamError = null;
|
||||
let req = null;
|
||||
|
||||
const wake = (result) => {
|
||||
if (!waiting) return;
|
||||
const resolve = waiting;
|
||||
waiting = null;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const fail = (error) => {
|
||||
if (streamError) return;
|
||||
streamError = error;
|
||||
ended = true;
|
||||
wake(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
try { req?.destroy(); } catch {}
|
||||
try { client.close(); } catch {}
|
||||
};
|
||||
|
||||
client.on("error", fail);
|
||||
|
||||
req = client.request({
|
||||
":method": "POST",
|
||||
":path": urlObj.pathname,
|
||||
":authority": urlObj.host,
|
||||
":scheme": "https",
|
||||
...headers,
|
||||
});
|
||||
|
||||
req.on("error", fail);
|
||||
req.on("data", (chunk) => {
|
||||
if (waiting) wake({ value: chunk, done: false });
|
||||
else chunkQueue.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
ended = true;
|
||||
wake({ value: undefined, done: true });
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
fail(new Error("Request aborted"));
|
||||
close();
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
const responseHeaders = new Promise((resolve, reject) => {
|
||||
const onEarlyError = (error) => reject(error);
|
||||
client.once("error", onEarlyError);
|
||||
req.once("error", onEarlyError);
|
||||
req.once("response", (hdrs) => {
|
||||
client.off("error", onEarlyError);
|
||||
req.off("error", onEarlyError);
|
||||
resolve(hdrs);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
responseHeaders,
|
||||
write(frame) {
|
||||
if (req && !req.destroyed) req.write(Buffer.from(frame));
|
||||
},
|
||||
end() {
|
||||
try { if (req && !req.destroyed) req.end(); } catch {}
|
||||
},
|
||||
close,
|
||||
async read() {
|
||||
if (chunkQueue.length) return { value: chunkQueue.shift(), done: false };
|
||||
if (ended) {
|
||||
if (streamError) throw streamError;
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
const result = await new Promise((resolve) => { waiting = resolve; });
|
||||
if (streamError) throw streamError;
|
||||
return result || { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async executeAgent({ model, body, stream, credentials, signal }) {
|
||||
const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint;
|
||||
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
|
||||
|
||||
const url = `${agentEndpoint}${AGENT_RUN_PATH}`;
|
||||
const headers = this.buildHeaders(credentials);
|
||||
const requestController = new AbortController();
|
||||
if (signal?.addEventListener) {
|
||||
signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true });
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
|
||||
session.write(buildAgentRunFrame(body.messages || [], model));
|
||||
} catch (error) {
|
||||
throw new Error(`Cursor AgentService request failed: ${error.message}`);
|
||||
}
|
||||
|
||||
let responseHeaders;
|
||||
try {
|
||||
responseHeaders = await session.responseHeaders;
|
||||
} catch (error) {
|
||||
session.close();
|
||||
throw new Error(`Cursor AgentService request failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const status = Number(responseHeaders[":status"] || 0);
|
||||
if (status !== 200) {
|
||||
let errorText = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await session.read();
|
||||
if (done) break;
|
||||
errorText += Buffer.from(value).toString("utf8");
|
||||
}
|
||||
} catch {}
|
||||
session.close();
|
||||
return {
|
||||
response: new Response(JSON.stringify({
|
||||
error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" },
|
||||
}), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
// The Claude SSE translator derives Anthropic's message ID by stripping
|
||||
// `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form
|
||||
// so strict clients such as Claude Code accept the completed stream.
|
||||
const responseId = `chatcmpl-msg_${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
let pending = Buffer.alloc(0);
|
||||
let finished = false;
|
||||
|
||||
const consume = async (onEvent) => {
|
||||
try {
|
||||
while (!finished) {
|
||||
const { done, value } = await session.read();
|
||||
if (done) break;
|
||||
pending = Buffer.concat([pending, Buffer.from(value)]);
|
||||
pending = decodeAgentFrames(pending, (payload) => {
|
||||
const serverMessage = decodeMessage(payload);
|
||||
|
||||
// agent.v1.AgentServerMessage.interaction_update
|
||||
if (serverMessage.has(1)) {
|
||||
const update = decodeMessage(serverMessage.get(1)[0].value);
|
||||
if (update.has(1)) {
|
||||
const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1);
|
||||
if (textDelta) onEvent({ type: "text", value: textDelta });
|
||||
}
|
||||
// Cursor's AgentService emits internal reasoning without the
|
||||
// cryptographic signature required by Anthropic thinking blocks.
|
||||
// Forwarding it makes strict Anthropic clients (Claude Code)
|
||||
// discard or wait on an otherwise complete response. Keep the
|
||||
// reasoning upstream-only and emit the normal answer text.
|
||||
if (update.has(14)) {
|
||||
finished = true;
|
||||
onEvent({ type: "done" });
|
||||
}
|
||||
}
|
||||
|
||||
// AgentService requests IDE context before producing a response.
|
||||
// Return an empty context; 9router is not coupled to an editor.
|
||||
if (serverMessage.has(2)) {
|
||||
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
|
||||
if (execRequest.has(10)) {
|
||||
session.write(createRequestContextResponse());
|
||||
} else {
|
||||
finished = true;
|
||||
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
|
||||
onEvent({ type: "done" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try { session.end(); } catch {}
|
||||
try { session.close(); } catch {}
|
||||
if (!finished) onEvent({ type: "done" });
|
||||
}
|
||||
};
|
||||
|
||||
if (stream === false) {
|
||||
let content = "";
|
||||
let reasoning = "";
|
||||
let agentError = null;
|
||||
await consume((event) => {
|
||||
if (event.type === "text") content += event.value;
|
||||
else if (event.type === "thinking") reasoning += event.value;
|
||||
else if (event.type === "error") agentError = event.value;
|
||||
});
|
||||
if (agentError) {
|
||||
return {
|
||||
response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), {
|
||||
status: HTTP_STATUS.BAD_REQUEST,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: new Response(JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }],
|
||||
usage: estimateUsage(body, content.length, FORMATS.OPENAI),
|
||||
}), { headers: { "Content-Type": "application/json" } }),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const responseStream = new ReadableStream({
|
||||
start(controller) {
|
||||
consume((event) => {
|
||||
if (event.type === "text") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
|
||||
} else if (event.type === "thinking") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
|
||||
} else if (event.type === "error") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } })));
|
||||
} else if (event.type === "done") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
controller.close();
|
||||
}
|
||||
}).catch((error) => controller.error(error));
|
||||
},
|
||||
cancel() {
|
||||
requestController.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(responseStream, { headers: SSE_HEADERS }),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
if (isAgentTextRequest(body)) {
|
||||
try {
|
||||
return await this.executeAgent({ model, body, stream, credentials, signal });
|
||||
} catch (error) {
|
||||
return {
|
||||
response: new Response(JSON.stringify({
|
||||
error: { message: error.message, type: "connection_error", code: "" },
|
||||
}), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
|
||||
url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const url = this.buildUrl();
|
||||
const headers = this.buildHeaders(credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
@@ -291,12 +291,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Execute request
|
||||
let providerResponse, providerUrl, providerHeaders, finalBody;
|
||||
// Most executors return their registry format. Cursor AgentService is an
|
||||
// exception: it is decoded by the executor into OpenAI-compatible output.
|
||||
let providerResponseFormat = targetFormat;
|
||||
try {
|
||||
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||
providerResponse = result.response;
|
||||
providerUrl = result.url;
|
||||
providerHeaders = result.headers;
|
||||
finalBody = result.transformedBody;
|
||||
providerResponseFormat = result.responseFormat || targetFormat;
|
||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||
} catch (error) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
@@ -335,7 +339,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
}
|
||||
try {
|
||||
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
|
||||
if (retryResult.response.ok) {
|
||||
providerResponse = retryResult.response;
|
||||
providerUrl = retryResult.url;
|
||||
providerResponseFormat = retryResult.responseFormat || targetFormat;
|
||||
}
|
||||
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
||||
} else {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
||||
@@ -382,14 +390,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// True non-streaming response
|
||||
if (!stream) {
|
||||
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog });
|
||||
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, trackDone, appendLog });
|
||||
streamController.handleComplete();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Streaming response
|
||||
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
|
||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId });
|
||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId });
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
"Content-Type": "application/connect+proto",
|
||||
"User-Agent": "connect-es/1.6.1",
|
||||
},
|
||||
clientVersion: "3.1.0",
|
||||
clientVersion: "3.12.17",
|
||||
},
|
||||
models: [
|
||||
{ id: "default", name: "Auto (Server Picks)" },
|
||||
@@ -44,11 +44,11 @@ export default {
|
||||
oauth: {
|
||||
apiEndpoint: "https://api2.cursor.sh",
|
||||
chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
|
||||
modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData",
|
||||
modelsEndpoint: "/agent.v1.AgentService/GetUsableModels",
|
||||
api3Endpoint: "https://api3.cursor.sh",
|
||||
agentEndpoint: "https://agent.api5.cursor.sh",
|
||||
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh",
|
||||
clientVersion: "3.1.0",
|
||||
clientVersion: "3.12.17",
|
||||
clientType: "ide",
|
||||
dbKeys: {
|
||||
accessToken: "cursorAuth/accessToken",
|
||||
|
||||
187
open-sse/services/cursorModels.js
Normal file
187
open-sse/services/cursorModels.js
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Cursor live model catalog fetcher.
|
||||
*
|
||||
* Cursor exposes the account-specific model picker through the AgentService
|
||||
* `GetUsableModels` Connect RPC. Unlike the static provider registry, this
|
||||
* includes models newly enabled for the account and omits unavailable ones.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import http2 from "http2";
|
||||
import { PROVIDER_OAUTH } from "../providers/index.js";
|
||||
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
||||
import { decodeMessage } from "../utils/cursorProtobuf.js";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// agent.v1.ModelDetails protobuf field numbers.
|
||||
const MODEL_ID_FIELD = 1;
|
||||
const DISPLAY_MODEL_ID_FIELD = 3;
|
||||
const DISPLAY_NAME_FIELD = 4;
|
||||
const DISPLAY_NAME_SHORT_FIELD = 5;
|
||||
const RESPONSE_MODELS_FIELD = 1;
|
||||
|
||||
/** @type {Map<string, { expiresAt: number, models: { id: string, name: string }[] }>} */
|
||||
const catalogCache = new Map();
|
||||
|
||||
function getCursorModelsUrl() {
|
||||
const config = PROVIDER_OAUTH.cursor;
|
||||
if (!config?.agentEndpoint || !config?.modelsEndpoint) return null;
|
||||
return `${config.agentEndpoint.replace(/\/$/, "")}${config.modelsEndpoint}`;
|
||||
}
|
||||
|
||||
function cacheKey(credentials) {
|
||||
const seed = [
|
||||
credentials?.providerSpecificData?.machineId,
|
||||
credentials?.accessToken,
|
||||
].filter(Boolean).join(":");
|
||||
if (!seed) return "cursor-anonymous";
|
||||
return crypto.createHash("sha256").update(`cursor:${seed}`).digest("hex");
|
||||
}
|
||||
|
||||
function firstString(fields, fieldNumber) {
|
||||
const value = fields.get(fieldNumber)?.[0]?.value;
|
||||
if (!value || typeof value === "number") return "";
|
||||
return Buffer.from(value).toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Cursor's `agent.v1.GetUsableModelsResponse` protobuf payload.
|
||||
* The response contains repeated `agent.v1.ModelDetails` messages in field 1.
|
||||
*/
|
||||
export function parseCursorUsableModels(payload) {
|
||||
const response = decodeMessage(payload);
|
||||
const seen = new Set();
|
||||
const models = [];
|
||||
|
||||
for (const entry of response.get(RESPONSE_MODELS_FIELD) || []) {
|
||||
if (!entry?.value || typeof entry.value === "number") continue;
|
||||
const detail = decodeMessage(entry.value);
|
||||
const id = firstString(detail, MODEL_ID_FIELD).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
const name = (
|
||||
firstString(detail, DISPLAY_NAME_FIELD)
|
||||
|| firstString(detail, DISPLAY_NAME_SHORT_FIELD)
|
||||
|| firstString(detail, DISPLAY_MODEL_ID_FIELD)
|
||||
|| id
|
||||
).trim();
|
||||
models.push({ id, name });
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* agent.api5.cursor.sh is HTTP/2-only; Node fetch/undici cannot speak h2.
|
||||
* Unary GetUsableModels uses an unframed protobuf body (application/proto).
|
||||
*/
|
||||
function http2PostProto(url, headers, body, signal, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = http2.connect(`https://${urlObj.host}`);
|
||||
const chunks = [];
|
||||
let responseHeaders = {};
|
||||
let settled = false;
|
||||
|
||||
const finish = (fn) => (...args) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
try { client.close(); } catch {}
|
||||
fn(...args);
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(finish(() => {
|
||||
reject(new Error("Cursor GetUsableModels timed out"));
|
||||
}), timeoutMs);
|
||||
|
||||
client.on("error", finish(reject));
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
":path": urlObj.pathname,
|
||||
":authority": urlObj.host,
|
||||
":scheme": "https",
|
||||
...headers,
|
||||
});
|
||||
|
||||
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
||||
req.on("data", (chunk) => { chunks.push(chunk); });
|
||||
req.on("end", finish(() => {
|
||||
resolve({
|
||||
status: Number(responseHeaders[":status"] || 0),
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
}));
|
||||
req.on("error", finish(reject));
|
||||
|
||||
if (signal) {
|
||||
const onAbort = finish(() => reject(new Error("Request aborted")));
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
req.end(body && body.length ? Buffer.from(body) : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCursorCatalog(credentials, signal) {
|
||||
const accessToken = credentials?.accessToken;
|
||||
const machineId = credentials?.providerSpecificData?.machineId;
|
||||
const url = getCursorModelsUrl();
|
||||
if (!accessToken || !machineId || !url) return null;
|
||||
|
||||
const headers = {
|
||||
...buildCursorHeaders(accessToken, machineId, credentials?.providerSpecificData?.ghostMode !== false),
|
||||
// Connect unary calls use an unframed protobuf body, unlike Cursor chat's
|
||||
// streaming `application/connect+proto` endpoint.
|
||||
accept: "application/proto",
|
||||
"content-type": "application/proto",
|
||||
};
|
||||
delete headers["connect-accept-encoding"];
|
||||
delete headers["connect-protocol-version"];
|
||||
|
||||
const response = await http2PostProto(url, headers, new Uint8Array(), signal, FETCH_TIMEOUT_MS);
|
||||
if (response.status !== 200) {
|
||||
const error = new Error(`Cursor GetUsableModels returned ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return parseCursorUsableModels(new Uint8Array(response.body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the live Cursor catalog for the authenticated account.
|
||||
* Returns null on any failure so callers can fall back to static models.
|
||||
*/
|
||||
export async function resolveCursorModels(credentials, options = {}) {
|
||||
if (!credentials?.accessToken || !credentials?.providerSpecificData?.machineId) {
|
||||
options.log?.debug?.("CURSOR_MODELS", "No Cursor access token or machine ID; skipping live fetch");
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = cacheKey(credentials);
|
||||
const now = Date.now();
|
||||
if (!options.forceRefresh) {
|
||||
const cached = catalogCache.get(key);
|
||||
if (cached?.expiresAt > now) return { models: cached.models };
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await fetchCursorCatalog(credentials, options.signal);
|
||||
if (!models?.length) return null;
|
||||
catalogCache.set(key, { expiresAt: now + CACHE_TTL_MS, models });
|
||||
return { models };
|
||||
} catch (error) {
|
||||
options.log?.warn?.("CURSOR_MODELS", `Live model fetch failed: ${error?.message || error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCursorModelCache() {
|
||||
catalogCache.clear();
|
||||
}
|
||||
@@ -128,7 +128,8 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr
|
||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||
"x-client-key": clientKey,
|
||||
"x-cursor-checksum": checksum,
|
||||
"x-cursor-client-version": "3.1.0",
|
||||
"x-cursor-client-version": "3.12.17",
|
||||
"x-cursor-client-commit": "0fb762053c34788bb7760d5673f8a6d4c8589d50",
|
||||
"x-cursor-client-type": "ide",
|
||||
"x-cursor-client-os": os,
|
||||
"x-cursor-client-arch": arch,
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function ProviderDetailPage() {
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [liveModels, setLiveModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
@@ -142,7 +143,10 @@ export default function ProviderDetailPage() {
|
||||
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth");
|
||||
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
|
||||
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
|
||||
const models = getModelsByProviderId(providerId);
|
||||
const staticModels = getModelsByProviderId(providerId);
|
||||
const models = providerId === "cursor" && liveModels.length > 0
|
||||
? liveModels
|
||||
: staticModels;
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
@@ -453,6 +457,34 @@ export default function ProviderDetailPage() {
|
||||
fetchDisabledModels();
|
||||
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
|
||||
|
||||
// Cursor's model availability is account-specific and changes frequently.
|
||||
// Load the active account's live catalog for the dashboard; the static
|
||||
// registry remains the fallback while the request is pending or unavailable.
|
||||
useEffect(() => {
|
||||
if (providerId !== "cursor") {
|
||||
setLiveModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connections.find((item) => item.isActive !== false);
|
||||
if (!connection?.id) {
|
||||
setLiveModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" })
|
||||
.then(async (res) => ({ ok: res.ok, data: await res.json() }))
|
||||
.then(({ ok, data }) => {
|
||||
if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) {
|
||||
setLiveModels(data.models);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [providerId, connections]);
|
||||
|
||||
// Fetch suggested models from provider's public API (if configured)
|
||||
useEffect(() => {
|
||||
const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||
|
||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||
|
||||
@@ -292,6 +293,19 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
};
|
||||
}
|
||||
},
|
||||
cursor: {
|
||||
customResolver: async (connection) => {
|
||||
const result = await resolveCursorModels({
|
||||
accessToken: connection.accessToken,
|
||||
providerSpecificData: connection.providerSpecificData || {},
|
||||
}, { forceRefresh: true, log: console });
|
||||
if (result?.models?.length) return { models: result.models };
|
||||
return {
|
||||
models: getStaticProviderModels("cursor"),
|
||||
warning: "Cursor returned no live models; falling back to static catalog.",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows)
|
||||
kiro: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
|
||||
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
|
||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
@@ -97,6 +98,13 @@ const LIVE_MODEL_RESOLVERS = {
|
||||
});
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
},
|
||||
cursor: async (conn) => {
|
||||
const result = await resolveCursorModels({
|
||||
accessToken: conn.accessToken,
|
||||
providerSpecificData: conn.providerSpecificData || {},
|
||||
}, { log: console });
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
|
||||
@@ -48,6 +48,48 @@ export default function ModelSelectModal({
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [disabledModels, setDisabledModels] = useState({});
|
||||
const [cursorModels, setCursorModels] = useState([]);
|
||||
|
||||
// Cursor exposes the usable catalog per account. Keep the static catalog only
|
||||
// as a fallback, since it quickly becomes stale and different accounts can
|
||||
// have different model entitlements.
|
||||
const cursorConnectionIds = useMemo(
|
||||
() => activeProviders
|
||||
.filter((provider) => provider.provider === "cursor" && provider.id)
|
||||
.map((provider) => provider.id),
|
||||
[activeProviders],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || cursorConnectionIds.length === 0) {
|
||||
setCursorModels([]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Promise.all(cursorConnectionIds.map(async (connectionId) => {
|
||||
const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" });
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return Array.isArray(data.models) ? data.models : [];
|
||||
}))
|
||||
.then((modelLists) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set();
|
||||
setCursorModels(modelLists.flat().filter((model) => {
|
||||
if (!model?.id || seen.has(model.id)) return false;
|
||||
seen.add(model.id);
|
||||
return true;
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
// Do not hide the static fallback when the account catalog is unavailable.
|
||||
console.warn("Unable to load Cursor models for selector:", error);
|
||||
if (!cancelled) setCursorModels([]);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, cursorConnectionIds]);
|
||||
|
||||
const fetchCombos = async () => {
|
||||
try {
|
||||
@@ -280,7 +322,9 @@ export default function ModelSelectModal({
|
||||
hasModels: mergedModels.length > 0,
|
||||
};
|
||||
} else {
|
||||
const hardcodedModels = getModelsByProviderId(providerId);
|
||||
const hardcodedModels = providerId === "cursor" && cursorModels.length > 0
|
||||
? cursorModels
|
||||
: getModelsByProviderId(providerId);
|
||||
const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
|
||||
|
||||
// Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider
|
||||
@@ -349,7 +393,7 @@ export default function ModelSelectModal({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]);
|
||||
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]);
|
||||
|
||||
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
|
||||
const filteredCombos = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"alicode-intl": {
|
||||
"baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
|
||||
"baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"headers": {},
|
||||
"quirks": {
|
||||
"preserveCacheControl": true
|
||||
@@ -231,7 +231,7 @@
|
||||
"Content-Type": "application/connect+proto",
|
||||
"User-Agent": "connect-es/1.6.1"
|
||||
},
|
||||
"clientVersion": "3.1.0"
|
||||
"clientVersion": "3.12.17"
|
||||
},
|
||||
"deepgram": {
|
||||
"baseUrl": "https://api.deepgram.com/v1/listen",
|
||||
|
||||
282
tests/unit/cursor-agent-proto.test.js
Normal file
282
tests/unit/cursor-agent-proto.test.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeMessage,
|
||||
encodeField,
|
||||
encodeAgentValue,
|
||||
decodeAgentValue,
|
||||
encodeMcpToolDefinition,
|
||||
encodeMcpTools,
|
||||
decodeMcpArgs,
|
||||
encodeMcpResultSuccess,
|
||||
encodeMcpResultError,
|
||||
encodeMcpResultToolNotFound,
|
||||
} from "../../open-sse/utils/cursorProtobuf.js";
|
||||
import {
|
||||
isAgentCapableRequest,
|
||||
buildAgentRunFrame,
|
||||
} from "../../open-sse/executors/cursor.js";
|
||||
|
||||
// AgentService (agent.v1) codec tests — validate the production implementation
|
||||
// in cursorProtobuf.js + the executor's frame builders. Pure round-trip, no network.
|
||||
// Field numbers verified against Cursor's agent.proto (extracted via @oh-my-pi).
|
||||
|
||||
const LEN = 2;
|
||||
// McpArgs.args map entry { field1: key, field2: Value }
|
||||
const entry = (k, v) => Buffer.concat([
|
||||
Buffer.from(encodeField(2, LEN,
|
||||
Buffer.concat([Buffer.from(encodeField(1, LEN, k)), Buffer.from(encodeField(2, LEN, encodeAgentValue(v)))])
|
||||
)),
|
||||
]);
|
||||
|
||||
describe("Cursor AgentService codec (cursorProtobuf.js)", () => {
|
||||
describe("google.protobuf.Value round-trip", () => {
|
||||
const cases = [
|
||||
["null", null],
|
||||
["bool true", true],
|
||||
["bool false", false],
|
||||
["string", "hello"],
|
||||
["integer", 42],
|
||||
["float", 3.14],
|
||||
["empty object", {}],
|
||||
["flat object", { a: 1, b: "x", c: true }],
|
||||
["nested object", { outer: { inner: [1, 2, "three"] } }],
|
||||
["array of mixed", [1, "two", false, null]],
|
||||
["deeply nested", { a: { b: { c: { d: 1 } } } }],
|
||||
];
|
||||
for (const [label, value] of cases) {
|
||||
it(`encodes/decodes ${label}`, () => {
|
||||
expect(decodeAgentValue(encodeAgentValue(value))).toEqual(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("McpToolDefinition", () => {
|
||||
it("encodes name, description, input_schema (Value), provider, tool_name", () => {
|
||||
const schema = { type: "object", properties: { city: { type: "string" } }, required: ["city"] };
|
||||
const def = encodeMcpToolDefinition({ function: { name: "get_weather", description: "Get weather", parameters: schema } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("get_weather");
|
||||
expect(Buffer.from(msg.get(2)[0].value).toString("utf8")).toBe("Get weather");
|
||||
expect(Buffer.from(msg.get(4)[0].value).toString("utf8")).toBe("9router");
|
||||
expect(Buffer.from(msg.get(5)[0].value).toString("utf8")).toBe("get_weather");
|
||||
expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema);
|
||||
});
|
||||
|
||||
it("preserves nested JSON-schema types", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "search query" },
|
||||
opts: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["query"],
|
||||
};
|
||||
const def = encodeMcpToolDefinition({ function: { name: "search", parameters: schema } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema);
|
||||
});
|
||||
|
||||
it("accepts flat tool shape (no .function wrapper)", () => {
|
||||
const def = encodeMcpToolDefinition({ name: "noop", description: "d", inputSchema: { type: "object" } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("noop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeMcpTools", () => {
|
||||
it("produces empty bytes for no tools", () => {
|
||||
expect(encodeMcpTools([]).length).toBe(0);
|
||||
expect(encodeMcpTools().length).toBe(0);
|
||||
});
|
||||
|
||||
it("wraps multiple tool defs as repeated field 1", () => {
|
||||
const tools = [
|
||||
{ function: { name: "get_weather", parameters: { type: "object" } } },
|
||||
{ function: { name: "calculate", parameters: { type: "object" } } },
|
||||
];
|
||||
const mcpTools = encodeMcpTools(tools);
|
||||
const inner = decodeMessage(mcpTools);
|
||||
expect(inner.get(1).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpArgs decode", () => {
|
||||
it("decodes name, toolName, toolCallId, and typed args map", () => {
|
||||
const argsBytes = Buffer.concat([
|
||||
entry("city", "Hanoi"),
|
||||
entry("count", 5),
|
||||
entry("flag", true),
|
||||
entry("nested", { a: [1, 2] }),
|
||||
]);
|
||||
const mcpArgs = Buffer.concat([
|
||||
Buffer.from(encodeField(1, LEN, "get_weather")),
|
||||
argsBytes,
|
||||
Buffer.from(encodeField(3, LEN, "call_abc")),
|
||||
Buffer.from(encodeField(5, LEN, "get_weather")),
|
||||
]);
|
||||
const decoded = decodeMcpArgs(mcpArgs);
|
||||
expect(decoded.name).toBe("get_weather");
|
||||
expect(decoded.toolName).toBe("get_weather");
|
||||
expect(decoded.toolCallId).toBe("call_abc");
|
||||
expect(decoded.args).toEqual({ city: "Hanoi", count: 5, flag: true, nested: { a: [1, 2] } });
|
||||
});
|
||||
|
||||
it("handles empty args map", () => {
|
||||
const mcpArgs = Buffer.concat([
|
||||
Buffer.from(encodeField(1, LEN, "noop")),
|
||||
Buffer.from(encodeField(5, LEN, "noop")),
|
||||
]);
|
||||
expect(decodeMcpArgs(mcpArgs).args).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult success", () => {
|
||||
it("builds success with single text content", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ['{"temp":32}'], isError: false });
|
||||
const msg = decodeMessage(bytes); // McpResult level
|
||||
expect(msg.has(1)).toBe(true); // success variant
|
||||
const success = decodeMessage(msg.get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(1);
|
||||
expect(success.get(2)[0].value).toBe(0); // is_error=false
|
||||
const item = decodeMessage(success.get(1)[0].value);
|
||||
const textContent = decodeMessage(item.get(1)[0].value);
|
||||
expect(Buffer.from(textContent.get(1)[0].value).toString("utf8")).toBe('{"temp":32}');
|
||||
});
|
||||
|
||||
it("builds success with multiple text items", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["line1", "line2"] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(2);
|
||||
});
|
||||
|
||||
it("marks is_error=true", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["fail"], isError: true });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(2)[0].value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult image content", () => {
|
||||
it("builds image item with raw bytes + mime type", () => {
|
||||
const imgBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const bytes = encodeMcpResultSuccess({ imageItems: [{ data: imgBytes, mimeType: "image/png" }] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
const item = decodeMessage(success.get(1)[0].value);
|
||||
expect(item.has(2)).toBe(true); // image variant
|
||||
const img = decodeMessage(item.get(2)[0].value);
|
||||
expect(Buffer.from(img.get(1)[0].value)).toEqual(Buffer.from(imgBytes));
|
||||
expect(Buffer.from(img.get(2)[0].value).toString("utf8")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("builds mixed text + image content", () => {
|
||||
const imgBytes = new Uint8Array([1, 2, 3]);
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["see image"], imageItems: [{ data: imgBytes, mimeType: "image/jpeg" }] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(2);
|
||||
expect(decodeMessage(success.get(1)[0].value).has(1)).toBe(true); // text
|
||||
expect(decodeMessage(success.get(1)[1].value).has(2)).toBe(true); // image
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult error / toolNotFound", () => {
|
||||
it("builds error result (field 2)", () => {
|
||||
const bytes = encodeMcpResultError("tool crashed");
|
||||
const msg = decodeMessage(bytes);
|
||||
expect(msg.has(2)).toBe(true);
|
||||
const err = decodeMessage(msg.get(2)[0].value);
|
||||
expect(Buffer.from(err.get(1)[0].value).toString("utf8")).toBe("tool crashed");
|
||||
});
|
||||
|
||||
it("builds toolNotFound result (field 5)", () => {
|
||||
const bytes = encodeMcpResultToolNotFound("missing_tool");
|
||||
const msg = decodeMessage(bytes);
|
||||
expect(msg.has(5)).toBe(true);
|
||||
const tnf = decodeMessage(msg.get(5)[0].value);
|
||||
expect(Buffer.from(tnf.get(1)[0].value).toString("utf8")).toBe("missing_tool");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cursor AgentService executor helpers (cursor.js)", () => {
|
||||
describe("isAgentCapableRequest", () => {
|
||||
it("accepts plain text content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts array text content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts request with tools declared", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }], tools: [{ function: { name: "t" } }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts history with assistant tool_calls + tool results", () => {
|
||||
expect(isAgentCapableRequest({
|
||||
messages: [
|
||||
{ role: "user", content: "weather?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: "{}" } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "sunny" },
|
||||
{ role: "user", content: "thanks" },
|
||||
],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-text (image) content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "image_url" }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing messages", () => {
|
||||
expect(isAgentCapableRequest({})).toBe(false);
|
||||
expect(isAgentCapableRequest(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentRunFrame", () => {
|
||||
// buildAgentRunFrame returns a wrapped Connect-RPC frame (5-byte header + AgentClientMessage).
|
||||
const unwrap = (frame) => frame.subarray(5);
|
||||
|
||||
it("encodes a text-only run request with system + model", () => {
|
||||
const frame = unwrap(buildAgentRunFrame(
|
||||
[{ role: "system", content: "be brief" }, { role: "user", content: "hi" }],
|
||||
"gpt-5.2",
|
||||
));
|
||||
const clientMsg = decodeMessage(frame);
|
||||
expect(clientMsg.has(1)).toBe(true); // run_request
|
||||
const run = decodeMessage(clientMsg.get(1)[0].value);
|
||||
expect(run.has(2)).toBe(true); // action
|
||||
expect(run.has(9)).toBe(true); // requested_model
|
||||
});
|
||||
|
||||
it("encodes mcp_tools (field 4) when tools are provided", () => {
|
||||
const tools = [{ function: { name: "get_weather", description: "weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }];
|
||||
const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "weather?" }], "gpt-5.2", tools));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
expect(run.has(4)).toBe(true); // mcp_tools
|
||||
const mcpTools = decodeMessage(run.get(4)[0].value);
|
||||
expect(mcpTools.get(1).length).toBe(1);
|
||||
});
|
||||
|
||||
it("omits mcp_tools when no tools provided", () => {
|
||||
const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "hi" }], "gpt-5.2", []));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
expect(run.has(4)).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes conversation_history from prior turns including tool calls/results", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "weather in Tokyo?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "18C cloudy" },
|
||||
{ role: "user", content: "thanks" },
|
||||
];
|
||||
const frame = unwrap(buildAgentRunFrame(messages, "gpt-5.2", []));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
const action = decodeMessage(run.get(2)[0].value);
|
||||
const userAction = decodeMessage(action.get(1)[0].value);
|
||||
expect(userAction.has(7)).toBe(true); // conversation_history (field 7)
|
||||
const history = decodeMessage(userAction.get(7)[0].value);
|
||||
expect(history.get(1).length).toBeGreaterThanOrEqual(2); // prior turns
|
||||
});
|
||||
});
|
||||
});
|
||||
103
tests/unit/cursor-models.test.js
Normal file
103
tests/unit/cursor-models.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearCursorModelCache,
|
||||
parseCursorUsableModels,
|
||||
resolveCursorModels,
|
||||
} from "../../open-sse/services/cursorModels.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function varint(value) {
|
||||
const bytes = [];
|
||||
while (value >= 0x80) {
|
||||
bytes.push((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function field(fieldNumber, value) {
|
||||
return Uint8Array.from([(fieldNumber << 3) | 2, ...varint(value.length), ...value]);
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function concat(...parts) {
|
||||
const size = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function model(id, name) {
|
||||
return field(1, concat(field(1, text(id)), field(4, text(name))));
|
||||
}
|
||||
|
||||
describe("Cursor live model catalog", () => {
|
||||
beforeEach(() => {
|
||||
clearCursorModelCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
clearCursorModelCache();
|
||||
});
|
||||
|
||||
it("decodes the GetUsableModels protobuf response", () => {
|
||||
const payload = concat(
|
||||
model("default", "Auto"),
|
||||
model("gpt-5.3-codex", "GPT 5.3 Codex"),
|
||||
model("gpt-5.3-codex", "Duplicate"),
|
||||
);
|
||||
|
||||
expect(parseCursorUsableModels(payload)).toEqual([
|
||||
{ id: "default", name: "Auto" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches the account-specific catalog and caches it", async () => {
|
||||
const payload = concat(model("claude-4.6-opus", "Claude 4.6 Opus"));
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(payload, { status: 200 }));
|
||||
const credentials = {
|
||||
accessToken: "cursor-token",
|
||||
providerSpecificData: { machineId: "machine-id" },
|
||||
};
|
||||
|
||||
await expect(resolveCursorModels(credentials)).resolves.toEqual({
|
||||
models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }],
|
||||
});
|
||||
await expect(resolveCursorModels(credentials)).resolves.toEqual({
|
||||
models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }],
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://agent.api5.cursor.sh/agent.v1.AgentService/GetUsableModels",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(Uint8Array),
|
||||
headers: expect.objectContaining({
|
||||
"content-type": "application/proto",
|
||||
accept: "application/proto",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails open when the Cursor catalog request fails", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 403 }));
|
||||
|
||||
await expect(resolveCursorModels({
|
||||
accessToken: "cursor-token",
|
||||
providerSpecificData: { machineId: "machine-id" },
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user