Fix : Add custom to model selector

This commit is contained in:
decolua
2026-03-11 11:59:07 +07:00
parent a501c05969
commit d9dad5bcf3
8 changed files with 699 additions and 451 deletions
+169 -44
View File
@@ -6,8 +6,11 @@
import { v4 as uuidv4 } from "uuid";
import zlib from "zlib";
const DEBUG = true;
const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1";
const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
const textDecoder = new TextDecoder();
const PROTOBUF_SCHEMA_VERSION = "1.1.3";
// ==================== SCHEMAS ====================
@@ -18,6 +21,8 @@ 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 CLIENT_SIDE_TOOL_V2_MCP = 19;
const FIELD = {
// StreamUnifiedChatRequestWithTools (top level)
@@ -67,28 +72,25 @@ const FIELD = {
TOOL_RESULT_TOOL_CALL: 11,
TOOL_RESULT_MODEL_CALL_ID: 12,
// ClientSideToolV2Result
CV2R_TOOL: 1,
CV2R_MCP_RESULT: 28,
CV2R_CALL_ID: 35,
CV2R_MODEL_CALL_ID: 48,
CV2R_TOOL_INDEX: 49,
// 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
MCPR_SELECTED_TOOL: 1,
MCPR_RESULT: 2,
// MCPResult (nested inside ClientSideToolV2Result.mcp_result)
MCP_RESULT_SELECTED_TOOL: 1,
MCP_RESULT_RESULT: 2,
// ClientSideToolV2Call
CV2C_TOOL: 1,
CV2C_MCP_PARAMS: 27,
CV2C_CALL_ID: 3,
CV2C_NAME: 9,
CV2C_RAW_ARGS: 10,
CV2C_TOOL_INDEX: 48,
CV2C_MODEL_CALL_ID: 49,
// ConversationMessage extra fields
MSG_SERVER_BUBBLE_ID: 32,
// 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,
@@ -135,6 +137,7 @@ const FIELD = {
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_IS_LAST_ALT: 15,
TOOL_MCP_PARAMS: 27,
// MCPParams
@@ -152,6 +155,19 @@ const FIELD = {
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) {
@@ -200,15 +216,46 @@ function concatArrays(...arrays) {
// ==================== MESSAGE ENCODING ====================
// ClientSideToolV2 enum: MCP = 19
const CLIENT_SIDE_TOOL_V2_MCP = 19;
/**
* Format tool name: "toolName" → "mcp_custom_toolName"
* Also handles: "mcp__server__tool" → "mcp_server_tool"
*/
function formatToolName(name) {
if (name.startsWith("mcp_")) return name;
return `mcp_custom_${name}`;
const base = typeof name === "string" && name.length > 0 ? name : "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 toolName = rest.slice(splitIdx + 2) || "tool";
return `mcp_${server}_${toolName}`;
}
return `mcp_custom_${rest || "tool"}`;
}
if (base.startsWith("mcp_")) return base;
return `mcp_custom_${base}`;
}
/**
* Parse formatted tool name: "mcp_server_tool" → { serverName, selectedTool }
*/
function parseToolName(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"
};
}
/**
@@ -235,15 +282,16 @@ function encodeMcpResult(selectedTool, resultContent) {
}
/**
* Encode ClientSideToolV2Result proto
* Encode ClientSideToolV2Result proto: { tool, mcp_result, call_id, model_call_id, tool_index }
* Represents the result of executing a tool
*/
function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent) {
function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex = 1) {
return concatArrays(
encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
encodeField(FIELD.CV2R_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)),
encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId),
...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, 1)
encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1)
);
}
@@ -260,16 +308,17 @@ function encodeMcpParamsForCall(toolName, rawArgs, serverName) {
}
/**
* Encode ClientSideToolV2Call proto
* Encode ClientSideToolV2Call proto: { tool, mcp_params, call_id, name, raw_args, tool_index, model_call_id }
* Represents a tool call definition
*/
function encodeClientSideToolV2Call(toolCallId, toolName, mcpToolName, rawArgs, modelCallId) {
function encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex = 1) {
return concatArrays(
encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(mcpToolName, rawArgs, "custom")),
encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(selectedTool, rawArgs, serverName)),
encodeField(FIELD.CV2C_CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.CV2C_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1),
...(modelCallId ? [encodeField(FIELD.CV2C_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : [])
);
}
@@ -282,23 +331,24 @@ export function encodeToolResult(toolResult) {
const originalName = toolResult.tool_name || toolResult.name || "";
const toolName = formatToolName(originalName);
const rawArgs = toolResult.raw_args || "{}";
const resultContent = toolResult.result_content || "";
const resultContent = toolResult.result_content || toolResult.result || "";
const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || "");
const toolIndex = toolResult.tool_index || toolResult.index || 1;
// Derive mcpToolName: strip "mcp_" prefix → "custom_toolName"
const mcpToolName = toolName.startsWith("mcp_") ? toolName.slice(4) : originalName;
// Parse tool name to extract server and selected tool
const { serverName, selectedTool } = parseToolName(toolName);
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, toolResult.tool_index || 1),
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1),
...(modelCallId ? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN,
encodeClientSideToolV2Result(toolCallId, modelCallId, mcpToolName, resultContent)
encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex)
),
encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN,
encodeClientSideToolV2Call(toolCallId, toolName, mcpToolName, rawArgs, modelCallId)
encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex)
)
);
}
@@ -384,13 +434,71 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort =
const isAgentic = hasTools;
const formattedMessages = [];
const messageIds = [];
const normalizedMessages = [];
// Prepare messages
// Guardrail: split mixed assistant payload into separate assistant messages
// This prevents protobuf encoding errors when tool calls and results are in same message
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 === messages.length - 1;
const isLast = i === normalizedMessages.length - 1;
formattedMessages.push({
content: msg.content,
@@ -719,6 +827,16 @@ 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);
@@ -731,7 +849,7 @@ export function extractTextFromResponse(payload) {
// 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 };
}
@@ -739,8 +857,15 @@ export function extractTextFromResponse(payload) {
return { text: null, error: null, toolCall: null, thinking: null };
} catch (err) {
log("EXTRACT", `Error: ${err.message}`);
return { text: null, error: null, toolCall: null, thinking: null };
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
};
}
}