mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(cursor): full OpenAI parity (tool calls, streaming, sessions) (#2082)
Merged automatically
This commit is contained in:
@@ -608,20 +608,107 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
headers: getCursorRegistryHeaders(),
|
||||
clientVersion: CURSOR_REGISTRY_VERSION,
|
||||
models: [
|
||||
{ id: "default", name: "Auto (Server Picks)" },
|
||||
{ id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" },
|
||||
{ id: "claude-4.6-opus-high", name: "Claude 4.6 Opus High" },
|
||||
{ id: "claude-4.6-sonnet-high-thinking", name: "Claude 4.6 Sonnet High Thinking" },
|
||||
{ id: "claude-4.6-sonnet-high", name: "Claude 4.6 Sonnet High" },
|
||||
{ id: "claude-4.6-haiku", name: "Claude 4.6 Haiku" },
|
||||
{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" },
|
||||
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
|
||||
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
|
||||
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
|
||||
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" },
|
||||
{ id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" },
|
||||
{ id: "claude-4.5-opus", name: "Claude 4.5 Opus" },
|
||||
{ id: "auto", name: "Auto (Server Picks)" },
|
||||
{ id: "composer-2-fast", name: "Composer 2 Fast" },
|
||||
{ id: "composer-2", name: "Composer 2" },
|
||||
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex Low" },
|
||||
{ id: "gpt-5.3-codex-low-fast", name: "GPT 5.3 Codex Low Fast" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-fast", name: "GPT 5.3 Codex Fast" },
|
||||
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex High" },
|
||||
{ id: "gpt-5.3-codex-high-fast", name: "GPT 5.3 Codex High Fast" },
|
||||
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex XHigh" },
|
||||
{ id: "gpt-5.3-codex-xhigh-fast", name: "GPT 5.3 Codex XHigh Fast" },
|
||||
{ id: "gpt-5.2", name: "GPT 5.2" },
|
||||
{ id: "gpt-5.3-codex-spark-preview-low", name: "GPT 5.3 Codex Spark Preview Low" },
|
||||
{ id: "gpt-5.3-codex-spark-preview", name: "GPT 5.3 Codex Spark Preview" },
|
||||
{ id: "gpt-5.3-codex-spark-preview-high", name: "GPT 5.3 Codex Spark Preview High" },
|
||||
{ id: "gpt-5.3-codex-spark-preview-xhigh", name: "GPT 5.3 Codex Spark Preview XHigh" },
|
||||
{ id: "gpt-5.2-codex-low", name: "GPT 5.2 Codex Low" },
|
||||
{ id: "gpt-5.2-codex-low-fast", name: "GPT 5.2 Codex Low Fast" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex" },
|
||||
{ id: "gpt-5.2-codex-fast", name: "GPT 5.2 Codex Fast" },
|
||||
{ id: "gpt-5.2-codex-high", name: "GPT 5.2 Codex High" },
|
||||
{ id: "gpt-5.2-codex-high-fast", name: "GPT 5.2 Codex High Fast" },
|
||||
{ id: "gpt-5.2-codex-xhigh", name: "GPT 5.2 Codex XHigh" },
|
||||
{ id: "gpt-5.2-codex-xhigh-fast", name: "GPT 5.2 Codex XHigh Fast" },
|
||||
{ id: "gpt-5.1-codex-max-low", name: "GPT 5.1 Codex Max Low" },
|
||||
{ id: "gpt-5.1-codex-max-low-fast", name: "GPT 5.1 Codex Max Low Fast" },
|
||||
{ id: "gpt-5.1-codex-max-medium", name: "GPT 5.1 Codex Max Medium" },
|
||||
{ id: "gpt-5.1-codex-max-medium-fast", name: "GPT 5.1 Codex Max Medium Fast" },
|
||||
{ id: "gpt-5.1-codex-max-high", name: "GPT 5.1 Codex Max High" },
|
||||
{ id: "gpt-5.1-codex-max-high-fast", name: "GPT 5.1 Codex Max High Fast" },
|
||||
{ id: "gpt-5.1-codex-max-xhigh", name: "GPT 5.1 Codex Max XHigh" },
|
||||
{ id: "gpt-5.1-codex-max-xhigh-fast", name: "GPT 5.1 Codex Max XHigh Fast" },
|
||||
{ id: "gpt-5.5-high", name: "GPT 5.5 High" },
|
||||
{ id: "gpt-5.5-high-fast", name: "GPT 5.5 High Fast" },
|
||||
{ id: "claude-opus-4-7-thinking-high", name: "Claude Opus 4.7 Thinking High" },
|
||||
{ id: "gpt-5.4-high", name: "GPT 5.4 High" },
|
||||
{ id: "gpt-5.4-high-fast", name: "GPT 5.4 High Fast" },
|
||||
{ id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" },
|
||||
{ id: "claude-4.6-opus-high-thinking-fast", name: "Claude 4.6 Opus High Thinking Fast" },
|
||||
{ id: "claude-4.6-sonnet-medium", name: "Claude 4.6 Sonnet Medium" },
|
||||
{ id: "claude-4.6-sonnet-medium-thinking", name: "Claude 4.6 Sonnet Medium Thinking" },
|
||||
{ id: "gpt-5.5-none", name: "GPT 5.5 None" },
|
||||
{ id: "gpt-5.5-none-fast", name: "GPT 5.5 None Fast" },
|
||||
{ id: "gpt-5.5-low", name: "GPT 5.5 Low" },
|
||||
{ id: "gpt-5.5-low-fast", name: "GPT 5.5 Low Fast" },
|
||||
{ id: "gpt-5.5-medium", name: "GPT 5.5 Medium" },
|
||||
{ id: "gpt-5.5-medium-fast", name: "GPT 5.5 Medium Fast" },
|
||||
{ id: "gpt-5.5-extra-high", name: "GPT 5.5 Extra High" },
|
||||
{ id: "gpt-5.5-extra-high-fast", name: "GPT 5.5 Extra High Fast" },
|
||||
{ id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low" },
|
||||
{ id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium" },
|
||||
{ id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High" },
|
||||
{ id: "claude-opus-4-7-xhigh", name: "Claude Opus 4.7 XHigh" },
|
||||
{ id: "claude-opus-4-7-max", name: "Claude Opus 4.7 Max" },
|
||||
{ id: "claude-opus-4-7-thinking-low", name: "Claude Opus 4.7 Thinking Low" },
|
||||
{ id: "claude-opus-4-7-thinking-medium", name: "Claude Opus 4.7 Thinking Medium" },
|
||||
{ id: "claude-opus-4-7-thinking-xhigh", name: "Claude Opus 4.7 Thinking XHigh" },
|
||||
{ id: "claude-opus-4-7-thinking-max", name: "Claude Opus 4.7 Thinking Max" },
|
||||
{ id: "gpt-5.4-low", name: "GPT 5.4 Low" },
|
||||
{ id: "gpt-5.4-medium", name: "GPT 5.4 Medium" },
|
||||
{ id: "gpt-5.4-medium-fast", name: "GPT 5.4 Medium Fast" },
|
||||
{ id: "gpt-5.4-xhigh", name: "GPT 5.4 XHigh" },
|
||||
{ id: "gpt-5.4-xhigh-fast", name: "GPT 5.4 XHigh Fast" },
|
||||
{ id: "claude-4.6-opus-high", name: "Claude 4.6 Opus High" },
|
||||
{ id: "claude-4.6-opus-max", name: "Claude 4.6 Opus Max" },
|
||||
{ id: "claude-4.6-opus-max-thinking", name: "Claude 4.6 Opus Max Thinking" },
|
||||
{ id: "claude-4.6-opus-max-thinking-fast", name: "Claude 4.6 Opus Max Thinking Fast" },
|
||||
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
|
||||
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
|
||||
{ id: "gpt-5.2-low", name: "GPT 5.2 Low" },
|
||||
{ id: "gpt-5.2-low-fast", name: "GPT 5.2 Low Fast" },
|
||||
{ id: "gpt-5.2-fast", name: "GPT 5.2 Fast" },
|
||||
{ id: "gpt-5.2-high", name: "GPT 5.2 High" },
|
||||
{ id: "gpt-5.2-high-fast", name: "GPT 5.2 High Fast" },
|
||||
{ id: "gpt-5.2-xhigh", name: "GPT 5.2 XHigh" },
|
||||
{ id: "gpt-5.2-xhigh-fast", name: "GPT 5.2 XHigh Fast" },
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gpt-5.4-mini-none", name: "GPT 5.4 Mini None" },
|
||||
{ id: "gpt-5.4-mini-low", name: "GPT 5.4 Mini Low" },
|
||||
{ id: "gpt-5.4-mini-medium", name: "GPT 5.4 Mini Medium" },
|
||||
{ id: "gpt-5.4-mini-high", name: "GPT 5.4 Mini High" },
|
||||
{ id: "gpt-5.4-mini-xhigh", name: "GPT 5.4 Mini XHigh" },
|
||||
{ id: "gpt-5.4-nano-none", name: "GPT 5.4 Nano None" },
|
||||
{ id: "gpt-5.4-nano-low", name: "GPT 5.4 Nano Low" },
|
||||
{ id: "gpt-5.4-nano-medium", name: "GPT 5.4 Nano Medium" },
|
||||
{ id: "gpt-5.4-nano-high", name: "GPT 5.4 Nano High" },
|
||||
{ id: "gpt-5.4-nano-xhigh", name: "GPT 5.4 Nano XHigh" },
|
||||
{ id: "grok-4.3", name: "Grok 4.3" },
|
||||
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" },
|
||||
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
|
||||
{ id: "gpt-5.1-low", name: "GPT 5.1 Low" },
|
||||
{ id: "gpt-5.1", name: "GPT 5.1" },
|
||||
{ id: "gpt-5.1-high", name: "GPT 5.1 High" },
|
||||
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
|
||||
{ id: "gpt-5.1-codex-mini-low", name: "GPT 5.1 Codex Mini Low" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT 5.1 Codex Mini" },
|
||||
{ id: "gpt-5.1-codex-mini-high", name: "GPT 5.1 Codex Mini High" },
|
||||
{ id: "claude-4-sonnet", name: "Claude 4 Sonnet" },
|
||||
{ id: "claude-4-sonnet-thinking", name: "Claude 4 Sonnet Thinking" },
|
||||
{ id: "gpt-5-mini", name: "GPT 5 Mini" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
167
open-sse/services/cursorSessionManager.ts
Normal file
167
open-sse/services/cursorSessionManager.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* CursorSessionManager — keeps cursor's h2 streams alive across OpenAI calls
|
||||
* so a tool-using turn can complete inline.
|
||||
*
|
||||
* cursor's `agent.v1.AgentService/Run` is bidirectional. When the model
|
||||
* invokes an MCP tool it pauses and waits for a `ExecClientMessage.McpResult`
|
||||
* on the SAME stream. Closing the stream between the OpenAI tool_calls
|
||||
* response and the role:"tool" follow-up loses the exec_id mapping cursor
|
||||
* needs to resume.
|
||||
*
|
||||
* This manager solves that by retaining the open h2 stream (keyed by
|
||||
* conversation_id) when the executor reports endReason="tool_calls". The
|
||||
* next OpenAI call with role:"tool" reacquires the session, replies via
|
||||
* encodeExecMcpResult on the live stream, and continues driving until
|
||||
* turn_ended.
|
||||
*
|
||||
* Multi-instance considerations: sessions live in process memory. If a
|
||||
* follow-up call lands on a different OmniRoute instance, acquire() returns
|
||||
* undefined and the executor falls back to cold-resume (fresh RunRequest
|
||||
* with all history flattened into UserText). Cold-resume is correctness-
|
||||
* preserving but loses the inline efficiency.
|
||||
*
|
||||
* Concurrency: one in-flight call per session. The acquire/release pattern
|
||||
* keeps a session in "awaiting_tool_result" between calls; if a second call
|
||||
* arrives while the first is still running, acquire() returns undefined and
|
||||
* the second falls back to cold-resume.
|
||||
*
|
||||
* TTL: sessions evict after CURSOR_SESSION_IDLE_TTL_MS (default 5min). The
|
||||
* sweep runs lazily on every acquire/release rather than via setInterval to
|
||||
* keep this module test-friendly.
|
||||
*/
|
||||
|
||||
import type { ClientHttp2Session, ClientHttp2Stream } from "node:http2";
|
||||
import { encodeExecMcpResult } from "../utils/cursorAgentProtobuf.ts";
|
||||
|
||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export type CursorSession = {
|
||||
conversationId: string;
|
||||
h2Client: ClientHttp2Session;
|
||||
h2Req: ClientHttp2Stream;
|
||||
blobStore: Map<string, Buffer>;
|
||||
pendingToolCalls: Map<string, { execMsgId: number; execId: string; toolName: string }>;
|
||||
state: "running" | "awaiting_tool_result" | "closed";
|
||||
lastActivityTs: number;
|
||||
};
|
||||
|
||||
export class CursorSessionManager {
|
||||
private sessions = new Map<string, CursorSession>();
|
||||
private idleTtlMs: number;
|
||||
|
||||
constructor(opts: { idleTtlMs?: number } = {}) {
|
||||
this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to reacquire an existing session for this conversation. Returns
|
||||
* undefined if there isn't one, if it's still running, or if it's idle
|
||||
* past the TTL (in which case it's closed as a side-effect).
|
||||
*/
|
||||
acquire(conversationId: string): CursorSession | undefined {
|
||||
this.evictExpired();
|
||||
const session = this.sessions.get(conversationId);
|
||||
if (!session) return undefined;
|
||||
if (session.state !== "awaiting_tool_result") return undefined;
|
||||
session.state = "running";
|
||||
session.lastActivityTs = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a freshly-opened h2 stream as the session for this conversation.
|
||||
* Any pre-existing session for the same conversation is closed first.
|
||||
*/
|
||||
open(
|
||||
conversationId: string,
|
||||
h2Client: ClientHttp2Session,
|
||||
h2Req: ClientHttp2Stream,
|
||||
blobStore: Map<string, Buffer>
|
||||
): CursorSession {
|
||||
const existing = this.sessions.get(conversationId);
|
||||
if (existing) this.close(existing);
|
||||
const session: CursorSession = {
|
||||
conversationId,
|
||||
h2Client,
|
||||
h2Req,
|
||||
blobStore,
|
||||
pendingToolCalls: new Map(),
|
||||
state: "running",
|
||||
lastActivityTs: Date.now(),
|
||||
};
|
||||
this.sessions.set(conversationId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as no longer in-flight. If finalState is
|
||||
* "awaiting_tool_result" the h2 stream stays open and the next acquire()
|
||||
* for this conversation_id can reuse it. If "idle" or "closed" the
|
||||
* h2 is torn down here.
|
||||
*/
|
||||
release(session: CursorSession, finalState: "awaiting_tool_result" | "idle" | "closed"): void {
|
||||
session.lastActivityTs = Date.now();
|
||||
if (finalState === "awaiting_tool_result") {
|
||||
session.state = "awaiting_tool_result";
|
||||
return;
|
||||
}
|
||||
this.close(session);
|
||||
}
|
||||
|
||||
close(session: CursorSession): void {
|
||||
session.state = "closed";
|
||||
try {
|
||||
session.h2Req.close();
|
||||
} catch {}
|
||||
try {
|
||||
session.h2Client.close();
|
||||
} catch {}
|
||||
this.sessions.delete(session.conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an MCP tool result on this session's open h2 stream. Returns true
|
||||
* if the openAIToolCallId matched a pending call we'd previously seen
|
||||
* mcp_args for; false otherwise (caller should fall back to cold-resume).
|
||||
*/
|
||||
sendToolResult(
|
||||
session: CursorSession,
|
||||
openAIToolCallId: string,
|
||||
content: string,
|
||||
isError: boolean
|
||||
): boolean {
|
||||
const pending = session.pendingToolCalls.get(openAIToolCallId);
|
||||
if (!pending) return false;
|
||||
try {
|
||||
session.h2Req.write(encodeExecMcpResult(pending.execMsgId, pending.execId, content, isError));
|
||||
session.pendingToolCalls.delete(openAIToolCallId);
|
||||
session.lastActivityTs = Date.now();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private evictExpired(): void {
|
||||
const now = Date.now();
|
||||
for (const session of this.sessions.values()) {
|
||||
if (now - session.lastActivityTs > this.idleTtlMs) {
|
||||
this.close(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test / introspection helpers ────────────────────────────────────────
|
||||
|
||||
size(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
has(conversationId: string): boolean {
|
||||
return this.sessions.has(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton — one manager per OmniRoute process. The executor
|
||||
// imports this directly. For testing, construct a fresh CursorSessionManager.
|
||||
export const cursorSessionManager = new CursorSessionManager();
|
||||
1369
open-sse/utils/cursorAgentProtobuf.ts
Normal file
1369
open-sse/utils/cursorAgentProtobuf.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,897 +0,0 @@
|
||||
/**
|
||||
* Cursor Protobuf Encoder/Decoder
|
||||
* Implements ConnectRPC protobuf wire format for Cursor API
|
||||
*
|
||||
* Schema Version: reverse-engineered from Cursor client traffic.
|
||||
* If Cursor updates their protocol, unknown field warnings will appear
|
||||
* in the logs — update the FIELD map and bump the version below.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import zlib from "zlib";
|
||||
|
||||
const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1";
|
||||
const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Schema version — bump when updating field definitions.
|
||||
* Logged in warnings to help correlate unknown fields with Cursor client versions.
|
||||
*/
|
||||
const PROTOBUF_SCHEMA_VERSION = "1.1.3";
|
||||
|
||||
// ==================== SCHEMAS ====================
|
||||
|
||||
const WIRE_TYPE = { VARINT: 0, FIXED64: 1, LEN: 2, FIXED32: 5 };
|
||||
|
||||
const ROLE = { USER: 1, ASSISTANT: 2 };
|
||||
|
||||
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
|
||||
|
||||
const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 };
|
||||
const CLIENT_SIDE_TOOL_V2 = { MCP: 19 };
|
||||
|
||||
const FIELD = {
|
||||
// StreamUnifiedChatRequestWithTools (top level)
|
||||
REQUEST: 1,
|
||||
|
||||
// StreamUnifiedChatRequest
|
||||
MESSAGES: 1,
|
||||
UNKNOWN_2: 2,
|
||||
INSTRUCTION: 3,
|
||||
UNKNOWN_4: 4,
|
||||
MODEL: 5,
|
||||
WEB_TOOL: 8,
|
||||
UNKNOWN_13: 13,
|
||||
CURSOR_SETTING: 15,
|
||||
UNKNOWN_19: 19,
|
||||
CONVERSATION_ID: 23,
|
||||
METADATA: 26,
|
||||
IS_AGENTIC: 27,
|
||||
SUPPORTED_TOOLS: 29,
|
||||
MESSAGE_IDS: 30,
|
||||
MCP_TOOLS: 34,
|
||||
LARGE_CONTEXT: 35,
|
||||
UNKNOWN_38: 38,
|
||||
UNIFIED_MODE: 46,
|
||||
UNKNOWN_47: 47,
|
||||
SHOULD_DISABLE_TOOLS: 48,
|
||||
THINKING_LEVEL: 49,
|
||||
UNKNOWN_51: 51,
|
||||
UNKNOWN_53: 53,
|
||||
UNIFIED_MODE_NAME: 54,
|
||||
|
||||
// ConversationMessage
|
||||
MSG_CONTENT: 1,
|
||||
MSG_ROLE: 2,
|
||||
MSG_ID: 13,
|
||||
MSG_TOOL_RESULTS: 18,
|
||||
MSG_IS_AGENTIC: 29,
|
||||
MSG_UNIFIED_MODE: 47,
|
||||
MSG_SUPPORTED_TOOLS: 51,
|
||||
|
||||
// ConversationMessage.ToolResult
|
||||
TOOL_RESULT_CALL_ID: 1,
|
||||
TOOL_RESULT_NAME: 2,
|
||||
TOOL_RESULT_INDEX: 3,
|
||||
TOOL_RESULT_RAW_ARGS: 5,
|
||||
TOOL_RESULT_RESULT: 8,
|
||||
TOOL_RESULT_TOOL_CALL: 11,
|
||||
TOOL_RESULT_MODEL_CALL_ID: 12,
|
||||
|
||||
// ClientSideToolV2Result (nested inside ToolResult.result)
|
||||
CLIENT_RESULT_TOOL: 1,
|
||||
CLIENT_RESULT_MCP_RESULT: 28,
|
||||
CLIENT_RESULT_TOOL_CALL_ID: 35,
|
||||
CLIENT_RESULT_MODEL_CALL_ID: 48,
|
||||
CLIENT_RESULT_TOOL_INDEX: 49,
|
||||
|
||||
// MCPResult (nested inside ClientSideToolV2Result.mcp_result)
|
||||
MCP_RESULT_SELECTED_TOOL: 1,
|
||||
MCP_RESULT_RESULT: 2,
|
||||
|
||||
// ClientSideToolV2Call (nested inside ToolResult.tool_call)
|
||||
CLIENT_CALL_TOOL: 1,
|
||||
CLIENT_CALL_MCP_PARAMS: 27,
|
||||
CLIENT_CALL_TOOL_CALL_ID: 3,
|
||||
CLIENT_CALL_NAME: 9,
|
||||
CLIENT_CALL_RAW_ARGS: 10,
|
||||
CLIENT_CALL_TOOL_INDEX: 48,
|
||||
CLIENT_CALL_MODEL_CALL_ID: 49,
|
||||
|
||||
// Model
|
||||
MODEL_NAME: 1,
|
||||
MODEL_EMPTY: 4,
|
||||
|
||||
// Instruction
|
||||
INSTRUCTION_TEXT: 1,
|
||||
|
||||
// CursorSetting
|
||||
SETTING_PATH: 1,
|
||||
SETTING_UNKNOWN_3: 3,
|
||||
SETTING_UNKNOWN_6: 6,
|
||||
SETTING_UNKNOWN_8: 8,
|
||||
SETTING_UNKNOWN_9: 9,
|
||||
|
||||
// CursorSetting.Unknown6
|
||||
SETTING6_FIELD_1: 1,
|
||||
SETTING6_FIELD_2: 2,
|
||||
|
||||
// Metadata
|
||||
META_PLATFORM: 1,
|
||||
META_ARCH: 2,
|
||||
META_VERSION: 3,
|
||||
META_CWD: 4,
|
||||
META_TIMESTAMP: 5,
|
||||
|
||||
// MessageId
|
||||
MSGID_ID: 1,
|
||||
MSGID_SUMMARY: 2,
|
||||
MSGID_ROLE: 3,
|
||||
|
||||
// MCPTool
|
||||
MCP_TOOL_NAME: 1,
|
||||
MCP_TOOL_DESC: 2,
|
||||
MCP_TOOL_PARAMS: 3,
|
||||
MCP_TOOL_SERVER: 4,
|
||||
|
||||
// StreamUnifiedChatResponseWithTools (response)
|
||||
TOOL_CALL: 1,
|
||||
RESPONSE: 2,
|
||||
|
||||
// ClientSideToolV2Call
|
||||
TOOL_ID: 3,
|
||||
TOOL_NAME: 9,
|
||||
TOOL_RAW_ARGS: 10,
|
||||
TOOL_IS_LAST: 11,
|
||||
TOOL_IS_LAST_ALT: 15,
|
||||
TOOL_MCP_PARAMS: 27,
|
||||
|
||||
// MCPParams
|
||||
MCP_TOOLS_LIST: 1,
|
||||
|
||||
// MCPParams.Tool (nested)
|
||||
MCP_NESTED_NAME: 1,
|
||||
MCP_NESTED_PARAMS: 3,
|
||||
|
||||
// StreamUnifiedChatResponse
|
||||
RESPONSE_TEXT: 1,
|
||||
THINKING: 25,
|
||||
|
||||
// Thinking
|
||||
THINKING_TEXT: 1,
|
||||
};
|
||||
|
||||
// Known response field numbers — used to detect unknown fields from protocol updates
|
||||
const KNOWN_RESPONSE_FIELDS = new Set([
|
||||
FIELD.TOOL_CALL,
|
||||
FIELD.RESPONSE,
|
||||
FIELD.TOOL_ID,
|
||||
FIELD.TOOL_NAME,
|
||||
FIELD.TOOL_RAW_ARGS,
|
||||
FIELD.TOOL_IS_LAST,
|
||||
FIELD.TOOL_MCP_PARAMS,
|
||||
FIELD.RESPONSE_TEXT,
|
||||
FIELD.THINKING,
|
||||
]);
|
||||
|
||||
// ==================== PRIMITIVE ENCODING ====================
|
||||
|
||||
export function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
while (value >= 0x80) {
|
||||
bytes.push((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value & 0x7f);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
export function encodeField(fieldNum, wireType, value) {
|
||||
const tag = (fieldNum << 3) | wireType;
|
||||
const tagBytes = encodeVarint(tag);
|
||||
|
||||
if (wireType === WIRE_TYPE.VARINT) {
|
||||
const valueBytes = encodeVarint(value);
|
||||
return concatArrays(tagBytes, valueBytes);
|
||||
}
|
||||
|
||||
if (wireType === WIRE_TYPE.LEN) {
|
||||
const dataBytes =
|
||||
typeof value === "string"
|
||||
? new TextEncoder().encode(value)
|
||||
: value instanceof Uint8Array
|
||||
? value
|
||||
: Buffer.isBuffer(value)
|
||||
? new Uint8Array(value)
|
||||
: new Uint8Array(0);
|
||||
|
||||
const lengthBytes = encodeVarint(dataBytes.length);
|
||||
return concatArrays(tagBytes, lengthBytes, dataBytes);
|
||||
}
|
||||
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
function concatArrays(...arrays) {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== MESSAGE ENCODING ====================
|
||||
|
||||
export function encodeToolResult(toolResult) {
|
||||
const { toolCallId, modelCallId } = parseToolCallId(toolResult.tool_call_id || "");
|
||||
const rawToolName = toolResult.name || "";
|
||||
const toolName = formatCursorToolName(rawToolName);
|
||||
const { selectedTool, serverName } = parseCursorToolName(toolName);
|
||||
const toolIndex = toolResult.index > 0 ? toolResult.index : 1;
|
||||
const rawArgs = toolResult.raw_args || "{}";
|
||||
const resultContent = toolResult.result || "";
|
||||
const encodedResultMessage = encodeClientSideToolResult(
|
||||
toolCallId,
|
||||
modelCallId,
|
||||
selectedTool,
|
||||
toolIndex,
|
||||
resultContent
|
||||
);
|
||||
const encodedToolCallMessage = encodeClientSideToolCall(
|
||||
toolCallId,
|
||||
modelCallId,
|
||||
toolName,
|
||||
selectedTool,
|
||||
serverName,
|
||||
rawArgs,
|
||||
toolIndex
|
||||
);
|
||||
|
||||
return concatArrays(
|
||||
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
|
||||
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
|
||||
...(modelCallId
|
||||
? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)]
|
||||
: []),
|
||||
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
||||
...(encodedResultMessage
|
||||
? [encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN, encodedResultMessage)]
|
||||
: []),
|
||||
encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN, encodedToolCallMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function parseToolCallId(toolCallIdRaw) {
|
||||
if (typeof toolCallIdRaw !== "string" || toolCallIdRaw.length === 0) {
|
||||
return { toolCallId: "", modelCallId: null };
|
||||
}
|
||||
const delimiter = "\nmc_";
|
||||
const idx = toolCallIdRaw.indexOf(delimiter);
|
||||
if (idx >= 0) {
|
||||
return {
|
||||
toolCallId: toolCallIdRaw.slice(0, idx),
|
||||
modelCallId: toolCallIdRaw.slice(idx + delimiter.length),
|
||||
};
|
||||
}
|
||||
return { toolCallId: toolCallIdRaw, modelCallId: null };
|
||||
}
|
||||
|
||||
function formatCursorToolName(rawName) {
|
||||
const base = typeof rawName === "string" && rawName.length > 0 ? rawName : "tool";
|
||||
|
||||
if (base.startsWith("mcp__")) {
|
||||
const rest = base.slice("mcp__".length);
|
||||
const splitIdx = rest.indexOf("__");
|
||||
if (splitIdx >= 0) {
|
||||
const server = rest.slice(0, splitIdx) || "custom";
|
||||
const name = rest.slice(splitIdx + 2) || "tool";
|
||||
return `mcp_${server}_${name}`;
|
||||
}
|
||||
return `mcp_custom_${rest || "tool"}`;
|
||||
}
|
||||
|
||||
if (base.startsWith("mcp_")) return base;
|
||||
return `mcp_custom_${base}`;
|
||||
}
|
||||
|
||||
function parseCursorToolName(formattedName) {
|
||||
if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) {
|
||||
return { serverName: "custom", selectedTool: formattedName || "tool" };
|
||||
}
|
||||
|
||||
const tail = formattedName.slice("mcp_".length);
|
||||
const splitIdx = tail.indexOf("_");
|
||||
if (splitIdx < 0) {
|
||||
return { serverName: "custom", selectedTool: tail || "tool" };
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: tail.slice(0, splitIdx) || "custom",
|
||||
selectedTool: tail.slice(splitIdx + 1) || "tool",
|
||||
};
|
||||
}
|
||||
|
||||
function encodeClientSideToolResult(toolCallId, modelCallId, toolName, toolIndex, resultContent) {
|
||||
const outputText = typeof resultContent === "string" ? resultContent : "";
|
||||
const selectedTool = typeof toolName === "string" && toolName.length > 0 ? toolName : "tool";
|
||||
|
||||
const mcpResult = concatArrays(
|
||||
encodeField(FIELD.MCP_RESULT_SELECTED_TOOL, WIRE_TYPE.LEN, selectedTool),
|
||||
encodeField(FIELD.MCP_RESULT_RESULT, WIRE_TYPE.LEN, outputText)
|
||||
);
|
||||
|
||||
return concatArrays(
|
||||
encodeField(FIELD.CLIENT_RESULT_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2.MCP),
|
||||
encodeField(FIELD.CLIENT_RESULT_MCP_RESULT, WIRE_TYPE.LEN, mcpResult),
|
||||
...(toolCallId
|
||||
? [encodeField(FIELD.CLIENT_RESULT_TOOL_CALL_ID, WIRE_TYPE.LEN, toolCallId)]
|
||||
: []),
|
||||
...(modelCallId
|
||||
? [encodeField(FIELD.CLIENT_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)]
|
||||
: []),
|
||||
encodeField(FIELD.CLIENT_RESULT_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex)
|
||||
);
|
||||
}
|
||||
|
||||
function encodeMcpParamsForCall(toolName, rawArgs, serverName) {
|
||||
const tool = concatArrays(
|
||||
encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName || "tool"),
|
||||
encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, rawArgs || "{}"),
|
||||
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, serverName || "custom")
|
||||
);
|
||||
return encodeField(FIELD.MCP_TOOLS_LIST, WIRE_TYPE.LEN, tool);
|
||||
}
|
||||
|
||||
function encodeClientSideToolCall(
|
||||
toolCallId,
|
||||
modelCallId,
|
||||
toolName,
|
||||
selectedTool,
|
||||
serverName,
|
||||
rawArgs,
|
||||
toolIndex
|
||||
) {
|
||||
return concatArrays(
|
||||
encodeField(FIELD.CLIENT_CALL_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2.MCP),
|
||||
encodeField(
|
||||
FIELD.CLIENT_CALL_MCP_PARAMS,
|
||||
WIRE_TYPE.LEN,
|
||||
encodeMcpParamsForCall(selectedTool, rawArgs, serverName)
|
||||
),
|
||||
...(toolCallId ? [encodeField(FIELD.CLIENT_CALL_TOOL_CALL_ID, WIRE_TYPE.LEN, toolCallId)] : []),
|
||||
encodeField(FIELD.CLIENT_CALL_NAME, WIRE_TYPE.LEN, toolName || "tool"),
|
||||
encodeField(FIELD.CLIENT_CALL_RAW_ARGS, WIRE_TYPE.LEN, rawArgs || "{}"),
|
||||
encodeField(FIELD.CLIENT_CALL_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1),
|
||||
...(modelCallId
|
||||
? [encodeField(FIELD.CLIENT_CALL_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)]
|
||||
: [])
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeMessage(
|
||||
content,
|
||||
role,
|
||||
messageId,
|
||||
chatModeEnum = null,
|
||||
isLast = false,
|
||||
hasTools = false,
|
||||
toolResults = []
|
||||
) {
|
||||
return concatArrays(
|
||||
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
|
||||
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
|
||||
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
|
||||
...(toolResults.length > 0
|
||||
? toolResults.map((tr) =>
|
||||
encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
|
||||
)
|
||||
: []),
|
||||
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
|
||||
encodeField(
|
||||
FIELD.MSG_UNIFIED_MODE,
|
||||
WIRE_TYPE.VARINT,
|
||||
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
|
||||
),
|
||||
...(isLast && hasTools
|
||||
? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
|
||||
: [])
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeInstruction(text) {
|
||||
return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
|
||||
}
|
||||
|
||||
export function encodeModel(modelName) {
|
||||
return concatArrays(
|
||||
encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName),
|
||||
encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeCursorSetting() {
|
||||
const unknown6 = concatArrays(
|
||||
encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
|
||||
encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
|
||||
);
|
||||
|
||||
return concatArrays(
|
||||
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"),
|
||||
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
|
||||
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
|
||||
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1),
|
||||
encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeMetadata() {
|
||||
return concatArrays(
|
||||
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"),
|
||||
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"),
|
||||
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"),
|
||||
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd?.() || "/"),
|
||||
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeMessageId(messageId, role, summaryId = null) {
|
||||
return concatArrays(
|
||||
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId),
|
||||
...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
|
||||
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role)
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeMcpTool(tool) {
|
||||
const toolName = tool.function?.name || tool.name || "";
|
||||
const toolDesc = tool.function?.description || tool.description || "";
|
||||
const inputSchema = tool.function?.parameters || tool.input_schema || {};
|
||||
|
||||
return concatArrays(
|
||||
...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []),
|
||||
...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []),
|
||||
...(Object.keys(inputSchema).length > 0
|
||||
? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
|
||||
: []),
|
||||
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom")
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== REQUEST BUILDING ====================
|
||||
|
||||
export function encodeRequest(messages, modelName, tools = [], reasoningEffort = null) {
|
||||
const hasTools = tools?.length > 0;
|
||||
const isAgentic = hasTools;
|
||||
const formattedMessages = [];
|
||||
const messageIds = [];
|
||||
const normalizedMessages = [];
|
||||
|
||||
// Guardrail: split mixed assistant payload into separate assistant messages.
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0;
|
||||
const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0;
|
||||
|
||||
if (msg?.role === "assistant" && hasToolCalls && hasToolResults) {
|
||||
log(
|
||||
"ENCODE",
|
||||
`normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})`
|
||||
);
|
||||
|
||||
// Keep assistant tool call message without embedded results
|
||||
normalizedMessages.push({
|
||||
...msg,
|
||||
tool_results: [],
|
||||
});
|
||||
|
||||
// Avoid inserting duplicate assistant tool-result message if next one already matches
|
||||
const nextMsg = messages[i + 1];
|
||||
const nextHasToolResults =
|
||||
nextMsg?.role === "assistant" &&
|
||||
Array.isArray(nextMsg?.tool_results) &&
|
||||
nextMsg.tool_results.length > 0;
|
||||
const currentIds = new Set(
|
||||
msg.tool_results.map((tr) => tr?.tool_call_id).filter((id) => typeof id === "string")
|
||||
);
|
||||
const nextIds = new Set(
|
||||
(nextMsg?.tool_results || [])
|
||||
.map((tr) => tr?.tool_call_id)
|
||||
.filter((id) => typeof id === "string")
|
||||
);
|
||||
let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size;
|
||||
if (sameIds) {
|
||||
for (const id of currentIds) {
|
||||
if (!nextIds.has(id)) {
|
||||
sameIds = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(nextHasToolResults && sameIds)) {
|
||||
normalizedMessages.push({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_results: msg.tool_results,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedMessages.push(msg);
|
||||
}
|
||||
|
||||
// Prepare messages
|
||||
for (let i = 0; i < normalizedMessages.length; i++) {
|
||||
const msg = normalizedMessages[i];
|
||||
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
|
||||
const msgId = uuidv4();
|
||||
const isLast = i === normalizedMessages.length - 1;
|
||||
|
||||
formattedMessages.push({
|
||||
content: msg.content,
|
||||
role,
|
||||
messageId: msgId,
|
||||
isLast,
|
||||
hasTools,
|
||||
toolResults: msg.tool_results || [],
|
||||
});
|
||||
|
||||
messageIds.push({ messageId: msgId, role });
|
||||
}
|
||||
|
||||
// Map reasoning effort to thinking level
|
||||
let thinkingLevel = THINKING_LEVEL.UNSPECIFIED;
|
||||
if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM;
|
||||
else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH;
|
||||
|
||||
// Build request
|
||||
return concatArrays(
|
||||
// Messages
|
||||
...formattedMessages.map((fm) =>
|
||||
encodeField(
|
||||
FIELD.MESSAGES,
|
||||
WIRE_TYPE.LEN,
|
||||
encodeMessage(
|
||||
fm.content,
|
||||
fm.role,
|
||||
fm.messageId,
|
||||
null,
|
||||
fm.isLast,
|
||||
fm.hasTools,
|
||||
fm.toolResults
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Static fields
|
||||
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
|
||||
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")),
|
||||
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
|
||||
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
|
||||
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""),
|
||||
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
|
||||
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
|
||||
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
|
||||
encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, uuidv4()),
|
||||
encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
|
||||
|
||||
// Tool-related fields
|
||||
encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
|
||||
...(isAgentic ? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []),
|
||||
|
||||
// Message IDs
|
||||
...messageIds.map((mid) =>
|
||||
encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
|
||||
),
|
||||
|
||||
// MCP Tools
|
||||
...(tools?.length > 0
|
||||
? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
|
||||
: []),
|
||||
|
||||
// Mode fields
|
||||
encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
|
||||
encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
|
||||
encodeField(
|
||||
FIELD.UNIFIED_MODE,
|
||||
WIRE_TYPE.VARINT,
|
||||
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
|
||||
),
|
||||
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""),
|
||||
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
|
||||
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
|
||||
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
|
||||
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
|
||||
encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? "Agent" : "Ask")
|
||||
);
|
||||
}
|
||||
|
||||
export function buildChatRequest(messages, modelName, tools = [], reasoningEffort = null) {
|
||||
return encodeField(
|
||||
FIELD.REQUEST,
|
||||
WIRE_TYPE.LEN,
|
||||
encodeRequest(messages, modelName, tools, reasoningEffort)
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapConnectRPCFrame(payload, compress = false) {
|
||||
let finalPayload = payload;
|
||||
let flags = 0x00;
|
||||
|
||||
if (compress) {
|
||||
finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload)));
|
||||
flags = 0x01;
|
||||
}
|
||||
|
||||
const frame = new Uint8Array(5 + finalPayload.length);
|
||||
frame[0] = flags;
|
||||
frame[1] = (finalPayload.length >> 24) & 0xff;
|
||||
frame[2] = (finalPayload.length >> 16) & 0xff;
|
||||
frame[3] = (finalPayload.length >> 8) & 0xff;
|
||||
frame[4] = finalPayload.length & 0xff;
|
||||
frame.set(finalPayload, 5);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function generateCursorBody(messages, modelName, tools = [], reasoningEffort = null) {
|
||||
log(
|
||||
"BODY",
|
||||
`Generating: ${messages.length} msgs, model=${modelName}, tools=${tools.length}, reasoning=${reasoningEffort || "none"}`
|
||||
);
|
||||
|
||||
const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort);
|
||||
const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests
|
||||
|
||||
log("BODY", `Protobuf=${protobuf.length}B, Framed=${framed.length}B`);
|
||||
return framed;
|
||||
}
|
||||
|
||||
// ==================== PRIMITIVE DECODING ====================
|
||||
|
||||
export function decodeVarint(buffer, offset) {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let pos = offset;
|
||||
|
||||
while (pos < buffer.length) {
|
||||
const b = buffer[pos];
|
||||
result |= (b & 0x7f) << shift;
|
||||
pos++;
|
||||
if (!(b & 0x80)) break;
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return [result, pos];
|
||||
}
|
||||
|
||||
export function decodeField(buffer, offset) {
|
||||
if (offset >= buffer.length) return [null, null, null, offset];
|
||||
|
||||
const [tag, pos1] = decodeVarint(buffer, offset);
|
||||
const fieldNum = tag >> 3;
|
||||
const wireType = tag & 0x07;
|
||||
|
||||
let value;
|
||||
let pos = pos1;
|
||||
|
||||
if (wireType === WIRE_TYPE.VARINT) {
|
||||
[value, pos] = decodeVarint(buffer, pos);
|
||||
} else if (wireType === WIRE_TYPE.LEN) {
|
||||
const [length, pos2] = decodeVarint(buffer, pos);
|
||||
value = buffer.slice(pos2, pos2 + length);
|
||||
pos = pos2 + length;
|
||||
} else if (wireType === WIRE_TYPE.FIXED64) {
|
||||
value = buffer.slice(pos, pos + 8);
|
||||
pos += 8;
|
||||
} else if (wireType === WIRE_TYPE.FIXED32) {
|
||||
value = buffer.slice(pos, pos + 4);
|
||||
pos += 4;
|
||||
} else {
|
||||
value = null;
|
||||
}
|
||||
|
||||
return [fieldNum, wireType, value, pos];
|
||||
}
|
||||
|
||||
export function decodeMessage(data) {
|
||||
const fields = new Map();
|
||||
let pos = 0;
|
||||
|
||||
while (pos < data.length) {
|
||||
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
|
||||
if (fieldNum === null) break;
|
||||
|
||||
if (!fields.has(fieldNum)) fields.set(fieldNum, []);
|
||||
fields.get(fieldNum).push({ wireType, value });
|
||||
pos = newPos;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ==================== RESPONSE PARSING ====================
|
||||
|
||||
export function parseConnectRPCFrame(buffer) {
|
||||
if (buffer.length < 5) return null;
|
||||
|
||||
const flags = buffer[0];
|
||||
const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
|
||||
|
||||
if (buffer.length < 5 + length) return null;
|
||||
|
||||
let payload = buffer.slice(5, 5 + length);
|
||||
|
||||
// Decompress if gzip
|
||||
if (flags === 0x01) {
|
||||
try {
|
||||
payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload)));
|
||||
} catch (err) {
|
||||
log("PARSE", `Decompression failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { flags, length, payload, consumed: 5 + length };
|
||||
}
|
||||
|
||||
function extractToolCall(toolCallData) {
|
||||
const toolCall = decodeMessage(toolCallData);
|
||||
let toolCallId = "";
|
||||
let toolName = "";
|
||||
let rawArgs = "";
|
||||
let isLast = false;
|
||||
|
||||
// Extract tool call ID
|
||||
if (toolCall.has(FIELD.TOOL_ID)) {
|
||||
toolCallId = textDecoder.decode(toolCall.get(FIELD.TOOL_ID)[0].value);
|
||||
}
|
||||
|
||||
// Extract tool name
|
||||
if (toolCall.has(FIELD.TOOL_NAME)) {
|
||||
toolName = textDecoder.decode(toolCall.get(FIELD.TOOL_NAME)[0].value);
|
||||
}
|
||||
|
||||
// Extract is_last flag
|
||||
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
|
||||
isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0;
|
||||
} else if (toolCall.has(FIELD.TOOL_IS_LAST_ALT)) {
|
||||
isLast = toolCall.get(FIELD.TOOL_IS_LAST_ALT)[0].value !== 0;
|
||||
}
|
||||
|
||||
// Extract MCP params - nested real tool info
|
||||
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
|
||||
try {
|
||||
const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)[0].value);
|
||||
|
||||
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
|
||||
const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value);
|
||||
|
||||
if (tool.has(FIELD.MCP_NESTED_NAME)) {
|
||||
toolName = textDecoder.decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value);
|
||||
}
|
||||
|
||||
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
|
||||
rawArgs = textDecoder.decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("EXTRACT", `MCP parse error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to raw_args
|
||||
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
|
||||
rawArgs = textDecoder.decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value);
|
||||
}
|
||||
|
||||
if (toolCallId && toolName) {
|
||||
return {
|
||||
id: toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: rawArgs || "{}",
|
||||
},
|
||||
isLast,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTextAndThinking(responseData) {
|
||||
const nested = decodeMessage(responseData);
|
||||
let text = null;
|
||||
let thinking = null;
|
||||
|
||||
// Extract text
|
||||
if (nested.has(FIELD.RESPONSE_TEXT)) {
|
||||
text = textDecoder.decode(nested.get(FIELD.RESPONSE_TEXT)[0].value);
|
||||
}
|
||||
|
||||
// Extract thinking
|
||||
if (nested.has(FIELD.THINKING)) {
|
||||
try {
|
||||
const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value);
|
||||
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
|
||||
thinking = textDecoder.decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value);
|
||||
}
|
||||
} catch (err) {
|
||||
log("EXTRACT", `Thinking parse error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { text, thinking };
|
||||
}
|
||||
|
||||
export function extractTextFromResponse(payload) {
|
||||
try {
|
||||
const fields = decodeMessage(payload);
|
||||
|
||||
// Warn about unknown field numbers — may indicate a Cursor protocol update
|
||||
for (const fieldNum of fields.keys()) {
|
||||
if (!KNOWN_RESPONSE_FIELDS.has(fieldNum)) {
|
||||
log(
|
||||
"SCHEMA",
|
||||
`Unknown response field #${fieldNum} detected. Schema v${PROTOBUF_SCHEMA_VERSION} may be outdated.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Field 1: ClientSideToolV2Call
|
||||
if (fields.has(FIELD.TOOL_CALL)) {
|
||||
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value);
|
||||
if (toolCall) {
|
||||
log("EXTRACT", `Tool call: ${toolCall.function.name}`);
|
||||
return { text: null, error: null, toolCall, thinking: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Field 2: StreamUnifiedChatResponse
|
||||
if (fields.has(FIELD.RESPONSE)) {
|
||||
const { text, thinking } = extractTextAndThinking(fields.get(FIELD.RESPONSE)[0].value);
|
||||
|
||||
if (text || thinking) {
|
||||
return { text, error: null, toolCall: null, thinking };
|
||||
}
|
||||
}
|
||||
|
||||
return { text: null, error: null, toolCall: null, thinking: null };
|
||||
} catch (err) {
|
||||
// Graceful fallback — return raw payload instead of crashing
|
||||
log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`);
|
||||
return {
|
||||
text: null,
|
||||
error: null,
|
||||
toolCall: null,
|
||||
thinking: null,
|
||||
raw: Buffer.from(payload).toString("base64"),
|
||||
decodeError: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EXPORTS ====================
|
||||
|
||||
const cursorProtobufUtils = {
|
||||
encodeVarint,
|
||||
encodeField,
|
||||
encodeMessage,
|
||||
buildChatRequest,
|
||||
wrapConnectRPCFrame,
|
||||
generateCursorBody,
|
||||
decodeVarint,
|
||||
decodeField,
|
||||
decodeMessage,
|
||||
parseConnectRPCFrame,
|
||||
extractTextFromResponse,
|
||||
};
|
||||
|
||||
export default cursorProtobufUtils;
|
||||
176
scripts/cursor-tap.cjs
Normal file
176
scripts/cursor-tap.cjs
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cursor-tap — capture cursor agent.v1.AgentService/Run wire bytes for tests.
|
||||
*
|
||||
* Usage:
|
||||
* CURSOR_TOKEN=... node scripts/cursor-tap.cjs <fixture-name> <prompt>
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/cursor-tap.cjs single-turn-chat "say only PING"
|
||||
* node scripts/cursor-tap.cjs system-prompt "be brief|hi" # split on first '|'
|
||||
* node scripts/cursor-tap.cjs tool-call "weather in Paris" --tools=get_weather
|
||||
* node scripts/cursor-tap.cjs composer-2-fast "hi" --model=composer-2-fast
|
||||
*
|
||||
* Writes the upstream response bytes to tests/fixtures/cursor/<fixture-name>.bin
|
||||
* and prints decoded summary to stdout. Use these fixtures in unit tests to
|
||||
* catch schema drift in cursor-agent's protobuf format.
|
||||
*
|
||||
* Note: this is a one-time / on-demand tool. The .bin output is gitignored
|
||||
* by default; commit fixtures explicitly when you want them in the test
|
||||
* baseline (tests/fixtures/cursor/.gitignore controls this).
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http2 = require("http2");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error("Usage: cursor-tap.cjs <fixture-name> <prompt> [--model=...] [--tools=name1,name2]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [fixtureName, prompt, ...flags] = args;
|
||||
const flagMap = Object.fromEntries(
|
||||
flags.map((f) => {
|
||||
const m = f.match(/^--([^=]+)=(.*)$/);
|
||||
return m ? [m[1], m[2]] : [f.replace(/^--/, ""), true];
|
||||
})
|
||||
);
|
||||
|
||||
const token = process.env.CURSOR_TOKEN;
|
||||
if (!token) {
|
||||
console.error("Set CURSOR_TOKEN environment variable.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = flagMap.model || "auto";
|
||||
const conversationId = crypto.randomUUID();
|
||||
const requestId = crypto.randomUUID();
|
||||
const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;
|
||||
|
||||
// ─── Minimal protobuf encoder (mirrors open-sse/utils/cursorAgentProtobuf.ts) ─
|
||||
|
||||
function encodeVarint(n) {
|
||||
const out = [];
|
||||
let v = BigInt(n);
|
||||
while (v > 0x7fn) {
|
||||
out.push(Number(v & 0x7fn) | 0x80);
|
||||
v >>= 7n;
|
||||
}
|
||||
out.push(Number(v));
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field, wt) {
|
||||
return encodeVarint((field << 3) | wt);
|
||||
}
|
||||
function lenField(f, payload) {
|
||||
return Buffer.concat([tag(f, 2), encodeVarint(payload.length), payload]);
|
||||
}
|
||||
function strField(f, s) {
|
||||
return lenField(f, Buffer.from(s, "utf8"));
|
||||
}
|
||||
function varintField(f, n) {
|
||||
return Buffer.concat([tag(f, 0), encodeVarint(n)]);
|
||||
}
|
||||
function wrapConnectFrame(payload) {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0;
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// AgentRunRequest body
|
||||
const userMessage = lenField(
|
||||
1,
|
||||
Buffer.concat([
|
||||
strField(1, prompt),
|
||||
strField(2, crypto.randomUUID()),
|
||||
lenField(3, Buffer.alloc(0)),
|
||||
varintField(4, 1),
|
||||
])
|
||||
);
|
||||
const userMessageAction = lenField(1, userMessage);
|
||||
const action = lenField(2, userMessageAction);
|
||||
const conversationState = lenField(1, Buffer.alloc(0));
|
||||
const requestedModel = lenField(9, strField(1, model === "auto" ? "default" : model));
|
||||
const arr = Buffer.concat([
|
||||
conversationState,
|
||||
action,
|
||||
lenField(4, Buffer.alloc(0)), // mcp_tools
|
||||
strField(5, conversationId),
|
||||
requestedModel,
|
||||
varintField(12, 0),
|
||||
strField(16, conversationId),
|
||||
]);
|
||||
const acm = lenField(1, arr);
|
||||
const body = wrapConnectFrame(acm);
|
||||
|
||||
// ─── h2 request ────────────────────────────────────────────────────────────
|
||||
|
||||
const cleanToken = token.includes("::") ? token.split("::")[1] : token;
|
||||
const client = http2.connect("https://agentn.global.api5.cursor.sh");
|
||||
|
||||
const collected = [];
|
||||
let responseStatus = 0;
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
":path": "/agent.v1.AgentService/Run",
|
||||
":authority": "agentn.global.api5.cursor.sh",
|
||||
":scheme": "https",
|
||||
authorization: `Bearer ${cleanToken}`,
|
||||
"backend-traceparent": traceParent,
|
||||
"connect-accept-encoding": "gzip,br",
|
||||
"connect-protocol-version": "1",
|
||||
"content-type": "application/connect+proto",
|
||||
traceparent: traceParent,
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"x-cursor-client-type": "cli",
|
||||
"x-cursor-client-version": "cli-2025.10.21-b2dfaef",
|
||||
"x-ghost-mode": "true",
|
||||
"x-original-request-id": requestId,
|
||||
"x-request-id": requestId,
|
||||
});
|
||||
|
||||
req.on("response", (h) => {
|
||||
responseStatus = Number(h[":status"]);
|
||||
});
|
||||
req.on("data", (chunk) => {
|
||||
collected.push(Buffer.from(chunk));
|
||||
});
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(collected);
|
||||
const outDir = path.join(__dirname, "..", "tests", "fixtures", "cursor");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outFile = path.join(outDir, `${fixtureName}.bin`);
|
||||
fs.writeFileSync(outFile, raw);
|
||||
console.log(`[cursor-tap] status=${responseStatus} bytes=${raw.length} → ${outFile}`);
|
||||
client.close();
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
console.error("[cursor-tap] req error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
// NOTE: we never end the request; cursor closes the stream itself when the
|
||||
// turn is done. For tool-using captures, the script may need to write
|
||||
// follow-up frames before the stream closes — extend as needed.
|
||||
|
||||
// Safety timeout: if cursor doesn't close in 60s, dump what we have.
|
||||
setTimeout(() => {
|
||||
console.warn("[cursor-tap] safety timeout; closing");
|
||||
try {
|
||||
req.close();
|
||||
client.close();
|
||||
} catch {}
|
||||
const raw = Buffer.concat(collected);
|
||||
if (raw.length > 0) {
|
||||
const outDir = path.join(__dirname, "..", "tests", "fixtures", "cursor");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, `${fixtureName}.bin`), raw);
|
||||
}
|
||||
process.exit(0);
|
||||
}, 60_000);
|
||||
130
scripts/sync-cursor-models.mjs
Normal file
130
scripts/sync-cursor-models.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync the cursor models list in open-sse/config/providerRegistry.ts from
|
||||
// cursor-agent's runtime model list. Triggers an intentional invalid --model
|
||||
// invocation so cursor-agent prints "Available models: ..." on stderr.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/sync-cursor-models.mjs # spawn cursor-agent and apply
|
||||
// node scripts/sync-cursor-models.mjs --dry-run # print proposed block, don't write
|
||||
// node scripts/sync-cursor-models.mjs --from-stdin # read the error message from stdin
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts");
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const DRY_RUN = args.has("--dry-run");
|
||||
const FROM_STDIN = args.has("--from-stdin");
|
||||
|
||||
function readSource() {
|
||||
if (FROM_STDIN) return readFileSync(0, "utf8");
|
||||
// cursor-agent prints "Available models: ..." to stderr and exits non-zero.
|
||||
const r = spawnSync("cursor-agent", ["--model", "--help"], { encoding: "utf8" });
|
||||
return `${r.stdout || ""}\n${r.stderr || ""}`;
|
||||
}
|
||||
|
||||
// `auto` is a CLI-side abstraction (cursor-agent resolves it locally before
|
||||
// sending) and `composer-*` targets cursor's edit/composer endpoint, neither
|
||||
// of which work via the chat RPC. Filter them so the dashboard never offers
|
||||
// them to the chat path. Pass --include-unsupported to keep them.
|
||||
function isUnsupportedChatModel(id) {
|
||||
if (id === "auto") return true;
|
||||
if (id.startsWith("composer-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseModelIds(text) {
|
||||
const m = text.match(/Available models:\s*([^\n]+)/);
|
||||
if (!m) throw new Error("Could not find 'Available models:' line in cursor-agent output");
|
||||
const includeUnsupported = args.has("--include-unsupported");
|
||||
return m[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((id) => includeUnsupported || !isUnsupportedChatModel(id));
|
||||
}
|
||||
|
||||
const SEGMENT_OVERRIDES = {
|
||||
gpt: "GPT",
|
||||
claude: "Claude",
|
||||
gemini: "Gemini",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
composer: "Composer",
|
||||
opus: "Opus",
|
||||
sonnet: "Sonnet",
|
||||
haiku: "Haiku",
|
||||
codex: "Codex",
|
||||
mini: "Mini",
|
||||
nano: "Nano",
|
||||
max: "Max",
|
||||
high: "High",
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
xhigh: "XHigh",
|
||||
none: "None",
|
||||
fast: "Fast",
|
||||
thinking: "Thinking",
|
||||
extra: "Extra",
|
||||
spark: "Spark",
|
||||
preview: "Preview",
|
||||
flash: "Flash",
|
||||
pro: "Pro",
|
||||
};
|
||||
|
||||
// Pretty-print an id by:
|
||||
// 1) collapsing claude-NAME-X-Y dotted version (e.g. claude-opus-4-7 → claude-opus-4.7)
|
||||
// 2) splitting on '-'
|
||||
// 3) applying SEGMENT_OVERRIDES; falling back to capitalize-first
|
||||
function humanize(id) {
|
||||
if (id === "auto") return "Auto (Server Picks)";
|
||||
|
||||
// Collapse "X-Y" numeric suffix in claude-foo-X-Y- patterns into "X.Y"
|
||||
const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2");
|
||||
const parts = collapsed.split("-");
|
||||
const labelled = parts.map((p) => {
|
||||
if (SEGMENT_OVERRIDES[p]) return SEGMENT_OVERRIDES[p];
|
||||
if (/^\d/.test(p)) return p; // leave version numbers / "k2.5" alone
|
||||
return p.charAt(0).toUpperCase() + p.slice(1);
|
||||
});
|
||||
return labelled.join(" ");
|
||||
}
|
||||
|
||||
function buildModelsArrayLines(ids) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(` { id: ${JSON.stringify(id)}, name: ${JSON.stringify(humanize(id))} },`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function replaceCursorModels(source, modelsBlock) {
|
||||
// Match the `cursor:` provider entry and replace just its `models: [ ... ],` array.
|
||||
const re = /(\n cursor:\s*\{[\s\S]*?\n models:\s*\[)([\s\S]*?)(\n \],)/;
|
||||
if (!re.test(source)) throw new Error("Could not locate cursor.models array in registry");
|
||||
return source.replace(re, `$1\n${modelsBlock}$3`);
|
||||
}
|
||||
|
||||
const ids = parseModelIds(readSource());
|
||||
const block = buildModelsArrayLines(ids);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(block);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const before = readFileSync(REGISTRY_PATH, "utf8");
|
||||
const after = replaceCursorModels(before, block);
|
||||
if (before === after) {
|
||||
console.log("No changes — cursor models already in sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
writeFileSync(REGISTRY_PATH, after);
|
||||
console.log(`Updated ${ids.length} cursor models in ${REGISTRY_PATH}`);
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
isAutoFetchModelsEnabled,
|
||||
persistDiscoveredModels,
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type LocalCatalogModel = {
|
||||
@@ -1525,6 +1526,31 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === "cursor") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
try {
|
||||
const models = await fetchCursorAgentModels();
|
||||
return buildApiDiscoveryResponse(models);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log("[models] cursor-agent fetch failed:", message);
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `cursor-agent unavailable (${message}) — using cached catalog`,
|
||||
localWarning: `cursor-agent unavailable (${message}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch Cursor models: ${message}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "glm" || provider === "glmt") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
166
src/lib/providerModels/cursorAgent.ts
Normal file
166
src/lib/providerModels/cursorAgent.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
|
||||
// cursor-agent waits on stdin when given a piped fd, so we always launch it
|
||||
// with stdin closed ("ignore") so it exits as soon as it prints the model list.
|
||||
function runCursorAgent(
|
||||
binary: string,
|
||||
args: string[],
|
||||
timeoutMs: number
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const killTimer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(killTimer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(killTimer);
|
||||
resolve({ stdout, stderr, code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve cursor-agent across common install locations, since the standalone
|
||||
// Next.js server may run with a PATH that doesn't include the user's local bin.
|
||||
function resolveCursorAgentBinary(): string | null {
|
||||
const home = homedir();
|
||||
const candidates = [
|
||||
join(home, ".local", "bin", "cursor-agent"),
|
||||
"/root/.local/bin/cursor-agent",
|
||||
"/usr/local/bin/cursor-agent",
|
||||
"/usr/bin/cursor-agent",
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
// Fallback: PATH-based lookup (lets execFile do the resolution).
|
||||
const pathDirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
||||
for (const dir of pathDirs) {
|
||||
const candidate = join(dir, "cursor-agent");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const SEGMENT_OVERRIDES: Record<string, string> = {
|
||||
gpt: "GPT",
|
||||
claude: "Claude",
|
||||
gemini: "Gemini",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
composer: "Composer",
|
||||
opus: "Opus",
|
||||
sonnet: "Sonnet",
|
||||
haiku: "Haiku",
|
||||
codex: "Codex",
|
||||
mini: "Mini",
|
||||
nano: "Nano",
|
||||
max: "Max",
|
||||
high: "High",
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
xhigh: "XHigh",
|
||||
none: "None",
|
||||
fast: "Fast",
|
||||
thinking: "Thinking",
|
||||
extra: "Extra",
|
||||
spark: "Spark",
|
||||
preview: "Preview",
|
||||
flash: "Flash",
|
||||
pro: "Pro",
|
||||
};
|
||||
|
||||
export function humanizeCursorModelId(id: string): string {
|
||||
if (id === "auto") return "Auto (Server Picks)";
|
||||
|
||||
// Collapse digit-dash-digit suffixes (e.g. claude-opus-4-7 → claude-opus-4.7)
|
||||
// so version numbers read naturally.
|
||||
const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2");
|
||||
return collapsed
|
||||
.split("-")
|
||||
.map((part) => {
|
||||
if (SEGMENT_OVERRIDES[part]) return SEGMENT_OVERRIDES[part];
|
||||
if (/^\d/.test(part)) return part;
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function parseCursorAgentModels(text: string): string[] {
|
||||
const match = text.match(/Available models:\s*([^\n]+)/);
|
||||
if (!match) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of match[1].split(",")) {
|
||||
const id = raw.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type CursorAgentModelEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
owned_by: "cursor";
|
||||
};
|
||||
|
||||
export async function fetchCursorAgentModels(
|
||||
options: { binary?: string; timeoutMs?: number } = {}
|
||||
): Promise<CursorAgentModelEntry[]> {
|
||||
const binary = options.binary || resolveCursorAgentBinary();
|
||||
const timeoutMs = options.timeoutMs ?? 5000;
|
||||
|
||||
if (!binary) {
|
||||
throw new Error(
|
||||
"cursor-agent binary not found. Install it (curl https://cursor.com/install -fsS | bash) so ~/.local/bin/cursor-agent exists, or pass a binary path explicitly."
|
||||
);
|
||||
}
|
||||
|
||||
// cursor-agent prints "Available models: ..." to stderr and exits non-zero
|
||||
// when given an unknown model id, so we intentionally pass `--help` as the
|
||||
// model value to coerce it into listing.
|
||||
let result: { stdout: string; stderr: string };
|
||||
try {
|
||||
result = await runCursorAgent(binary, ["--model", "--help"], timeoutMs);
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e?.code === "ENOENT") {
|
||||
throw new Error(`cursor-agent binary not executable at ${binary}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const combined = `${result.stdout}\n${result.stderr}`;
|
||||
|
||||
const ids = parseCursorAgentModels(combined);
|
||||
if (ids.length === 0) {
|
||||
throw new Error("cursor-agent did not return an 'Available models:' line");
|
||||
}
|
||||
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
name: humanizeCursorModelId(id),
|
||||
owned_by: "cursor" as const,
|
||||
}));
|
||||
}
|
||||
5
tests/fixtures/cursor/.gitignore
vendored
Normal file
5
tests/fixtures/cursor/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Captured cursor wire bytes from scripts/cursor-tap.cjs.
|
||||
# By default these are local-only — uncomment a specific fixture to
|
||||
# include it in the repo as a regression baseline.
|
||||
*.bin
|
||||
!.gitignore
|
||||
147
tests/integration/cursor-e2e.test.ts
Normal file
147
tests/integration/cursor-e2e.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* 1. Single-turn chat with system prompt
|
||||
* 2. Tool-use round trip (request → tool_calls → role:"tool" follow-up)
|
||||
* 3. Streaming SSE incremental delivery
|
||||
* 4. Inline-session reuse across two consecutive calls
|
||||
* 5. Cold-resume fallback when session is missing/evicted
|
||||
*
|
||||
* To run:
|
||||
* CURSOR_E2E_TOKEN=$(cat ~/.cursor/access-token) \
|
||||
* node --import tsx/esm --test tests/integration/cursor-e2e.test.ts
|
||||
*
|
||||
* Capturing wire fixtures (separate workflow):
|
||||
* CURSOR_TOKEN=... node scripts/cursor-tap.cjs single-turn-chat "say PING"
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const TOKEN = process.env.CURSOR_E2E_TOKEN;
|
||||
const skipReason = TOKEN ? undefined : "CURSOR_E2E_TOKEN not set";
|
||||
|
||||
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\]/);
|
||||
}
|
||||
);
|
||||
219
tests/unit/cursor-agent-exec-router.test.ts
Normal file
219
tests/unit/cursor-agent-exec-router.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
decodeExecServerEvent,
|
||||
iterateConnectFrames,
|
||||
wrapConnectFrame,
|
||||
} from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
|
||||
// ─── Wire-format helpers (match the encoder's primitives) ──────────────────
|
||||
//
|
||||
// We synthesize ExecServerMessage payloads for each variant and assert
|
||||
// decodeExecServerEvent returns the right kind + extracted fields. This
|
||||
// validates the decoder against the field-number contract independently of
|
||||
// the encoder (which runs the same code path in different test files).
|
||||
|
||||
function v(n: number): Buffer {
|
||||
const out: number[] = [];
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field: number, wireType: number): Buffer {
|
||||
return v((field << 3) | wireType);
|
||||
}
|
||||
function lenPrefixed(field: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
||||
}
|
||||
function varintField(field: number, value: number): Buffer {
|
||||
return Buffer.concat([tag(field, 0), v(value)]);
|
||||
}
|
||||
function stringField(field: number, value: string): Buffer {
|
||||
return lenPrefixed(field, Buffer.from(value, "utf8"));
|
||||
}
|
||||
|
||||
// AgentServerMessage { exec_server_message (2): ExecServerMessage }
|
||||
function buildAgentServerMessage(esmInner: Buffer): Buffer {
|
||||
return lenPrefixed(2, esmInner);
|
||||
}
|
||||
|
||||
// ExecServerMessage { id (1): execMsgId, exec_id (15): execId, <variant>: bytes }
|
||||
function buildExecServerMessage(
|
||||
execMsgId: number,
|
||||
execId: string,
|
||||
variantField: number,
|
||||
variantPayload: Buffer
|
||||
): Buffer {
|
||||
return Buffer.concat([
|
||||
varintField(1, execMsgId),
|
||||
stringField(15, execId),
|
||||
lenPrefixed(variantField, variantPayload),
|
||||
]);
|
||||
}
|
||||
|
||||
test("decodeExecServerEvent returns null for unrelated AgentServerMessage", () => {
|
||||
// text_delta InteractionUpdate, no exec_server_message
|
||||
const interactionUpdate = lenPrefixed(1, lenPrefixed(1, Buffer.from("hi", "utf8")));
|
||||
const asm = lenPrefixed(1, interactionUpdate);
|
||||
assert.equal(decodeExecServerEvent(asm), null);
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes request_context (field 10)", () => {
|
||||
const variant = Buffer.alloc(0); // RequestContextArgs has no fields we care about
|
||||
const esm = buildExecServerMessage(1, "exec-rc", 10, variant);
|
||||
const asm = buildAgentServerMessage(esm);
|
||||
const event = decodeExecServerEvent(asm);
|
||||
assert.deepEqual(event, { kind: "exec_request_context", execMsgId: 1, execId: "exec-rc" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes read_args (field 7) with path", () => {
|
||||
const variant = stringField(1, "/etc/passwd");
|
||||
const esm = buildExecServerMessage(2, "exec-r", 7, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_read",
|
||||
execMsgId: 2,
|
||||
execId: "exec-r",
|
||||
path: "/etc/passwd",
|
||||
});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes write_args (field 3)", () => {
|
||||
const variant = stringField(1, "/tmp/x");
|
||||
const esm = buildExecServerMessage(3, "exec-w", 3, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, { kind: "exec_write", execMsgId: 3, execId: "exec-w", path: "/tmp/x" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes delete_args (field 4)", () => {
|
||||
const variant = stringField(1, "/tmp/y");
|
||||
const esm = buildExecServerMessage(4, "exec-d", 4, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, { kind: "exec_delete", execMsgId: 4, execId: "exec-d", path: "/tmp/y" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes ls_args (field 8)", () => {
|
||||
const variant = stringField(1, "/home");
|
||||
const esm = buildExecServerMessage(5, "exec-l", 8, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, { kind: "exec_ls", execMsgId: 5, execId: "exec-l", path: "/home" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes grep_args (field 5)", () => {
|
||||
const variant = stringField(1, "pattern");
|
||||
const esm = buildExecServerMessage(6, "exec-g", 5, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, { kind: "exec_grep", execMsgId: 6, execId: "exec-g" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes diagnostics_args (field 9)", () => {
|
||||
const esm = buildExecServerMessage(7, "exec-diag", 9, Buffer.alloc(0));
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, { kind: "exec_diagnostics", execMsgId: 7, execId: "exec-diag" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes shell_args (field 2) with command + working_dir", () => {
|
||||
const variant = Buffer.concat([stringField(1, "ls -la"), stringField(2, "/tmp")]);
|
||||
const esm = buildExecServerMessage(8, "exec-s", 2, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_shell",
|
||||
execMsgId: 8,
|
||||
execId: "exec-s",
|
||||
command: "ls -la",
|
||||
workingDir: "/tmp",
|
||||
});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes shell_stream_args (field 14)", () => {
|
||||
const variant = Buffer.concat([stringField(1, "tail -f x"), stringField(2, "/var/log")]);
|
||||
const esm = buildExecServerMessage(9, "exec-ss", 14, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_shell_stream",
|
||||
execMsgId: 9,
|
||||
execId: "exec-ss",
|
||||
command: "tail -f x",
|
||||
workingDir: "/var/log",
|
||||
});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes background_shell_spawn (field 16)", () => {
|
||||
const variant = Buffer.concat([stringField(1, "node server"), stringField(2, "/app")]);
|
||||
const esm = buildExecServerMessage(10, "exec-bg", 16, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_bg_shell",
|
||||
execMsgId: 10,
|
||||
execId: "exec-bg",
|
||||
command: "node server",
|
||||
workingDir: "/app",
|
||||
});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes fetch_args (field 20) with url", () => {
|
||||
const variant = stringField(1, "https://example.com/x");
|
||||
const esm = buildExecServerMessage(11, "exec-f", 20, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_fetch",
|
||||
execMsgId: 11,
|
||||
execId: "exec-f",
|
||||
url: "https://example.com/x",
|
||||
});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes write_shell_stdin_args (field 23)", () => {
|
||||
const esm = buildExecServerMessage(12, "exec-stdin", 23, Buffer.alloc(0));
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, { kind: "exec_write_shell_stdin", execMsgId: 12, execId: "exec-stdin" });
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent recognizes mcp_args (field 11) with name + tool_call_id", () => {
|
||||
// McpArgs { name (1): "foo", tool_call_id (3): "call_x" }
|
||||
const variant = Buffer.concat([stringField(1, "get_weather"), stringField(3, "call_abc123")]);
|
||||
const esm = buildExecServerMessage(13, "exec-mcp", 11, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_mcp",
|
||||
execMsgId: 13,
|
||||
execId: "exec-mcp",
|
||||
toolName: "get_weather",
|
||||
toolCallId: "call_abc123",
|
||||
args: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent prefers tool_name (field 5) over name (field 1)", () => {
|
||||
// McpArgs { name: "old", tool_name: "new", tool_call_id: "x" }
|
||||
const variant = Buffer.concat([
|
||||
stringField(1, "old_alias"),
|
||||
stringField(3, "call_y"),
|
||||
stringField(5, "canonical_name"),
|
||||
]);
|
||||
const esm = buildExecServerMessage(14, "exec-mcp2", 11, variant);
|
||||
const event = decodeExecServerEvent(buildAgentServerMessage(esm));
|
||||
if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp");
|
||||
assert.equal(event.toolName, "canonical_name");
|
||||
});
|
||||
|
||||
// ─── Wire-tap regression: real Connect-RPC frame round-trips ───────────────
|
||||
|
||||
test("decodeExecServerEvent works through a Connect-RPC frame", () => {
|
||||
const variant = stringField(1, "/etc/shadow");
|
||||
const esm = buildExecServerMessage(99, "exec-real", 7, variant);
|
||||
const asm = buildAgentServerMessage(esm);
|
||||
const framed = wrapConnectFrame(asm);
|
||||
const frames = [...iterateConnectFrames(framed)];
|
||||
assert.equal(frames.length, 1);
|
||||
const event = decodeExecServerEvent(frames[0].payload);
|
||||
assert.deepEqual(event, {
|
||||
kind: "exec_read",
|
||||
execMsgId: 99,
|
||||
execId: "exec-real",
|
||||
path: "/etc/shadow",
|
||||
});
|
||||
});
|
||||
42
tests/unit/cursor-agent-models.test.ts
Normal file
42
tests/unit/cursor-agent-models.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
humanizeCursorModelId,
|
||||
parseCursorAgentModels,
|
||||
} from "../../src/lib/providerModels/cursorAgent";
|
||||
|
||||
test("parseCursorAgentModels returns every reported id including auto and composer-*", () => {
|
||||
const text =
|
||||
"Cannot use this model: --help. Available models: auto, composer-2, composer-2-fast, gpt-5.3-codex-low, claude-opus-4-7-thinking-high, kimi-k2.5";
|
||||
assert.deepEqual(parseCursorAgentModels(text), [
|
||||
"auto",
|
||||
"composer-2",
|
||||
"composer-2-fast",
|
||||
"gpt-5.3-codex-low",
|
||||
"claude-opus-4-7-thinking-high",
|
||||
"kimi-k2.5",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseCursorAgentModels deduplicates and trims", () => {
|
||||
assert.deepEqual(parseCursorAgentModels("Available models: a, a , b"), ["a", "b"]);
|
||||
});
|
||||
|
||||
test("parseCursorAgentModels returns [] when the marker is missing", () => {
|
||||
assert.deepEqual(parseCursorAgentModels("nothing here"), []);
|
||||
});
|
||||
|
||||
test("humanizeCursorModelId pretty-prints common patterns", () => {
|
||||
assert.equal(humanizeCursorModelId("auto"), "Auto (Server Picks)");
|
||||
assert.equal(humanizeCursorModelId("composer-2-fast"), "Composer 2 Fast");
|
||||
assert.equal(humanizeCursorModelId("gpt-5.3-codex-low"), "GPT 5.3 Codex Low");
|
||||
assert.equal(humanizeCursorModelId("gpt-5.5-extra-high-fast"), "GPT 5.5 Extra High Fast");
|
||||
// Collapses claude-opus-4-7-* version pattern into 4.7
|
||||
assert.equal(
|
||||
humanizeCursorModelId("claude-opus-4-7-thinking-high"),
|
||||
"Claude Opus 4.7 Thinking High"
|
||||
);
|
||||
assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5");
|
||||
assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro");
|
||||
assert.equal(humanizeCursorModelId("claude-4-sonnet-thinking"), "Claude 4 Sonnet Thinking");
|
||||
});
|
||||
490
tests/unit/cursor-agent-protobuf.test.ts
Normal file
490
tests/unit/cursor-agent-protobuf.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveRequestedModel,
|
||||
encodeAgentRunRequest,
|
||||
buildAgentRequestBody,
|
||||
iterateConnectFrames,
|
||||
decodeAgentServerMessage,
|
||||
flattenMessages,
|
||||
wrapConnectFrame,
|
||||
encodeExecReadRejected,
|
||||
encodeExecWriteRejected,
|
||||
encodeExecDeleteRejected,
|
||||
encodeExecLsRejected,
|
||||
encodeExecShellRejected,
|
||||
encodeExecBackgroundShellSpawnRejected,
|
||||
encodeExecGrepError,
|
||||
encodeExecFetchError,
|
||||
encodeExecWriteShellStdinError,
|
||||
encodeExecDiagnosticsResult,
|
||||
encodeExecMcpResult,
|
||||
encodeExecMcpError,
|
||||
encodeKvGetBlobResult,
|
||||
encodeKvSetBlobResult,
|
||||
encodeMcpToolDefinitionBody,
|
||||
jsonSchemaToProtobufValue,
|
||||
encodeRequestContextResponse,
|
||||
openAIToolsToMcpDefs,
|
||||
} from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
|
||||
test("resolveRequestedModel maps cursor-agent's client-side aliases", () => {
|
||||
assert.deepEqual(resolveRequestedModel("auto"), { modelId: "default", parameters: [] });
|
||||
assert.deepEqual(resolveRequestedModel("composer-2-fast"), {
|
||||
modelId: "composer-2",
|
||||
parameters: [{ id: "fast", value: "true" }],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("claude-4.6-sonnet-medium"), {
|
||||
modelId: "claude-4.6-sonnet-medium",
|
||||
parameters: [],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("composer-2"), { modelId: "composer-2", parameters: [] });
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest embeds user text and resolves the model id", () => {
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "say only PING",
|
||||
conversationId: "00000000-0000-0000-0000-000000000000",
|
||||
messageId: "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
// Verifiable substrings: cursor-agent itself emits these on the wire.
|
||||
const text = buf.toString("latin1");
|
||||
assert.ok(text.includes("say only PING"), "user text present");
|
||||
assert.ok(text.includes("default"), "auto rewritten to 'default'");
|
||||
assert.ok(text.includes("00000000-0000-0000-0000-000000000000"), "conversation id present");
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest emits composer parameters", () => {
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "composer-2-fast",
|
||||
userText: "hi",
|
||||
});
|
||||
const text = buf.toString("latin1");
|
||||
assert.ok(text.includes("composer-2"), "split model id present");
|
||||
assert.ok(text.includes("fast"), "parameter id 'fast' present");
|
||||
assert.ok(text.includes("true"), "parameter value 'true' present");
|
||||
});
|
||||
|
||||
test("buildAgentRequestBody wraps the message in a Connect-RPC frame", () => {
|
||||
const buf = buildAgentRequestBody({ modelId: "claude-4.6-sonnet-medium", userText: "hi" });
|
||||
// First byte is flags (0x00 = uncompressed); next 4 bytes are big-endian length.
|
||||
assert.equal(buf[0], 0x00);
|
||||
const length = buf.readUInt32BE(1);
|
||||
assert.equal(length, buf.length - 5);
|
||||
});
|
||||
|
||||
test("iterateConnectFrames + decodeAgentServerMessage extract a text delta", () => {
|
||||
// Synthesize a server-side delta frame: AgentServerMessage { interaction_update {
|
||||
// text_delta { text = "hello" } } }
|
||||
function v(n: number): Buffer {
|
||||
const out: number[] = [];
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field: number, wt: number) {
|
||||
return v((field << 3) | wt);
|
||||
}
|
||||
function lenPrefixed(field: number, payload: Buffer) {
|
||||
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
||||
}
|
||||
const textDelta = Buffer.from("hello", "utf8");
|
||||
const tdu = lenPrefixed(1, textDelta); // TextDeltaUpdate { text }
|
||||
const iu = lenPrefixed(1, tdu); // InteractionUpdate { text_delta }
|
||||
const asm = lenPrefixed(1, iu); // AgentServerMessage { interaction_update }
|
||||
const framed = wrapConnectFrame(asm);
|
||||
|
||||
const frames = [...iterateConnectFrames(framed)];
|
||||
assert.equal(frames.length, 1);
|
||||
const deltas = decodeAgentServerMessage(frames[0].payload);
|
||||
assert.deepEqual(deltas, [{ kind: "text", text: "hello" }]);
|
||||
});
|
||||
|
||||
test("flattenMessages handles a simple single user message", () => {
|
||||
assert.equal(flattenMessages([{ role: "user", content: "hello there" }]), "hello there");
|
||||
});
|
||||
|
||||
test("flattenMessages prepends system prompts and labels multi-turn", () => {
|
||||
const out = flattenMessages([
|
||||
{ role: "system", content: "be brief" },
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "hello" },
|
||||
{ role: "user", content: "again" },
|
||||
]);
|
||||
assert.ok(out.startsWith("be brief"));
|
||||
assert.ok(out.includes("User: hi"));
|
||||
assert.ok(out.includes("Assistant: hello"));
|
||||
assert.ok(out.includes("User: again"));
|
||||
});
|
||||
|
||||
test("flattenMessages flattens content arrays", () => {
|
||||
const out = flattenMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "part1" },
|
||||
{ type: "text", text: "part2" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(out, "part1\npart2");
|
||||
});
|
||||
|
||||
// ─── Phase 1: rejection encoders ───────────────────────────────────────────
|
||||
//
|
||||
// All ExecClientMessage frames share a structure:
|
||||
// wrapConnectFrame(
|
||||
// AgentClientMessage {
|
||||
// exec_client_message (2): ExecClientMessage {
|
||||
// id (1): execMsgId,
|
||||
// exec_id (15): execId,
|
||||
// <result_field>: <result_payload>,
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
//
|
||||
// We unwrap via decodeFields and assert: (1) framing is valid, (2) the
|
||||
// ExecClientMessage carries the right id+exec_id, (3) the result field
|
||||
// number matches the variant, (4) the payload bytes contain the rejection
|
||||
// reason as a substring (sufficient since Buffer.from(reason, "utf8") is
|
||||
// embedded verbatim by encodeString).
|
||||
|
||||
function unwrapFrame(buf: Buffer): Buffer {
|
||||
const frames = [...iterateConnectFrames(buf)];
|
||||
assert.equal(frames.length, 1);
|
||||
return frames[0].payload;
|
||||
}
|
||||
|
||||
function assertEcmShape(
|
||||
framed: Buffer,
|
||||
expectedExecMsgId: number,
|
||||
expectedExecId: string,
|
||||
expectedResultField: number
|
||||
) {
|
||||
const payload = unwrapFrame(framed);
|
||||
// Top-level: AgentClientMessage { exec_client_message (2): ECM }
|
||||
// Just verify the fragment contains the exec id and result-field tag.
|
||||
assert.ok(payload.includes(Buffer.from(expectedExecId, "utf8")), "exec_id present");
|
||||
// Tag for result field with WT_LEN (2): (field<<3)|2
|
||||
const resultTag = (expectedResultField << 3) | 2;
|
||||
let found = false;
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
if (payload[i] === resultTag) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(found, `result tag for field ${expectedResultField} present`);
|
||||
void expectedExecMsgId; // varint encoded; covered by other tests
|
||||
}
|
||||
|
||||
test("encodeExecReadRejected wraps in read_result (field 7) with reason", () => {
|
||||
const framed = encodeExecReadRejected(42, "exec-abc", "/etc/passwd", "denied");
|
||||
assertEcmShape(framed, 42, "exec-abc", 7);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("/etc/passwd", "utf8")));
|
||||
assert.ok(payload.includes(Buffer.from("denied", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeExecWriteRejected wraps in write_result (field 3)", () => {
|
||||
const framed = encodeExecWriteRejected(1, "x", "/tmp/foo", "no");
|
||||
assertEcmShape(framed, 1, "x", 3);
|
||||
});
|
||||
|
||||
test("encodeExecDeleteRejected wraps in delete_result (field 4)", () => {
|
||||
const framed = encodeExecDeleteRejected(1, "x", "/tmp/foo", "no");
|
||||
assertEcmShape(framed, 1, "x", 4);
|
||||
});
|
||||
|
||||
test("encodeExecLsRejected wraps in ls_result (field 8)", () => {
|
||||
const framed = encodeExecLsRejected(1, "x", "/tmp", "no");
|
||||
assertEcmShape(framed, 1, "x", 8);
|
||||
});
|
||||
|
||||
test("encodeExecShellRejected carries command and working directory", () => {
|
||||
const framed = encodeExecShellRejected(7, "exec-7", "rm -rf /", "/home", "denied");
|
||||
assertEcmShape(framed, 7, "exec-7", 2);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("rm -rf /", "utf8")));
|
||||
assert.ok(payload.includes(Buffer.from("/home", "utf8")));
|
||||
assert.ok(payload.includes(Buffer.from("denied", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeExecBackgroundShellSpawnRejected uses field 16", () => {
|
||||
const framed = encodeExecBackgroundShellSpawnRejected(1, "x", "sleep", "/", "no");
|
||||
assertEcmShape(framed, 1, "x", 16);
|
||||
});
|
||||
|
||||
test("encodeExecGrepError uses field 5 with error message", () => {
|
||||
const framed = encodeExecGrepError(1, "exec-grep", "regex too complex");
|
||||
assertEcmShape(framed, 1, "exec-grep", 5);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("regex too complex", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeExecFetchError uses field 20 with url and error", () => {
|
||||
const framed = encodeExecFetchError(1, "x", "https://example.com", "timeout");
|
||||
assertEcmShape(framed, 1, "x", 20);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("https://example.com", "utf8")));
|
||||
assert.ok(payload.includes(Buffer.from("timeout", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeExecWriteShellStdinError uses field 23", () => {
|
||||
const framed = encodeExecWriteShellStdinError(1, "x", "no shell");
|
||||
assertEcmShape(framed, 1, "x", 23);
|
||||
});
|
||||
|
||||
test("encodeExecDiagnosticsResult is empty success on field 9", () => {
|
||||
const framed = encodeExecDiagnosticsResult(1, "exec-diag");
|
||||
assertEcmShape(framed, 1, "exec-diag", 9);
|
||||
});
|
||||
|
||||
// ─── Phase 1: MCP encoders ──────────────────────────────────────────────────
|
||||
|
||||
test("encodeExecMcpResult wraps text content in mcp_result (field 11)", () => {
|
||||
const framed = encodeExecMcpResult(1, "exec-mcp", "tool output", false);
|
||||
assertEcmShape(framed, 1, "exec-mcp", 11);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("tool output", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeExecMcpResult sets is_error when isError=true", () => {
|
||||
const framed = encodeExecMcpResult(1, "x", "err msg", true);
|
||||
// is_error field would be encoded as varint 1 — verify framing only
|
||||
assertEcmShape(framed, 1, "x", 11);
|
||||
});
|
||||
|
||||
test("encodeExecMcpError wraps error in mcp_result", () => {
|
||||
const framed = encodeExecMcpError(1, "exec-err", "tool crashed");
|
||||
assertEcmShape(framed, 1, "exec-err", 11);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("tool crashed", "utf8")));
|
||||
});
|
||||
|
||||
// ─── Phase 1: KV blob encoders ──────────────────────────────────────────────
|
||||
|
||||
test("encodeKvGetBlobResult wraps in kv_client_message (field 3)", () => {
|
||||
const framed = encodeKvGetBlobResult(99, Buffer.from("blob-content", "utf8"));
|
||||
const payload = unwrapFrame(framed);
|
||||
// Top-level field 3 (kv_client_message) tag = (3<<3)|2 = 26
|
||||
assert.equal(payload[0], 26);
|
||||
assert.ok(payload.includes(Buffer.from("blob-content", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeKvSetBlobResult is an empty ack", () => {
|
||||
const framed = encodeKvSetBlobResult(7);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.equal(payload[0], 26); // (3<<3)|2 = kv_client_message
|
||||
});
|
||||
|
||||
// ─── Phase 1: MCP tool definition body ──────────────────────────────────────
|
||||
|
||||
test("encodeMcpToolDefinitionBody encodes name, description, schema bytes", () => {
|
||||
const body = encodeMcpToolDefinitionBody({
|
||||
name: "get_weather",
|
||||
description: "Look up current weather",
|
||||
inputSchemaBytes: Buffer.from("schema-bytes"),
|
||||
providerIdentifier: "omniroute",
|
||||
toolName: "get_weather",
|
||||
});
|
||||
assert.ok(body.includes(Buffer.from("get_weather", "utf8")));
|
||||
assert.ok(body.includes(Buffer.from("Look up current weather", "utf8")));
|
||||
assert.ok(body.includes(Buffer.from("schema-bytes")));
|
||||
assert.ok(body.includes(Buffer.from("omniroute", "utf8")));
|
||||
});
|
||||
|
||||
test("encodeMcpToolDefinitionBody omits optional providerIdentifier and toolName", () => {
|
||||
const body = encodeMcpToolDefinitionBody({
|
||||
name: "n",
|
||||
description: "d",
|
||||
inputSchemaBytes: Buffer.alloc(0),
|
||||
});
|
||||
assert.ok(!body.includes(Buffer.from("omniroute", "utf8")));
|
||||
});
|
||||
|
||||
// ─── Phase 1: encodeRequestContextResponse with tools ───────────────────────
|
||||
|
||||
test("encodeRequestContextResponse with no tools produces empty RequestContext (existing behavior)", () => {
|
||||
const framed = encodeRequestContextResponse(5, "exec-rc");
|
||||
assertEcmShape(framed, 5, "exec-rc", 10); // request_context_result = field 10
|
||||
});
|
||||
|
||||
test("encodeRequestContextResponse with tools embeds tool name and schema", () => {
|
||||
const framed = encodeRequestContextResponse(5, "exec-rc", [
|
||||
{
|
||||
name: "get_weather",
|
||||
description: "lookup",
|
||||
inputSchemaBytes: jsonSchemaToProtobufValue({
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
}),
|
||||
providerIdentifier: "omniroute",
|
||||
toolName: "get_weather",
|
||||
},
|
||||
]);
|
||||
assertEcmShape(framed, 5, "exec-rc", 10);
|
||||
const payload = unwrapFrame(framed);
|
||||
assert.ok(payload.includes(Buffer.from("get_weather", "utf8")));
|
||||
assert.ok(payload.includes(Buffer.from("city", "utf8")));
|
||||
});
|
||||
|
||||
// ─── Phase 1: jsonSchemaToProtobufValue ─────────────────────────────────────
|
||||
//
|
||||
// google.protobuf.Value is a oneof message. We verify the encoded bytes
|
||||
// start with the right field tag for each kind, and (for nested types) that
|
||||
// the inner content survives.
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes a string as field 3", () => {
|
||||
const buf = jsonSchemaToProtobufValue("hello");
|
||||
// tag (3<<3)|2 = 26
|
||||
assert.equal(buf[0], 26);
|
||||
assert.ok(buf.includes(Buffer.from("hello", "utf8")));
|
||||
});
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes a number as field 2 (double)", () => {
|
||||
const buf = jsonSchemaToProtobufValue(3.14);
|
||||
// tag (2<<3)|1 = 17
|
||||
assert.equal(buf[0], 17);
|
||||
assert.equal(buf.length, 9); // 1-byte tag + 8-byte double
|
||||
assert.equal(buf.readDoubleLE(1), 3.14);
|
||||
});
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes a bool as field 4", () => {
|
||||
const buf = jsonSchemaToProtobufValue(true);
|
||||
// tag (4<<3)|0 = 32
|
||||
assert.equal(buf[0], 32);
|
||||
assert.equal(buf[1], 1);
|
||||
});
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes null as field 1", () => {
|
||||
const buf = jsonSchemaToProtobufValue(null);
|
||||
// tag (1<<3)|0 = 8
|
||||
assert.equal(buf[0], 8);
|
||||
assert.equal(buf[1], 0);
|
||||
});
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes a list as field 6", () => {
|
||||
const buf = jsonSchemaToProtobufValue(["a", "b"]);
|
||||
// tag (6<<3)|2 = 50
|
||||
assert.equal(buf[0], 50);
|
||||
assert.ok(buf.includes(Buffer.from("a", "utf8")));
|
||||
assert.ok(buf.includes(Buffer.from("b", "utf8")));
|
||||
});
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes a struct as field 5", () => {
|
||||
const buf = jsonSchemaToProtobufValue({ key: "value" });
|
||||
// tag (5<<3)|2 = 42
|
||||
assert.equal(buf[0], 42);
|
||||
assert.ok(buf.includes(Buffer.from("key", "utf8")));
|
||||
assert.ok(buf.includes(Buffer.from("value", "utf8")));
|
||||
});
|
||||
|
||||
test("jsonSchemaToProtobufValue encodes a nested OpenAI tool input_schema", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string", description: "the city" },
|
||||
units: { type: "string", enum: ["c", "f"] },
|
||||
},
|
||||
required: ["city"],
|
||||
};
|
||||
const buf = jsonSchemaToProtobufValue(schema);
|
||||
// Outer tag is struct (5)
|
||||
assert.equal(buf[0], 42);
|
||||
assert.ok(buf.includes(Buffer.from("city", "utf8")));
|
||||
assert.ok(buf.includes(Buffer.from("units", "utf8")));
|
||||
assert.ok(buf.includes(Buffer.from("required", "utf8")));
|
||||
assert.ok(buf.includes(Buffer.from("the city", "utf8")));
|
||||
});
|
||||
|
||||
// ─── Phase 3: tools in AgentRunRequest ─────────────────────────────────────
|
||||
|
||||
test("openAIToolsToMcpDefs converts OpenAI tool array to McpToolDefinition[]", () => {
|
||||
const defs = openAIToolsToMcpDefs([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "lookup",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(defs.length, 1);
|
||||
assert.equal(defs[0].name, "get_weather");
|
||||
assert.equal(defs[0].description, "lookup");
|
||||
assert.equal(defs[0].toolName, "get_weather");
|
||||
assert.equal(defs[0].providerIdentifier, "omniroute");
|
||||
assert.ok(defs[0].inputSchemaBytes.length > 0);
|
||||
// Schema bytes are a Struct (field 5)
|
||||
assert.equal(defs[0].inputSchemaBytes[0], 42);
|
||||
});
|
||||
|
||||
test("openAIToolsToMcpDefs supplies a default schema if parameters omitted", () => {
|
||||
const defs = openAIToolsToMcpDefs([{ type: "function", function: { name: "no_params" } }]);
|
||||
assert.equal(defs.length, 1);
|
||||
// Default schema is { type: "object", properties: {} } — encoded as struct
|
||||
assert.equal(defs[0].inputSchemaBytes[0], 42);
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest with tools embeds tool name and schema in mcp_tools", () => {
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "claude-4.6-sonnet-medium",
|
||||
userText: "what's the weather?",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Look up current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const text = buf.toString("latin1");
|
||||
assert.ok(text.includes("get_weather"), "tool name present");
|
||||
assert.ok(text.includes("Look up current weather"), "tool description present");
|
||||
assert.ok(text.includes("city"), "tool schema field present");
|
||||
assert.ok(text.includes("omniroute"), "provider_identifier set");
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest without tools preserves empty mcp_tools placeholder", () => {
|
||||
const bufNoTools = encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "hi",
|
||||
});
|
||||
const bufEmptyTools = encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "hi",
|
||||
tools: [],
|
||||
});
|
||||
// Both should produce essentially the same shape — neither embeds tools.
|
||||
// Lengths may differ by message-id randomness; just verify neither contains
|
||||
// tool-related markers.
|
||||
assert.ok(!bufNoTools.toString("latin1").includes("omniroute"));
|
||||
assert.ok(!bufEmptyTools.toString("latin1").includes("omniroute"));
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest with multiple tools embeds all of them", () => {
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "test",
|
||||
tools: [
|
||||
{ type: "function", function: { name: "tool_a", description: "A" } },
|
||||
{ type: "function", function: { name: "tool_b", description: "B" } },
|
||||
],
|
||||
});
|
||||
const text = buf.toString("latin1");
|
||||
assert.ok(text.includes("tool_a"));
|
||||
assert.ok(text.includes("tool_b"));
|
||||
});
|
||||
227
tests/unit/cursor-agent-session.test.ts
Normal file
227
tests/unit/cursor-agent-session.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
CursorSessionManager,
|
||||
type CursorSession,
|
||||
} from "../../open-sse/services/cursorSessionManager";
|
||||
import { flattenMessages } from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
|
||||
// ─── Test doubles for h2 ───────────────────────────────────────────────────
|
||||
//
|
||||
// We don't open real h2 connections in unit tests. Sessions hold opaque
|
||||
// references that the manager only ever .close()s or .write()s through
|
||||
// encodeExecMcpResult. A pair of stubs is enough.
|
||||
|
||||
type WriteCall = { kind: "write"; data: Buffer } | { kind: "close" };
|
||||
|
||||
function mockReq() {
|
||||
const calls: WriteCall[] = [];
|
||||
return {
|
||||
req: {
|
||||
write: (data: Buffer) => {
|
||||
calls.push({ kind: "write", data });
|
||||
return true;
|
||||
},
|
||||
close: () => {
|
||||
calls.push({ kind: "close" });
|
||||
},
|
||||
} as unknown as import("node:http2").ClientHttp2Stream,
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
function mockClient() {
|
||||
const closed = { value: false };
|
||||
return {
|
||||
client: {
|
||||
close: () => {
|
||||
closed.value = true;
|
||||
},
|
||||
} as unknown as import("node:http2").ClientHttp2Session,
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── flattenMessages: Phase 6 cold-resume support ──────────────────────────
|
||||
|
||||
test("flattenMessages handles role:'tool' messages", () => {
|
||||
const out = flattenMessages([
|
||||
{ role: "user", content: "what's the weather?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_xyz",
|
||||
type: "function",
|
||||
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_xyz", content: "sunny, 22C" },
|
||||
]);
|
||||
assert.match(out, /User: what's the weather\?/);
|
||||
assert.match(
|
||||
out,
|
||||
/Assistant called tool get_weather \(call_xyz\) with arguments: \{"city":"Paris"\}/
|
||||
);
|
||||
assert.match(out, /Tool result \(call_xyz\): sunny, 22C/);
|
||||
});
|
||||
|
||||
test("flattenMessages handles assistant with text + tool_calls in same message", () => {
|
||||
const out = flattenMessages([
|
||||
{ role: "user", content: "do x" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Let me check.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: { name: "check", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "c1", content: "result" },
|
||||
]);
|
||||
assert.match(out, /Assistant: Let me check\./);
|
||||
assert.match(out, /Assistant called tool check \(c1\) with arguments: \{\}/);
|
||||
assert.match(out, /Tool result \(c1\): result/);
|
||||
});
|
||||
|
||||
test("flattenMessages handles parallel tool_calls", () => {
|
||||
const out = flattenMessages([
|
||||
{ role: "user", content: "check both" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "c1", type: "function", function: { name: "tool_a", arguments: "{}" } },
|
||||
{ id: "c2", type: "function", function: { name: "tool_b", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "c1", content: "result_a" },
|
||||
{ role: "tool", tool_call_id: "c2", content: "result_b" },
|
||||
]);
|
||||
assert.match(out, /tool_a \(c1\)/);
|
||||
assert.match(out, /tool_b \(c2\)/);
|
||||
assert.match(out, /Tool result \(c1\): result_a/);
|
||||
assert.match(out, /Tool result \(c2\): result_b/);
|
||||
});
|
||||
|
||||
test("flattenMessages keeps single-user fast path unchanged when no tool_calls", () => {
|
||||
const out = flattenMessages([{ role: "user", content: "hi" }]);
|
||||
assert.equal(out, "hi");
|
||||
});
|
||||
|
||||
// ─── CursorSessionManager lifecycle ────────────────────────────────────────
|
||||
|
||||
test("CursorSessionManager.open registers a session under conversation_id", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const { req } = mockReq();
|
||||
const { client } = mockClient();
|
||||
const session = m.open("conv-1", client, req, new Map());
|
||||
assert.equal(m.size(), 1);
|
||||
assert.ok(m.has("conv-1"));
|
||||
assert.equal(session.conversationId, "conv-1");
|
||||
assert.equal(session.state, "running");
|
||||
});
|
||||
|
||||
test("CursorSessionManager.acquire returns undefined when no session", () => {
|
||||
const m = new CursorSessionManager();
|
||||
assert.equal(m.acquire("nope"), undefined);
|
||||
});
|
||||
|
||||
test("CursorSessionManager.acquire returns undefined when session is still running", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const { req } = mockReq();
|
||||
const { client } = mockClient();
|
||||
m.open("conv-2", client, req, new Map());
|
||||
// open() leaves state="running"; acquire requires "awaiting_tool_result"
|
||||
assert.equal(m.acquire("conv-2"), undefined);
|
||||
});
|
||||
|
||||
test("CursorSessionManager.acquire returns the session after release(awaiting_tool_result)", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const { req } = mockReq();
|
||||
const { client } = mockClient();
|
||||
const opened = m.open("conv-3", client, req, new Map());
|
||||
m.release(opened, "awaiting_tool_result");
|
||||
const acquired = m.acquire("conv-3");
|
||||
assert.equal(acquired, opened);
|
||||
assert.equal(acquired?.state, "running");
|
||||
});
|
||||
|
||||
test("CursorSessionManager.release(idle) closes the session", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const { req, calls } = mockReq();
|
||||
const { client, closed } = mockClient();
|
||||
const session = m.open("conv-4", client, req, new Map());
|
||||
m.release(session, "idle");
|
||||
assert.equal(m.size(), 0);
|
||||
assert.ok(closed.value);
|
||||
assert.ok(calls.some((c) => c.kind === "close"));
|
||||
});
|
||||
|
||||
test("CursorSessionManager.acquire evicts expired sessions", () => {
|
||||
const m = new CursorSessionManager({ idleTtlMs: 10 });
|
||||
const { req } = mockReq();
|
||||
const { client, closed } = mockClient();
|
||||
const session = m.open("conv-5", client, req, new Map());
|
||||
m.release(session, "awaiting_tool_result");
|
||||
// Manually backdate lastActivityTs to simulate idle.
|
||||
session.lastActivityTs = Date.now() - 1000;
|
||||
const acquired = m.acquire("conv-5");
|
||||
assert.equal(acquired, undefined);
|
||||
assert.equal(m.size(), 0);
|
||||
assert.ok(closed.value);
|
||||
});
|
||||
|
||||
test("CursorSessionManager.sendToolResult writes ExecMcpResult on the session's req", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const { req, calls } = mockReq();
|
||||
const { client } = mockClient();
|
||||
const session = m.open("conv-6", client, req, new Map());
|
||||
session.pendingToolCalls.set("call_x", {
|
||||
execMsgId: 1,
|
||||
execId: "exec-1",
|
||||
toolName: "get_weather",
|
||||
});
|
||||
const ok = m.sendToolResult(session, "call_x", "sunny", false);
|
||||
assert.equal(ok, true);
|
||||
// Verify a write happened
|
||||
const writes = calls.filter((c) => c.kind === "write");
|
||||
assert.equal(writes.length, 1);
|
||||
// The write should be a Connect-RPC frame containing "sunny" and "exec-1"
|
||||
const data = (writes[0] as { kind: "write"; data: Buffer }).data;
|
||||
assert.ok(data.includes(Buffer.from("sunny", "utf8")));
|
||||
assert.ok(data.includes(Buffer.from("exec-1", "utf8")));
|
||||
// Pending tool call was consumed
|
||||
assert.equal(session.pendingToolCalls.has("call_x"), false);
|
||||
});
|
||||
|
||||
test("CursorSessionManager.sendToolResult returns false when openAIToolCallId not pending", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const { req } = mockReq();
|
||||
const { client } = mockClient();
|
||||
const session = m.open("conv-7", client, req, new Map());
|
||||
const ok = m.sendToolResult(session, "unknown_id", "x", false);
|
||||
assert.equal(ok, false);
|
||||
});
|
||||
|
||||
test("CursorSessionManager.open replaces an existing session for the same conversation", () => {
|
||||
const m = new CursorSessionManager();
|
||||
const r1 = mockReq();
|
||||
const c1 = mockClient();
|
||||
const session1 = m.open("conv-8", c1.client, r1.req, new Map());
|
||||
m.release(session1, "awaiting_tool_result");
|
||||
const r2 = mockReq();
|
||||
const c2 = mockClient();
|
||||
const session2 = m.open("conv-8", c2.client, r2.req, new Map());
|
||||
// First session's client should be closed
|
||||
assert.ok(c1.closed.value);
|
||||
// Map only has one session; the new one
|
||||
assert.equal(m.size(), 1);
|
||||
assert.equal(m.acquire("conv-8"), undefined); // session2 is "running"
|
||||
void session2;
|
||||
});
|
||||
122
tests/unit/cursor-agent-system-prompt.test.ts
Normal file
122
tests/unit/cursor-agent-system-prompt.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
encodeAgentRunRequest,
|
||||
decodeKvServerEvent,
|
||||
} from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
|
||||
// ─── Wire-format helpers ───────────────────────────────────────────────────
|
||||
|
||||
function v(n: number): Buffer {
|
||||
const out: number[] = [];
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field: number, wireType: number): Buffer {
|
||||
return v((field << 3) | wireType);
|
||||
}
|
||||
function lenPrefixed(field: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
||||
}
|
||||
function varintField(field: number, value: number): Buffer {
|
||||
return Buffer.concat([tag(field, 0), v(value)]);
|
||||
}
|
||||
|
||||
// ─── encodeAgentRunRequest with system prompt ──────────────────────────────
|
||||
|
||||
test("encodeAgentRunRequest with systemPrompt populates blobStore", () => {
|
||||
const blobStore = new Map<string, Buffer>();
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "hi",
|
||||
systemPrompt: "be brief",
|
||||
blobStore,
|
||||
});
|
||||
// The blobStore should have one entry (the system prompt blob)
|
||||
assert.equal(blobStore.size, 1);
|
||||
// Key is hex of sha256({role:"system", content:"be brief"})
|
||||
const expectedJson = JSON.stringify({ role: "system", content: "be brief" });
|
||||
const expectedHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(Buffer.from(expectedJson, "utf8"))
|
||||
.digest("hex");
|
||||
assert.ok(blobStore.has(expectedHash));
|
||||
// The stored bytes should match the JSON
|
||||
assert.equal(blobStore.get(expectedHash)!.toString("utf8"), expectedJson);
|
||||
// The blob hash (32 bytes) should appear in the encoded request body
|
||||
const hashBytes = Buffer.from(expectedHash, "hex");
|
||||
assert.ok(buf.includes(hashBytes), "blob id embedded in CSS");
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest without systemPrompt leaves blobStore empty", () => {
|
||||
const blobStore = new Map<string, Buffer>();
|
||||
encodeAgentRunRequest({ modelId: "auto", userText: "hi", blobStore });
|
||||
assert.equal(blobStore.size, 0);
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest with systemPrompt but no blobStore is a no-op for blobs", () => {
|
||||
// The system prompt is silently dropped if blobStore isn't provided.
|
||||
// This matches the existing pre-Phase-7 behavior.
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "hi",
|
||||
systemPrompt: "be brief",
|
||||
});
|
||||
// No crash; the system prompt isn't embedded (no blob to reference).
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest with multi-message system prompt joins them", () => {
|
||||
const blobStore = new Map<string, Buffer>();
|
||||
encodeAgentRunRequest({
|
||||
modelId: "auto",
|
||||
userText: "hi",
|
||||
systemPrompt: "first instruction\n\nsecond instruction",
|
||||
blobStore,
|
||||
});
|
||||
assert.equal(blobStore.size, 1);
|
||||
const [stored] = [...blobStore.values()];
|
||||
const parsed = JSON.parse(stored.toString("utf8"));
|
||||
assert.equal(parsed.role, "system");
|
||||
assert.equal(parsed.content, "first instruction\n\nsecond instruction");
|
||||
});
|
||||
|
||||
// ─── KV server message decoder ─────────────────────────────────────────────
|
||||
|
||||
test("decodeKvServerEvent recognizes get_blob_args", () => {
|
||||
// KvServerMessage { id (1): 7, get_blob_args (2): { blob_id (1): bytes } }
|
||||
const blobId = Buffer.from("01020304".repeat(8), "hex");
|
||||
const getBlobArgs = lenPrefixed(1, blobId);
|
||||
const ksm = Buffer.concat([varintField(1, 7), lenPrefixed(2, getBlobArgs)]);
|
||||
const asm = lenPrefixed(4, ksm); // ASM_KV_SERVER_MESSAGE = 4
|
||||
const event = decodeKvServerEvent(asm);
|
||||
assert.deepEqual(event, { kind: "kv_get_blob", kvId: 7, blobId, requestMetadata: null });
|
||||
});
|
||||
|
||||
test("decodeKvServerEvent recognizes set_blob_args with data", () => {
|
||||
// SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes }
|
||||
const blobId = Buffer.from("aa".repeat(32), "hex");
|
||||
const blobData = Buffer.from("hello world", "utf8");
|
||||
const setBlobArgs = Buffer.concat([lenPrefixed(1, blobId), lenPrefixed(2, blobData)]);
|
||||
const ksm = Buffer.concat([varintField(1, 5), lenPrefixed(3, setBlobArgs)]);
|
||||
const asm = lenPrefixed(4, ksm);
|
||||
const event = decodeKvServerEvent(asm);
|
||||
if (event?.kind !== "kv_set_blob") throw new Error("expected kv_set_blob");
|
||||
assert.equal(event.kvId, 5);
|
||||
assert.ok(event.blobId.equals(blobId));
|
||||
assert.ok(event.blobData.equals(blobData));
|
||||
assert.equal(event.requestMetadata, null);
|
||||
});
|
||||
|
||||
test("decodeKvServerEvent returns null when AgentServerMessage has no kv frame", () => {
|
||||
// ASM with only an interaction_update (field 1)
|
||||
const tdu = lenPrefixed(1, Buffer.from("hi"));
|
||||
const iu = lenPrefixed(1, tdu);
|
||||
const asm = lenPrefixed(1, iu);
|
||||
assert.equal(decodeKvServerEvent(asm), null);
|
||||
});
|
||||
236
tests/unit/cursor-agent-tool-calls.test.ts
Normal file
236
tests/unit/cursor-agent-tool-calls.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
decodeExecServerEvent,
|
||||
decodeProtobufValue,
|
||||
jsonSchemaToProtobufValue,
|
||||
} from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
import { newStreamCtx, processFrame } from "../../open-sse/executors/cursor";
|
||||
|
||||
// ─── Wire-format helpers ───────────────────────────────────────────────────
|
||||
|
||||
function v(n: number): Buffer {
|
||||
const out: number[] = [];
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field: number, wireType: number): Buffer {
|
||||
return v((field << 3) | wireType);
|
||||
}
|
||||
function lenPrefixed(field: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
||||
}
|
||||
function stringField(field: number, value: string): Buffer {
|
||||
return lenPrefixed(field, Buffer.from(value, "utf8"));
|
||||
}
|
||||
function varintField(field: number, value: number): Buffer {
|
||||
return Buffer.concat([tag(field, 0), v(value)]);
|
||||
}
|
||||
|
||||
// Encodes a single map<string, bytes> entry: { key (1): k, value (2): valueBytes }
|
||||
function mapEntry(field: number, key: string, valueBytes: Buffer): Buffer {
|
||||
const entry = Buffer.concat([stringField(1, key), lenPrefixed(2, valueBytes)]);
|
||||
return lenPrefixed(field, entry);
|
||||
}
|
||||
|
||||
// AgentServerMessage { exec_server_message (2): ESM { id, exec_id, mcp_args (11): ... } }
|
||||
function buildMcpArgsEvent(
|
||||
execMsgId: number,
|
||||
execId: string,
|
||||
toolName: string,
|
||||
toolCallId: string,
|
||||
args: Record<string, unknown>
|
||||
): Buffer {
|
||||
const mcaParts: Buffer[] = [
|
||||
stringField(1, toolName), // name
|
||||
stringField(3, toolCallId), // tool_call_id
|
||||
];
|
||||
for (const [k, val] of Object.entries(args)) {
|
||||
const valueBytes = jsonSchemaToProtobufValue(val);
|
||||
mcaParts.push(mapEntry(2, k, valueBytes)); // args = field 2
|
||||
}
|
||||
const mca = Buffer.concat(mcaParts);
|
||||
const esm = Buffer.concat([
|
||||
varintField(1, execMsgId),
|
||||
stringField(15, execId),
|
||||
lenPrefixed(11, mca),
|
||||
]);
|
||||
return lenPrefixed(2, esm);
|
||||
}
|
||||
|
||||
// ─── decodeProtobufValue round-trip tests ──────────────────────────────────
|
||||
|
||||
test("decodeProtobufValue round-trips primitives", () => {
|
||||
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue("hi") as Buffer), "hi");
|
||||
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(42.5)), 42.5);
|
||||
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(true)), true);
|
||||
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(false)), false);
|
||||
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(null)), null);
|
||||
});
|
||||
|
||||
test("decodeProtobufValue round-trips a flat object", () => {
|
||||
const obj = { name: "alice", age: 30, active: true };
|
||||
const encoded = jsonSchemaToProtobufValue(obj);
|
||||
assert.deepEqual(decodeProtobufValue(encoded), obj);
|
||||
});
|
||||
|
||||
test("decodeProtobufValue round-trips an array of strings", () => {
|
||||
const arr = ["a", "b", "c"];
|
||||
const encoded = jsonSchemaToProtobufValue(arr);
|
||||
assert.deepEqual(decodeProtobufValue(encoded), arr);
|
||||
});
|
||||
|
||||
test("decodeProtobufValue round-trips a nested object with mixed types", () => {
|
||||
const obj = {
|
||||
city: "Paris",
|
||||
coords: { lat: 48.8566, lng: 2.3522 },
|
||||
tags: ["europe", "capital"],
|
||||
populated: true,
|
||||
sister_city: null,
|
||||
};
|
||||
const encoded = jsonSchemaToProtobufValue(obj);
|
||||
assert.deepEqual(decodeProtobufValue(encoded), obj);
|
||||
});
|
||||
|
||||
test("decodeProtobufValue handles deeply nested OpenAI tool args", () => {
|
||||
const args = {
|
||||
location: { city: "Tokyo", country: "JP" },
|
||||
units: "metric",
|
||||
days: 7,
|
||||
};
|
||||
const encoded = jsonSchemaToProtobufValue(args);
|
||||
assert.deepEqual(decodeProtobufValue(encoded), args);
|
||||
});
|
||||
|
||||
// ─── McpArgs decoding tests ────────────────────────────────────────────────
|
||||
|
||||
test("decodeExecServerEvent populates args dict from McpArgs map entries", () => {
|
||||
const args = { city: "Paris", units: "celsius" };
|
||||
const event = decodeExecServerEvent(
|
||||
buildMcpArgsEvent(1, "exec-w", "get_weather", "call_abc", args)
|
||||
);
|
||||
assert.equal(event?.kind, "exec_mcp");
|
||||
if (event?.kind !== "exec_mcp") return;
|
||||
assert.equal(event.toolName, "get_weather");
|
||||
assert.equal(event.toolCallId, "call_abc");
|
||||
assert.deepEqual(event.args, args);
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent handles McpArgs with empty args", () => {
|
||||
const event = decodeExecServerEvent(buildMcpArgsEvent(1, "exec-x", "no_args_tool", "call_x", {}));
|
||||
if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp");
|
||||
assert.deepEqual(event.args, {});
|
||||
});
|
||||
|
||||
test("decodeExecServerEvent handles McpArgs with nested object args", () => {
|
||||
const args = {
|
||||
location: { city: "London", country: "UK" },
|
||||
days: 3,
|
||||
};
|
||||
const event = decodeExecServerEvent(buildMcpArgsEvent(1, "exec-n", "weather", "call_n", args));
|
||||
if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp");
|
||||
assert.deepEqual(event.args, args);
|
||||
});
|
||||
|
||||
// ─── Streaming SSE tool_calls emission ─────────────────────────────────────
|
||||
|
||||
function parseChunk(line: string): {
|
||||
delta?: {
|
||||
tool_calls?: Array<{
|
||||
index: number;
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason?: string | null;
|
||||
} {
|
||||
return JSON.parse(line.replace(/^data: /, "").trim()).choices[0];
|
||||
}
|
||||
|
||||
test("processFrame emits OpenAI tool_calls deltas for an mcp_args event", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
const payload = buildMcpArgsEvent(1, "exec-tc", "get_weather", "cursor_call_x", {
|
||||
city: "Paris",
|
||||
});
|
||||
processFrame(payload, ctx, new Set());
|
||||
|
||||
// Expect: role chunk + tool_calls init chunk + tool_calls args chunk
|
||||
assert.equal(emitted.length, 3);
|
||||
const roleChunk = parseChunk(emitted[0]);
|
||||
assert.ok(roleChunk.delta);
|
||||
// Init chunk: id + name
|
||||
const initChunk = parseChunk(emitted[1]);
|
||||
const initCall = initChunk.delta?.tool_calls?.[0];
|
||||
assert.equal(initCall?.index, 0);
|
||||
assert.match(initCall?.id ?? "", /^call_/);
|
||||
assert.equal(initCall?.function?.name, "get_weather");
|
||||
assert.equal(initCall?.function?.arguments, "");
|
||||
// Args chunk
|
||||
const argsChunk = parseChunk(emitted[2]);
|
||||
const argsCall = argsChunk.delta?.tool_calls?.[0];
|
||||
assert.equal(argsCall?.index, 0);
|
||||
assert.equal(argsCall?.function?.arguments, JSON.stringify({ city: "Paris" }));
|
||||
|
||||
// Side-effects on ctx
|
||||
assert.equal(ctx.toolCalls.length, 1);
|
||||
assert.equal(ctx.toolCalls[0].name, "get_weather");
|
||||
assert.equal(ctx.emittedToolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("processFrame indexes parallel tool calls sequentially", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
const acked = new Set<string>();
|
||||
processFrame(buildMcpArgsEvent(1, "exec-1", "tool_a", "ca", { x: 1 }), ctx, acked);
|
||||
processFrame(buildMcpArgsEvent(2, "exec-2", "tool_b", "cb", { y: 2 }), ctx, acked);
|
||||
assert.equal(ctx.toolCalls.length, 2);
|
||||
assert.equal(ctx.emittedToolCallIndex, 2);
|
||||
// emitted[0] = role chunk
|
||||
// emitted[1] = init for tool_a (index=0)
|
||||
// emitted[2] = args for tool_a (index=0)
|
||||
// emitted[3] = init for tool_b (index=1)
|
||||
// emitted[4] = args for tool_b (index=1)
|
||||
const initB = parseChunk(emitted[3]);
|
||||
assert.equal(initB.delta?.tool_calls?.[0].index, 1);
|
||||
assert.equal(initB.delta?.tool_calls?.[0].function?.name, "tool_b");
|
||||
});
|
||||
|
||||
test("processFrame emits text + tool_calls in mixed order", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
const acked = new Set<string>();
|
||||
|
||||
// Build a text_delta payload
|
||||
function textDelta(text: string): Buffer {
|
||||
const tdu = lenPrefixed(1, Buffer.from(text, "utf8"));
|
||||
const iu = lenPrefixed(1, tdu);
|
||||
return lenPrefixed(1, iu);
|
||||
}
|
||||
|
||||
processFrame(textDelta("Let me check"), ctx, acked);
|
||||
processFrame(buildMcpArgsEvent(1, "exec-mix", "lookup", "c1", {}), ctx, acked);
|
||||
|
||||
assert.equal(ctx.totalText, "Let me check");
|
||||
assert.equal(ctx.toolCalls.length, 1);
|
||||
// Order: role chunk, content chunk, tool_call init, tool_call args
|
||||
assert.equal(emitted.length, 4);
|
||||
const contentChunk = JSON.parse(emitted[1].replace(/^data: /, "").trim());
|
||||
assert.equal(contentChunk.choices[0].delta.content, "Let me check");
|
||||
const initChunk = JSON.parse(emitted[2].replace(/^data: /, "").trim());
|
||||
assert.ok(initChunk.choices[0].delta.tool_calls);
|
||||
});
|
||||
|
||||
test("processFrame doesn't emit tool_calls for the same exec_id twice", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
const acked = new Set<string>();
|
||||
const payload = buildMcpArgsEvent(1, "exec-dup", "tool", "cd", {});
|
||||
processFrame(payload, ctx, acked);
|
||||
processFrame(payload, ctx, acked);
|
||||
assert.equal(ctx.toolCalls.length, 1);
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildChatRequest,
|
||||
decodeMessage,
|
||||
decodeField,
|
||||
encodeField,
|
||||
encodeMcpTool,
|
||||
extractTextFromResponse,
|
||||
generateCursorBody,
|
||||
parseConnectRPCFrame,
|
||||
wrapConnectRPCFrame,
|
||||
} from "../../open-sse/utils/cursorProtobuf.ts";
|
||||
|
||||
const LEN = 2;
|
||||
const VARINT = 0;
|
||||
const TOP_LEVEL_TOOL_CALL = 1;
|
||||
const TOP_LEVEL_RESPONSE = 2;
|
||||
const RESPONSE_TEXT = 1;
|
||||
const THINKING = 25;
|
||||
const THINKING_TEXT = 1;
|
||||
const TOOL_ID = 3;
|
||||
const TOOL_NAME = 9;
|
||||
const TOOL_RAW_ARGS = 10;
|
||||
const TOOL_IS_LAST_ALT = 15;
|
||||
const TOOL_MCP_PARAMS = 27;
|
||||
const MCP_TOOLS_LIST = 1;
|
||||
const MCP_NESTED_NAME = 1;
|
||||
const MCP_NESTED_PARAMS = 3;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function concatArrays(...arrays) {
|
||||
const total = arrays.reduce((sum, array) => sum + array.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
|
||||
for (const array of arrays) {
|
||||
result.set(array, offset);
|
||||
offset += array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
test("parseConnectRPCFrame round-trips compressed payloads", () => {
|
||||
const payload = textEncoder.encode("cursor-frame");
|
||||
const frame = wrapConnectRPCFrame(payload, true);
|
||||
const parsed = parseConnectRPCFrame(frame);
|
||||
|
||||
assert.equal(parsed.flags, 1);
|
||||
assert.equal(parsed.consumed, frame.length);
|
||||
assert.equal(textDecoder.decode(parsed.payload), "cursor-frame");
|
||||
});
|
||||
|
||||
test("parseConnectRPCFrame returns null for truncated frames", () => {
|
||||
const payload = textEncoder.encode("short");
|
||||
const frame = wrapConnectRPCFrame(payload, false);
|
||||
|
||||
assert.equal(parseConnectRPCFrame(frame.slice(0, frame.length - 1)), null);
|
||||
});
|
||||
|
||||
test("parseConnectRPCFrame keeps the raw payload when a compressed frame is not valid gzip", () => {
|
||||
const payload = textEncoder.encode("not-gzip");
|
||||
const frame = new Uint8Array(5 + payload.length);
|
||||
frame[0] = 0x01;
|
||||
frame[1] = (payload.length >> 24) & 0xff;
|
||||
frame[2] = (payload.length >> 16) & 0xff;
|
||||
frame[3] = (payload.length >> 8) & 0xff;
|
||||
frame[4] = payload.length & 0xff;
|
||||
frame.set(payload, 5);
|
||||
|
||||
const parsed = parseConnectRPCFrame(frame);
|
||||
|
||||
assert.equal(parsed.flags, 0x01);
|
||||
assert.equal(parsed.consumed, frame.length);
|
||||
assert.equal(textDecoder.decode(parsed.payload), "not-gzip");
|
||||
});
|
||||
|
||||
test("decodeField handles fixed-width wire types and past-end offsets", () => {
|
||||
const buffer = Uint8Array.from([
|
||||
(1 << 3) | 1,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
(2 << 3) | 5,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
]);
|
||||
|
||||
const [field1, wire1, value1, pos1] = decodeField(buffer, 0);
|
||||
const [field2, wire2, value2, pos2] = decodeField(buffer, pos1);
|
||||
const pastEnd = decodeField(buffer, pos2);
|
||||
|
||||
assert.equal(field1, 1);
|
||||
assert.equal(wire1, 1);
|
||||
assert.deepEqual([...value1], [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert.equal(field2, 2);
|
||||
assert.equal(wire2, 5);
|
||||
assert.deepEqual([...value2], [9, 10, 11, 12]);
|
||||
assert.deepEqual(pastEnd, [null, null, null, pos2]);
|
||||
});
|
||||
|
||||
test("extractTextFromResponse reads MCP nested tool metadata and alternate last-tool flag", () => {
|
||||
const toolCallPayload = encodeField(
|
||||
TOP_LEVEL_TOOL_CALL,
|
||||
LEN,
|
||||
concatArrays(
|
||||
encodeField(TOOL_ID, LEN, "call_1"),
|
||||
encodeField(TOOL_NAME, LEN, "mcp_custom_placeholder"),
|
||||
encodeField(TOOL_RAW_ARGS, LEN, "{}"),
|
||||
encodeField(TOOL_IS_LAST_ALT, VARINT, 1),
|
||||
encodeField(
|
||||
TOOL_MCP_PARAMS,
|
||||
LEN,
|
||||
encodeField(
|
||||
MCP_TOOLS_LIST,
|
||||
LEN,
|
||||
concatArrays(
|
||||
encodeField(MCP_NESTED_NAME, LEN, "read_file"),
|
||||
encodeField(MCP_NESTED_PARAMS, LEN, '{"path":"/tmp/a"}')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const extracted = extractTextFromResponse(toolCallPayload);
|
||||
|
||||
assert.equal(extracted.toolCall.id, "call_1");
|
||||
assert.equal(extracted.toolCall.function.name, "read_file");
|
||||
assert.equal(extracted.toolCall.function.arguments, '{"path":"/tmp/a"}');
|
||||
assert.equal(extracted.toolCall.isLast, true);
|
||||
});
|
||||
|
||||
test("extractTextFromResponse falls back to raw args when MCP metadata is absent", () => {
|
||||
const toolCallPayload = encodeField(
|
||||
TOP_LEVEL_TOOL_CALL,
|
||||
LEN,
|
||||
concatArrays(
|
||||
encodeField(TOOL_ID, LEN, "call_fallback"),
|
||||
encodeField(TOOL_NAME, LEN, "read_file"),
|
||||
encodeField(TOOL_RAW_ARGS, LEN, '{"path":"/tmp/fallback"}')
|
||||
)
|
||||
);
|
||||
|
||||
const extracted = extractTextFromResponse(toolCallPayload);
|
||||
|
||||
assert.equal(extracted.toolCall.id, "call_fallback");
|
||||
assert.equal(extracted.toolCall.function.name, "read_file");
|
||||
assert.equal(extracted.toolCall.function.arguments, '{"path":"/tmp/fallback"}');
|
||||
assert.equal(extracted.toolCall.isLast, false);
|
||||
});
|
||||
|
||||
test("extractTextFromResponse ignores incomplete tool call payloads", () => {
|
||||
const toolCallPayload = encodeField(
|
||||
TOP_LEVEL_TOOL_CALL,
|
||||
LEN,
|
||||
encodeField(TOOL_NAME, LEN, "read_file")
|
||||
);
|
||||
|
||||
assert.deepEqual(extractTextFromResponse(toolCallPayload), {
|
||||
text: null,
|
||||
error: null,
|
||||
toolCall: null,
|
||||
thinking: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("extractTextFromResponse returns text and thinking blocks from response payloads", () => {
|
||||
const responsePayload = encodeField(
|
||||
TOP_LEVEL_RESPONSE,
|
||||
LEN,
|
||||
concatArrays(
|
||||
encodeField(RESPONSE_TEXT, LEN, "hello"),
|
||||
encodeField(THINKING, LEN, encodeField(THINKING_TEXT, LEN, "reasoning"))
|
||||
)
|
||||
);
|
||||
|
||||
const extracted = extractTextFromResponse(responsePayload);
|
||||
|
||||
assert.equal(extracted.text, "hello");
|
||||
assert.equal(extracted.thinking, "reasoning");
|
||||
assert.equal(extracted.toolCall, null);
|
||||
});
|
||||
|
||||
test("generateCursorBody encodes tool metadata, message ids and high reasoning mode", () => {
|
||||
const framed = generateCursorBody(
|
||||
[
|
||||
{ role: "user", content: "Hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "mcp__repo__read_file",
|
||||
arguments: '{"path":"/tmp/a"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_results: [
|
||||
{
|
||||
tool_call_id: "call_1\nmc_model_1",
|
||||
name: "mcp__repo__read_file",
|
||||
index: 1,
|
||||
raw_args: '{"path":"/tmp/a"}',
|
||||
result: "file contents",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"cursor-small",
|
||||
[
|
||||
{
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: { type: "object", properties: { path: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
"high"
|
||||
);
|
||||
|
||||
const parsed = parseConnectRPCFrame(framed);
|
||||
const topLevel = decodeMessage(parsed.payload);
|
||||
const request = decodeMessage(topLevel.get(1)[0].value);
|
||||
|
||||
assert.equal(parsed.flags, 0);
|
||||
assert.equal(request.has(29), true);
|
||||
assert.equal(request.has(34), true);
|
||||
assert.equal(request.has(30), true);
|
||||
assert.equal(request.get(30).length >= 2, true);
|
||||
assert.equal(request.get(49)[0].value, 2);
|
||||
});
|
||||
|
||||
test("buildChatRequest normalizes mixed assistant tool payloads without duplicating matching tool-result messages", () => {
|
||||
const requestFrame = buildChatRequest(
|
||||
[
|
||||
{ role: "user", content: "Hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "mcp__repo__read_file",
|
||||
arguments: '{"path":"/tmp/a"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_results: [
|
||||
{
|
||||
tool_call_id: "call_1\nmc_model_1",
|
||||
name: "mcp__repo__read_file",
|
||||
index: 1,
|
||||
raw_args: '{"path":"/tmp/a"}',
|
||||
result: "file contents",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_results: [
|
||||
{
|
||||
tool_call_id: "call_1\nmc_model_1",
|
||||
name: "mcp__repo__read_file",
|
||||
index: 1,
|
||||
raw_args: '{"path":"/tmp/a"}',
|
||||
result: "file contents",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"cursor-small",
|
||||
[
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
},
|
||||
],
|
||||
"medium"
|
||||
);
|
||||
const topLevel = decodeMessage(requestFrame);
|
||||
const request = decodeMessage(topLevel.get(1)[0].value);
|
||||
|
||||
assert.equal(request.get(1).length, 3);
|
||||
assert.equal(request.get(27)[0].value, 1);
|
||||
assert.equal(request.get(48)[0].value, 0);
|
||||
assert.equal(request.get(49)[0].value, 1);
|
||||
assert.equal(request.has(29), true);
|
||||
assert.equal(request.has(34), true);
|
||||
});
|
||||
|
||||
test("encodeMcpTool keeps the custom server field even when name and schema are missing", () => {
|
||||
const encoded = encodeMcpTool({ description: "" });
|
||||
const decoded = decodeMessage(encoded);
|
||||
|
||||
assert.equal(decoded.has(1), false);
|
||||
assert.equal(decoded.has(2), false);
|
||||
assert.equal(decoded.has(3), false);
|
||||
assert.equal(textDecoder.decode(decoded.get(4)[0].value), "custom");
|
||||
});
|
||||
276
tests/unit/cursor-streaming.test.ts
Normal file
276
tests/unit/cursor-streaming.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
newStreamCtx,
|
||||
processFrame,
|
||||
buildCursorUsage,
|
||||
type StreamCtx,
|
||||
} from "../../open-sse/executors/cursor";
|
||||
|
||||
// ─── Wire-format helpers (mirror the encoder's primitives) ─────────────────
|
||||
|
||||
function v(n: number): Buffer {
|
||||
const out: number[] = [];
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field: number, wireType: number): Buffer {
|
||||
return v((field << 3) | wireType);
|
||||
}
|
||||
function lenPrefixed(field: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
||||
}
|
||||
|
||||
// AgentServerMessage { interaction_update (1): { text_delta (1): { text (1): str } } }
|
||||
function buildTextDeltaPayload(text: string): Buffer {
|
||||
const tdu = lenPrefixed(1, Buffer.from(text, "utf8"));
|
||||
const iu = lenPrefixed(1, tdu);
|
||||
return lenPrefixed(1, iu);
|
||||
}
|
||||
|
||||
// AgentServerMessage { interaction_update (1): { turn_ended (14): {} } }
|
||||
function buildTurnEndedPayload(): Buffer {
|
||||
const iu = lenPrefixed(14, Buffer.alloc(0));
|
||||
return lenPrefixed(1, iu);
|
||||
}
|
||||
|
||||
// AgentServerMessage { interaction_update (1): { token_delta (8): { count (1): n } } }
|
||||
function buildTokenDeltaPayload(tokens: number): Buffer {
|
||||
const tokDelta = Buffer.concat([tag(1, 0), v(tokens)]);
|
||||
const iu = lenPrefixed(8, tokDelta);
|
||||
return lenPrefixed(1, iu);
|
||||
}
|
||||
|
||||
// AgentServerMessage { interaction_update (1): { thinking_delta (4): { text (1): str } } }
|
||||
function buildThinkingDeltaPayload(text: string): Buffer {
|
||||
const tdu = lenPrefixed(1, Buffer.from(text, "utf8"));
|
||||
const iu = lenPrefixed(4, tdu);
|
||||
return lenPrefixed(1, iu);
|
||||
}
|
||||
|
||||
// AgentServerMessage { kv_server_message (4): {...} } — empty body
|
||||
function buildKvServerMessagePayload(): Buffer {
|
||||
return lenPrefixed(4, Buffer.alloc(0));
|
||||
}
|
||||
|
||||
// JSON error payload (Connect-RPC error envelope)
|
||||
function buildJsonErrorPayload(): Buffer {
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
error: { message: "rate limited", code: "resource_exhausted" },
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("newStreamCtx initializes with empty state", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
assert.equal(ctx.totalText, "");
|
||||
assert.equal(ctx.tokenDelta, 0);
|
||||
assert.equal(ctx.endReason, null);
|
||||
assert.equal(ctx.emittedRoleChunk, false);
|
||||
assert.equal(ctx.midStreamError, null);
|
||||
assert.equal(ctx.model, "auto");
|
||||
assert.match(ctx.responseId, /^chatcmpl-cursor-/);
|
||||
});
|
||||
|
||||
test("processFrame emits role+content chunks for text deltas", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
processFrame(buildTextDeltaPayload("hello"), ctx, new Set());
|
||||
assert.equal(emitted.length, 2, "role chunk then content chunk");
|
||||
// First chunk: role
|
||||
const first = JSON.parse(emitted[0].replace(/^data: /, "").trim());
|
||||
assert.equal(first.choices[0].delta.role, "assistant");
|
||||
// Second chunk: content
|
||||
const second = JSON.parse(emitted[1].replace(/^data: /, "").trim());
|
||||
assert.equal(second.choices[0].delta.content, "hello");
|
||||
assert.equal(ctx.totalText, "hello");
|
||||
assert.equal(ctx.receivedText, true);
|
||||
});
|
||||
|
||||
test("processFrame skips role chunk on subsequent text deltas", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
processFrame(buildTextDeltaPayload("hello "), ctx, new Set());
|
||||
processFrame(buildTextDeltaPayload("world"), ctx, new Set());
|
||||
assert.equal(emitted.length, 3, "role + 2 content chunks");
|
||||
assert.equal(ctx.totalText, "hello world");
|
||||
});
|
||||
|
||||
test("processFrame sets endReason on turn_ended", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTurnEndedPayload(), ctx, new Set());
|
||||
assert.equal(ctx.endReason, "turn_ended");
|
||||
});
|
||||
|
||||
test("processFrame accumulates token_delta", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTokenDeltaPayload(42), ctx, new Set());
|
||||
processFrame(buildTokenDeltaPayload(13), ctx, new Set());
|
||||
assert.equal(ctx.tokenDelta, 55);
|
||||
});
|
||||
|
||||
test("processFrame sets endReason on kv_server_message after text", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTextDeltaPayload("hi"), ctx, new Set());
|
||||
processFrame(buildKvServerMessagePayload(), ctx, new Set());
|
||||
assert.equal(ctx.endReason, "kv_after_text");
|
||||
assert.equal(ctx.kvAfterTextSeen, true);
|
||||
});
|
||||
|
||||
test("processFrame ignores kv_server_message before text (no end signal yet)", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildKvServerMessagePayload(), ctx, new Set());
|
||||
assert.equal(ctx.endReason, null);
|
||||
assert.equal(ctx.kvAfterTextSeen, false);
|
||||
});
|
||||
|
||||
test("processFrame captures mid-stream JSON error", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildJsonErrorPayload(), ctx, new Set());
|
||||
assert.equal(ctx.endReason, "server_end");
|
||||
assert.ok(ctx.midStreamError);
|
||||
assert.match(ctx.midStreamError!.message, /rate limited/);
|
||||
});
|
||||
|
||||
test("processFrame JSON error after text terminates without overwriting content", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTextDeltaPayload("partial"), ctx, new Set());
|
||||
processFrame(buildJsonErrorPayload(), ctx, new Set());
|
||||
assert.equal(ctx.endReason, "server_end");
|
||||
assert.equal(ctx.midStreamError, null, "no error overlay when text already streamed");
|
||||
assert.equal(ctx.totalText, "partial");
|
||||
});
|
||||
|
||||
test("processFrame doesn't ack same exec_id twice", () => {
|
||||
// Simulate request_context appearing twice — only the first should ack.
|
||||
// Phase 6 dedup is keyed by kind+execId+execMsgId so request_context and
|
||||
// mcp_args sharing an empty execId don't collide.
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
const acked = new Set<string>();
|
||||
|
||||
// Build an exec_request_context payload:
|
||||
// ASM { exec_server_message (2): ESM { id (1): 1, exec_id (15): "x", request_context_args (10): {} } }
|
||||
function buildRequestContext(execId: string): Buffer {
|
||||
const esm = Buffer.concat([
|
||||
Buffer.concat([tag(1, 0), v(1)]),
|
||||
lenPrefixed(15, Buffer.from(execId, "utf8")),
|
||||
lenPrefixed(10, Buffer.alloc(0)),
|
||||
]);
|
||||
return lenPrefixed(2, esm);
|
||||
}
|
||||
|
||||
processFrame(buildRequestContext("x"), ctx, acked);
|
||||
assert.ok(acked.has("exec_request_context:x:1"));
|
||||
// Second call doesn't error or change state
|
||||
processFrame(buildRequestContext("x"), ctx, acked);
|
||||
assert.equal(acked.size, 1);
|
||||
});
|
||||
|
||||
test("StreamCtx custom emit is invoked with full SSE-formatted strings", () => {
|
||||
const captured: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => captured.push(s));
|
||||
processFrame(buildTextDeltaPayload("ok"), ctx, new Set());
|
||||
// Each emit ends with \n\n and starts with "data: "
|
||||
for (const s of captured) {
|
||||
assert.match(s, /^data: /);
|
||||
assert.ok(s.endsWith("\n\n"));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Thinking-delta capture and reasoning emission ─────────────────────────
|
||||
|
||||
test("processFrame accumulates thinking_delta into thinkingText and emits reasoning_content", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
processFrame(buildThinkingDeltaPayload("step 1: "), ctx, new Set());
|
||||
processFrame(buildThinkingDeltaPayload("compute"), ctx, new Set());
|
||||
assert.equal(ctx.thinkingText, "step 1: compute");
|
||||
assert.equal(ctx.receivedText, true);
|
||||
// role chunk + 2 reasoning_content chunks
|
||||
assert.equal(emitted.length, 3);
|
||||
const chunks = emitted.map((s) => JSON.parse(s.replace(/^data: /, "").trim()));
|
||||
assert.equal(chunks[0].choices[0].delta.role, "assistant");
|
||||
assert.equal(chunks[1].choices[0].delta.reasoning_content, "step 1: ");
|
||||
assert.equal(chunks[2].choices[0].delta.reasoning_content, "compute");
|
||||
});
|
||||
|
||||
test("processFrame on empty thinking text is a no-op (no role chunk, no emit)", () => {
|
||||
const emitted: string[] = [];
|
||||
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
||||
// Build an empty-text thinking delta: thinking_delta(4) wraps an empty TDU
|
||||
const iu = lenPrefixed(4, lenPrefixed(1, Buffer.from("", "utf8")));
|
||||
const payload = lenPrefixed(1, iu);
|
||||
processFrame(payload, ctx, new Set());
|
||||
assert.equal(ctx.thinkingText, "");
|
||||
assert.equal(ctx.emittedRoleChunk, false);
|
||||
assert.equal(emitted.length, 0);
|
||||
});
|
||||
|
||||
test("newStreamCtx initializes thinkingText to empty", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
assert.equal(ctx.thinkingText, "");
|
||||
});
|
||||
|
||||
// ─── Usage construction (buildCursorUsage) ─────────────────────────────────
|
||||
|
||||
const SAMPLE_BODY = { messages: [{ role: "user" as const, content: "hello world" }] };
|
||||
|
||||
test("buildCursorUsage uses cursor's real tokenDelta for completion_tokens", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTextDeltaPayload("answer"), ctx, new Set());
|
||||
processFrame(buildTokenDeltaPayload(42), ctx, new Set());
|
||||
processFrame(buildTokenDeltaPayload(13), ctx, new Set());
|
||||
const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record<string, number>;
|
||||
// 55 tokens + buffer added by addBufferToUsage (currently +5 per util convention)
|
||||
assert.ok(usage.completion_tokens >= 55, `expected ≥55, got ${usage.completion_tokens}`);
|
||||
assert.ok(usage.prompt_tokens > 0);
|
||||
assert.equal(usage.total_tokens, usage.prompt_tokens + usage.completion_tokens);
|
||||
});
|
||||
|
||||
test("buildCursorUsage falls back to estimate when tokenDelta is zero", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTextDeltaPayload("abc"), ctx, new Set());
|
||||
// No token_delta frames sent
|
||||
const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record<string, number>;
|
||||
assert.ok(usage.completion_tokens > 0);
|
||||
assert.equal(usage.total_tokens, usage.prompt_tokens + usage.completion_tokens);
|
||||
assert.equal((usage as Record<string, unknown>).estimated, true);
|
||||
});
|
||||
|
||||
test("buildCursorUsage emits completion_tokens_details.reasoning_tokens when thinking seen", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildThinkingDeltaPayload("let me think about this carefully"), ctx, new Set());
|
||||
processFrame(buildTextDeltaPayload("done"), ctx, new Set());
|
||||
processFrame(buildTokenDeltaPayload(20), ctx, new Set());
|
||||
const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record<string, unknown>;
|
||||
const details = usage.completion_tokens_details as Record<string, number> | undefined;
|
||||
assert.ok(details, "completion_tokens_details should be present");
|
||||
assert.ok(details!.reasoning_tokens > 0, "reasoning_tokens should be >0");
|
||||
});
|
||||
|
||||
test("buildCursorUsage omits completion_tokens_details when no thinking", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildTextDeltaPayload("just text"), ctx, new Set());
|
||||
processFrame(buildTokenDeltaPayload(10), ctx, new Set());
|
||||
const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record<string, unknown>;
|
||||
assert.equal(usage.completion_tokens_details, undefined);
|
||||
});
|
||||
|
||||
test("buildCursorUsage never emits cache fields (cursor doesn't expose them)", () => {
|
||||
const ctx = newStreamCtx("auto", () => {});
|
||||
processFrame(buildThinkingDeltaPayload("thinking"), ctx, new Set());
|
||||
processFrame(buildTextDeltaPayload("text"), ctx, new Set());
|
||||
processFrame(buildTokenDeltaPayload(20), ctx, new Set());
|
||||
const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record<string, unknown>;
|
||||
assert.equal(usage.cached_tokens, undefined);
|
||||
assert.equal(usage.cache_read_input_tokens, undefined);
|
||||
assert.equal(usage.cache_creation_input_tokens, undefined);
|
||||
});
|
||||
@@ -1,549 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { CursorExecutor } from "../../open-sse/executors/cursor.ts";
|
||||
import {
|
||||
decodeMessage,
|
||||
encodeField,
|
||||
parseConnectRPCFrame,
|
||||
wrapConnectRPCFrame,
|
||||
} from "../../open-sse/utils/cursorProtobuf.ts";
|
||||
import {
|
||||
buildCursorHeaders,
|
||||
generateCursorChecksum,
|
||||
generateHashed64Hex,
|
||||
generateSessionId,
|
||||
} from "../../open-sse/utils/cursorChecksum.ts";
|
||||
import {
|
||||
getCursorVersion,
|
||||
resetCursorVersionCache,
|
||||
} from "../../open-sse/utils/cursorVersionDetector.ts";
|
||||
|
||||
const LEN = 2;
|
||||
const VARINT = 0;
|
||||
const TOP_LEVEL_TOOL_CALL = 1;
|
||||
const TOP_LEVEL_RESPONSE = 2;
|
||||
const RESPONSE_TEXT = 1;
|
||||
const TOOL_ID = 3;
|
||||
const TOOL_NAME = 9;
|
||||
const TOOL_RAW_ARGS = 10;
|
||||
const TOOL_IS_LAST = 11;
|
||||
|
||||
function concatArrays(...arrays) {
|
||||
const total = arrays.reduce((sum, array) => sum + array.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
|
||||
for (const array of arrays) {
|
||||
result.set(array, offset);
|
||||
offset += array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildTextFrame(text) {
|
||||
return Buffer.from(
|
||||
wrapConnectRPCFrame(
|
||||
encodeField(TOP_LEVEL_RESPONSE, LEN, encodeField(RESPONSE_TEXT, LEN, text)),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompressedTextFrame(text) {
|
||||
return Buffer.from(
|
||||
wrapConnectRPCFrame(
|
||||
encodeField(TOP_LEVEL_RESPONSE, LEN, encodeField(RESPONSE_TEXT, LEN, text)),
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolCallFrame({ id, name, args, isLast }) {
|
||||
return Buffer.from(
|
||||
wrapConnectRPCFrame(
|
||||
encodeField(
|
||||
TOP_LEVEL_TOOL_CALL,
|
||||
LEN,
|
||||
concatArrays(
|
||||
encodeField(TOOL_ID, LEN, id),
|
||||
encodeField(TOOL_NAME, LEN, name),
|
||||
encodeField(TOOL_RAW_ARGS, LEN, args),
|
||||
encodeField(TOOL_IS_LAST, VARINT, isLast ? 1 : 0)
|
||||
)
|
||||
),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildJsonErrorFrame(error) {
|
||||
return Buffer.from(wrapConnectRPCFrame(new TextEncoder().encode(JSON.stringify(error)), false));
|
||||
}
|
||||
|
||||
test("CursorExecutor.buildUrl uses the configured Cursor endpoint", () => {
|
||||
const executor = new CursorExecutor();
|
||||
assert.equal(
|
||||
executor.buildUrl(),
|
||||
"https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools"
|
||||
);
|
||||
});
|
||||
|
||||
test("CursorExecutor.buildHeaders strips token prefixes and derives checksum/session headers", () => {
|
||||
const executor = new CursorExecutor();
|
||||
const originalDateNow = Date.now;
|
||||
const originalDbPath = process.env.CURSOR_STATE_DB_PATH;
|
||||
// Force fallback by pointing to a non-existent DB path
|
||||
process.env.CURSOR_STATE_DB_PATH = "/nonexistent/cursor/state.vscdb";
|
||||
resetCursorVersionCache();
|
||||
Date.now = () => 1_700_000_000_000;
|
||||
|
||||
try {
|
||||
const headers = executor.buildHeaders({
|
||||
accessToken: "prefix::real-token",
|
||||
providerSpecificData: { machineId: "machine-1", ghostMode: false },
|
||||
});
|
||||
|
||||
const expectedVersion = getCursorVersion();
|
||||
|
||||
assert.equal(headers.authorization, "Bearer real-token");
|
||||
assert.equal(headers["x-client-key"], generateHashed64Hex("real-token"));
|
||||
assert.equal(headers["x-session-id"], generateSessionId("real-token"));
|
||||
assert.equal(headers["x-cursor-checksum"], generateCursorChecksum("machine-1"));
|
||||
assert.equal(headers["x-cursor-client-version"], expectedVersion);
|
||||
assert.equal(headers["x-cursor-user-agent"], `Cursor/${expectedVersion}`);
|
||||
assert.equal(headers["user-agent"], `Cursor/${expectedVersion}`);
|
||||
assert.equal(headers["x-ghost-mode"], "false");
|
||||
assert.equal(headers["connect-protocol-version"], "1");
|
||||
assert.match(headers["x-amzn-trace-id"], /^Root=/);
|
||||
assert.ok(headers["x-request-id"]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
if (originalDbPath === undefined) {
|
||||
delete process.env.CURSOR_STATE_DB_PATH;
|
||||
} else {
|
||||
process.env.CURSOR_STATE_DB_PATH = originalDbPath;
|
||||
}
|
||||
resetCursorVersionCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("buildCursorHeaders utility stays aligned with Cursor Composer 2 versioned headers", () => {
|
||||
const originalDbPath = process.env.CURSOR_STATE_DB_PATH;
|
||||
process.env.CURSOR_STATE_DB_PATH = "/nonexistent/cursor/state.vscdb";
|
||||
resetCursorVersionCache();
|
||||
|
||||
try {
|
||||
const headers = buildCursorHeaders("prefix::real-token", "machine-1", false);
|
||||
const expectedVersion = getCursorVersion();
|
||||
|
||||
assert.equal(headers.Authorization, "Bearer real-token");
|
||||
assert.equal(headers["x-cursor-client-version"], expectedVersion);
|
||||
assert.equal(headers["x-cursor-user-agent"], `Cursor/${expectedVersion}`);
|
||||
assert.equal(headers["User-Agent"], `Cursor/${expectedVersion}`);
|
||||
assert.equal(headers["x-ghost-mode"], "false");
|
||||
} finally {
|
||||
if (originalDbPath === undefined) {
|
||||
delete process.env.CURSOR_STATE_DB_PATH;
|
||||
} else {
|
||||
process.env.CURSOR_STATE_DB_PATH = originalDbPath;
|
||||
}
|
||||
resetCursorVersionCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("CursorExecutor.buildHeaders derives machineId when not provided", () => {
|
||||
const executor = new CursorExecutor();
|
||||
const headers = executor.buildHeaders({ accessToken: "real-token", providerSpecificData: {} });
|
||||
assert.ok(headers["x-cursor-checksum"], "should have a checksum header");
|
||||
assert.ok(headers["x-client-key"], "should have a client key header");
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformRequest produces a framed protobuf payload", () => {
|
||||
const executor = new CursorExecutor();
|
||||
const transformed = executor.transformRequest(
|
||||
"claude-3.5-sonnet",
|
||||
{ messages: [{ role: "user", content: "Hello" }], tools: [] },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
const frame = parseConnectRPCFrame(transformed);
|
||||
const fields = decodeMessage(frame.payload);
|
||||
|
||||
assert.ok(transformed instanceof Uint8Array);
|
||||
assert.equal(frame.flags, 0);
|
||||
assert.equal(frame.consumed, transformed.length);
|
||||
assert.equal(fields.has(1), true);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToJSON aggregates text and split tool call arguments", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const buffer = Buffer.concat([
|
||||
buildTextFrame("Hello "),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '{"path":',
|
||||
isLast: false,
|
||||
}),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '"/tmp/a"}',
|
||||
isLast: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = executor.transformProtobufToJSON(buffer, "cursor-small", body);
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.object, "chat.completion");
|
||||
assert.equal(payload.model, "cursor-small");
|
||||
assert.equal(payload.choices[0].message.content, "Hello ");
|
||||
assert.equal(payload.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(payload.choices[0].message.tool_calls[0].function.name, "read_file");
|
||||
assert.equal(payload.choices[0].message.tool_calls[0].function.arguments, '{"path":"/tmp/a"}');
|
||||
assert.equal(payload.usage.estimated, true);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToJSON finalizes incomplete tool calls when the stream ends early", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToJSON(
|
||||
Buffer.concat([
|
||||
buildToolCallFrame({
|
||||
id: "call_2",
|
||||
name: "list_files",
|
||||
args: '{"path":"/tmp"}',
|
||||
isLast: false,
|
||||
}),
|
||||
]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(payload.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(payload.choices[0].message.tool_calls[0].id, "call_2");
|
||||
assert.equal(payload.choices[0].message.tool_calls[0].function.name, "list_files");
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToJSON keeps prior content when an error frame arrives after output", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToJSON(
|
||||
Buffer.concat([
|
||||
buildTextFrame("Partial answer"),
|
||||
buildJsonErrorFrame({
|
||||
error: {
|
||||
code: "resource_exhausted",
|
||||
message: "late error",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.choices[0].message.content, "Partial answer");
|
||||
assert.equal(payload.choices[0].finish_reason, "stop");
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToJSON decompresses gzip frames", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToJSON(
|
||||
Buffer.concat([buildCompressedTextFrame("Compressed answer")]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(payload.choices[0].message.content, "Compressed answer");
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE emits assistant chunks, tool deltas and DONE marker", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const buffer = Buffer.concat([
|
||||
buildTextFrame("Hello "),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '{"path":',
|
||||
isLast: false,
|
||||
}),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '"/tmp/a"}',
|
||||
isLast: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = executor.transformProtobufToSSE(buffer, "cursor-small", body);
|
||||
const text = await response.text();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Content-Type"), "text/event-stream");
|
||||
assert.match(text, /"role":"assistant","content":"Hello "/);
|
||||
assert.match(text, /"tool_calls":\[/);
|
||||
assert.match(text, /"name":"read_file"/);
|
||||
assert.match(text, /"finish_reason":"tool_calls"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE finalizes unterminated tool calls at stream end", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(
|
||||
Buffer.concat([
|
||||
buildToolCallFrame({
|
||||
id: "call_2",
|
||||
name: "read_file",
|
||||
args: '{"path":"/tmp/b"}',
|
||||
isLast: false,
|
||||
}),
|
||||
]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
assert.match(text, /"name":"read_file"/);
|
||||
assert.match(text, /"finish_reason":"tool_calls"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE returns a JSON error before any content is streamed", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(
|
||||
buildJsonErrorFrame({
|
||||
error: {
|
||||
code: "resource_exhausted",
|
||||
message: "too many requests",
|
||||
details: [{ debug: { error: "LIMIT", details: { title: "Limit hit" } } }],
|
||||
},
|
||||
}),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(payload.error.type, "rate_limit_error");
|
||||
assert.equal(payload.error.message, "Limit hit");
|
||||
assert.equal(payload.error.code, "LIMIT");
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE stops gracefully when a JSON error arrives after content", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(
|
||||
Buffer.concat([
|
||||
buildTextFrame("Partial Cursor answer"),
|
||||
buildJsonErrorFrame({
|
||||
error: {
|
||||
code: "resource_exhausted",
|
||||
message: "late limit",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(text, /Partial Cursor answer/);
|
||||
assert.match(text, /"finish_reason":"stop"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE emits plain content deltas after tool call chunks", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(
|
||||
Buffer.concat([
|
||||
buildToolCallFrame({
|
||||
id: "call_3",
|
||||
name: "read_file",
|
||||
args: '{"path":"/tmp/c"}',
|
||||
isLast: false,
|
||||
}),
|
||||
buildTextFrame("Follow-up text"),
|
||||
]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const text = await response.text();
|
||||
|
||||
assert.match(text, /"name":"read_file"/);
|
||||
assert.match(text, /"delta":\{"content":"Follow-up text"\}/);
|
||||
assert.match(text, /"finish_reason":"tool_calls"/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE emits an empty assistant envelope for empty responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(Buffer.alloc(0), "cursor-small", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
const text = await response.text();
|
||||
|
||||
assert.match(text, /"role":"assistant","content":""/);
|
||||
assert.match(text, /"finish_reason":"stop"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE converts JSON error frames into rate-limit responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(
|
||||
buildJsonErrorFrame({
|
||||
error: {
|
||||
code: "resource_exhausted",
|
||||
message: "rate limited",
|
||||
details: [{ debug: { error: "LIMIT", details: { title: "Limit", detail: "Slow down" } } }],
|
||||
},
|
||||
}),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(payload.error.type, "rate_limit_error");
|
||||
assert.equal(payload.error.message, "Limit");
|
||||
assert.equal(payload.error.code, "LIMIT");
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute returns transformed JSON for non-stream responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const responseBuffer = Buffer.concat([buildTextFrame("Hello from Cursor")]);
|
||||
executor.makeHttp2Request = async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: responseBuffer,
|
||||
});
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const payload = (await result.response.json()) as any;
|
||||
|
||||
assert.equal(
|
||||
result.url,
|
||||
"https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools"
|
||||
);
|
||||
assert.equal(result.transformedBody, body);
|
||||
assert.equal(result.headers.authorization, "Bearer token");
|
||||
assert.equal(payload.object, "chat.completion");
|
||||
assert.equal(payload.choices[0].message.content, "Hello from Cursor");
|
||||
assert.equal(payload.choices[0].finish_reason, "stop");
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute returns transformed SSE for stream responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const responseBuffer = Buffer.concat([buildTextFrame("Hello stream")]);
|
||||
executor.makeHttp2Request = async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: responseBuffer,
|
||||
});
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body,
|
||||
stream: true,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const text = await result.response.text();
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.match(text, /"content":"Hello stream"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute maps non-200 upstream responses to OpenAI-style errors", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
executor.makeHttp2Request = async () => ({
|
||||
status: 403,
|
||||
headers: {},
|
||||
body: Buffer.from("denied"),
|
||||
});
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const payload = (await result.response.json()) as any;
|
||||
|
||||
assert.equal(result.response.status, 403);
|
||||
assert.equal(payload.error.type, "invalid_request_error");
|
||||
assert.match(payload.error.message, /\[403\]: denied/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute maps transport failures to connection_error and refreshCredentials returns null", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
executor.makeHttp2Request = async () => {
|
||||
throw new Error("socket hang up");
|
||||
};
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const payload = (await result.response.json()) as any;
|
||||
|
||||
assert.equal(result.response.status, 500);
|
||||
assert.equal(payload.error.type, "connection_error");
|
||||
assert.equal(payload.error.message, "socket hang up");
|
||||
assert.equal(await executor.refreshCredentials(), null);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE finalizes un-terminated tools when stream abruptly cuts before isLast", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Send a tool call but never close it
|
||||
const response = executor.transformProtobufToSSE(
|
||||
Buffer.concat([
|
||||
buildToolCallFrame({
|
||||
id: "call_abrupt",
|
||||
name: "write_file",
|
||||
args: '{"content":"partial"',
|
||||
isLast: false,
|
||||
}),
|
||||
]),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
|
||||
const text = await response.text();
|
||||
assert.match(text, /"name":"write_file"/);
|
||||
assert.match(text, /"finish_reason":"tool_calls"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
Reference in New Issue
Block a user