mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 18:16:29 +00:00
fix(cursor): make bare cursor command useful and harden tool-result translation
This commit is contained in:
@@ -12,7 +12,7 @@ export function renderCursorHelp(): number {
|
||||
printLines([
|
||||
'Cursor IDE Integration',
|
||||
'',
|
||||
'Usage: ccs cursor <subcommand> [options]',
|
||||
'Usage: ccs cursor [subcommand] [options]',
|
||||
'',
|
||||
'Subcommands:',
|
||||
' auth Import Cursor IDE authentication token',
|
||||
@@ -32,6 +32,7 @@ export function renderCursorHelp(): number {
|
||||
' 1. ccs cursor enable # Enable integration',
|
||||
' 2. ccs cursor auth # Import Cursor IDE token',
|
||||
' 3. ccs cursor start # Start daemon',
|
||||
' 4. ccs cursor # Show status and runtime connection details',
|
||||
'',
|
||||
'Or use the web UI: ccs config -> Cursor page',
|
||||
'',
|
||||
@@ -45,6 +46,10 @@ export function renderCursorStatus(
|
||||
authStatus: CursorAuthStatus,
|
||||
daemonStatus: CursorDaemonStatus
|
||||
): void {
|
||||
const localBaseUrl = `http://127.0.0.1:${cursorConfig.port}`;
|
||||
const isReady =
|
||||
cursorConfig.enabled && authStatus.authenticated && !authStatus.expired && daemonStatus.running;
|
||||
|
||||
console.log('Cursor IDE Status');
|
||||
console.log('─────────────────');
|
||||
console.log('');
|
||||
@@ -77,15 +82,23 @@ export function renderCursorStatus(
|
||||
console.log(` Ghost mode: ${cursorConfig.ghost_mode ? 'On' : 'Off'}`);
|
||||
console.log('');
|
||||
|
||||
if (
|
||||
cursorConfig.enabled &&
|
||||
authStatus.authenticated &&
|
||||
!authStatus.expired &&
|
||||
daemonStatus.running
|
||||
) {
|
||||
console.log('Runtime:');
|
||||
console.log(` OpenAI base: ${localBaseUrl}/v1`);
|
||||
console.log(` Anthropic base: ${localBaseUrl}`);
|
||||
console.log(` Chat route: ${localBaseUrl}/v1/chat/completions`);
|
||||
console.log(` Messages route: ${localBaseUrl}/v1/messages`);
|
||||
console.log(` Models route: ${localBaseUrl}/v1/models`);
|
||||
console.log('');
|
||||
console.log('Client setup:');
|
||||
console.log(' Raw settings: ~/.ccs/cursor.settings.json');
|
||||
console.log(' Subcommands: ccs cursor help');
|
||||
|
||||
if (isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
console.log('Next steps:');
|
||||
if (!cursorConfig.enabled) {
|
||||
console.log(' - Enable: ccs cursor enable');
|
||||
@@ -96,6 +109,7 @@ export function renderCursorStatus(
|
||||
if (!daemonStatus.running) {
|
||||
console.log(' - Start: ccs cursor start');
|
||||
}
|
||||
console.log(' - Help: ccs cursor help');
|
||||
}
|
||||
|
||||
export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function handleCursorCommand(args: string[]): Promise<number> {
|
||||
case 'disable':
|
||||
return handleDisable();
|
||||
case undefined:
|
||||
return handleStatus();
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
|
||||
+318
-83
@@ -1,14 +1,42 @@
|
||||
/**
|
||||
* OpenAI to Cursor Request Translator
|
||||
* Converts OpenAI messages to Cursor format
|
||||
* Converts OpenAI messages to Cursor format.
|
||||
*/
|
||||
|
||||
import type { CursorMessage, CursorToolResult, CursorTool } from './cursor-protobuf-schema.js';
|
||||
import type { CursorMessage, CursorTool } from './cursor-protobuf-schema.js';
|
||||
|
||||
interface OpenAITextPart {
|
||||
type: 'text';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface OpenAIToolUsePart {
|
||||
type: 'tool_use';
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface OpenAIToolResultPart {
|
||||
type: 'tool_result';
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
interface OpenAIUnknownPart {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type OpenAIContentPart =
|
||||
| OpenAITextPart
|
||||
| OpenAIToolUsePart
|
||||
| OpenAIToolResultPart
|
||||
| OpenAIUnknownPart;
|
||||
|
||||
/** OpenAI message format */
|
||||
interface OpenAIMessage {
|
||||
role: string;
|
||||
content: string | Array<{ type: string; text?: string }>;
|
||||
content: string | OpenAIContentPart[];
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
@@ -18,113 +46,326 @@ interface OpenAIMessage {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** OpenAI request body */
|
||||
interface OpenAIRequestBody {
|
||||
messages: OpenAIMessage[];
|
||||
tools?: CursorTool[];
|
||||
reasoning_effort?: string;
|
||||
}
|
||||
|
||||
const MAX_TOOL_RESULT_CHARS = 12_000;
|
||||
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
|
||||
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
|
||||
|
||||
function isTextPart(part: OpenAIContentPart): part is OpenAITextPart {
|
||||
return part.type === 'text';
|
||||
}
|
||||
|
||||
function isToolUsePart(part: OpenAIContentPart): part is OpenAIToolUsePart {
|
||||
return part.type === 'tool_use';
|
||||
}
|
||||
|
||||
function isToolResultPart(part: OpenAIContentPart): part is OpenAIToolResultPart {
|
||||
return part.type === 'tool_result';
|
||||
}
|
||||
|
||||
function extractTextContent(content: OpenAIMessage['content']): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
let text = '';
|
||||
for (const part of content) {
|
||||
if (isTextPart(part) && part.text) {
|
||||
text += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown, fallback = ''): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return typeof serialized === 'string' ? serialized : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeToolResultText(text: string): string {
|
||||
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
||||
}
|
||||
|
||||
function truncateToolResultText(text: string): string {
|
||||
if (text.length <= MAX_TOOL_RESULT_CHARS) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let suffix = '\n[truncated]';
|
||||
let keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0);
|
||||
let omittedChars = text.length - keepLength;
|
||||
|
||||
suffix = `\n[truncated ${omittedChars} chars]`;
|
||||
keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0);
|
||||
omittedChars = text.length - keepLength;
|
||||
suffix = `\n[truncated ${omittedChars} chars]`;
|
||||
|
||||
return `${text.slice(0, keepLength)}${suffix}`;
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string {
|
||||
const cleanResult = truncateToolResultText(escapeXml(sanitizeToolResultText(resultText)));
|
||||
|
||||
return [
|
||||
'<tool_result>',
|
||||
`<tool_name>${escapeXml(toolName || 'tool')}</tool_name>`,
|
||||
`<tool_call_id>${escapeXml(toolCallId)}</tool_call_id>`,
|
||||
`<result>${cleanResult}</result>`,
|
||||
'</tool_result>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function normalizeToolCallId(toolCallId: string | undefined): string {
|
||||
return typeof toolCallId === 'string' ? toolCallId.split('\n')[0] : '';
|
||||
}
|
||||
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (content === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(isTextPart)
|
||||
.map((part) => part.text || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
return stringifyUnknown(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
|
||||
}
|
||||
|
||||
function createFallbackToolUseId(messageIndex: number, partIndex: number): string {
|
||||
return `toolu_cursor_fallback_${messageIndex}_${partIndex}`;
|
||||
}
|
||||
|
||||
function resolveToolUseId(
|
||||
part: OpenAIToolUsePart,
|
||||
messageIndex: number,
|
||||
partIndex: number
|
||||
): string {
|
||||
return typeof part.id === 'string' && part.id.length > 0
|
||||
? part.id
|
||||
: createFallbackToolUseId(messageIndex, partIndex);
|
||||
}
|
||||
|
||||
function rememberToolCallMeta(
|
||||
toolCallMetaMap: Map<string, { name: string }>,
|
||||
toolCalls: NonNullable<OpenAIMessage['tool_calls']>
|
||||
): void {
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolCallId = toolCall.id || '';
|
||||
const toolName = toolCall.function?.name || 'tool';
|
||||
if (!toolCallId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toolCallMetaMap.set(toolCallId, { name: toolName });
|
||||
|
||||
const normalizedId = normalizeToolCallId(toolCallId);
|
||||
if (normalizedId && normalizedId !== toolCallId) {
|
||||
toolCallMetaMap.set(normalizedId, { name: toolName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rememberToolUseParts(
|
||||
toolCallMetaMap: Map<string, { name: string }>,
|
||||
content: OpenAIMessage['content'],
|
||||
messageIndex: number
|
||||
): void {
|
||||
if (!Array.isArray(content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let partIndex = 0; partIndex < content.length; partIndex++) {
|
||||
const part = content[partIndex];
|
||||
if (!isToolUsePart(part)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolCallId = resolveToolUseId(part, messageIndex, partIndex);
|
||||
const toolName = part.name || 'tool';
|
||||
toolCallMetaMap.set(toolCallId, { name: toolName });
|
||||
|
||||
const normalizedId = normalizeToolCallId(toolCallId);
|
||||
if (normalizedId && normalizedId !== toolCallId) {
|
||||
toolCallMetaMap.set(normalizedId, { name: toolName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolCallsFromContent(
|
||||
content: OpenAIMessage['content'],
|
||||
messageIndex: number
|
||||
): NonNullable<OpenAIMessage['tool_calls']> {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return content.flatMap((part, partIndex) => {
|
||||
if (!isToolUsePart(part)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: resolveToolUseId(part, messageIndex, partIndex),
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name || 'tool',
|
||||
arguments: stringifyUnknown(part.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function mergeAssistantToolCalls(
|
||||
primaryToolCalls: NonNullable<OpenAIMessage['tool_calls']>,
|
||||
secondaryToolCalls: NonNullable<OpenAIMessage['tool_calls']>
|
||||
): NonNullable<OpenAIMessage['tool_calls']> {
|
||||
const merged: NonNullable<OpenAIMessage['tool_calls']> = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const toolCall of [...primaryToolCalls, ...secondaryToolCalls]) {
|
||||
if (seenIds.has(toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(toolCall.id);
|
||||
merged.push(toolCall);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function renderUserContent(
|
||||
content: OpenAIMessage['content'],
|
||||
toolCallMetaMap: Map<string, { name: string }>
|
||||
): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let textBuffer = '';
|
||||
for (const part of content) {
|
||||
if (isTextPart(part) && part.text) {
|
||||
textBuffer += part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isToolResultPart(part)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (textBuffer) {
|
||||
parts.push(textBuffer);
|
||||
textBuffer = '';
|
||||
}
|
||||
|
||||
const toolCallId = part.tool_use_id || '';
|
||||
const normalizedId = normalizeToolCallId(toolCallId);
|
||||
const toolName =
|
||||
toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedId)?.name || 'tool';
|
||||
parts.push(buildToolResultBlock(toolName, toolCallId, extractToolResultText(part.content)));
|
||||
}
|
||||
|
||||
if (textBuffer) {
|
||||
parts.push(textBuffer);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI messages to Cursor format with native tool_results support
|
||||
* Convert OpenAI messages to Cursor format with a safer tool-result strategy.
|
||||
* - system → user with [System Instructions] prefix
|
||||
* - tool → accumulate into tool_results array for next user/assistant message
|
||||
* - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively)
|
||||
* - tool → flatten into a structured user text block for Cursor compatibility
|
||||
* - assistant with tool_calls → keep tool_calls in the translated shape for metadata recovery
|
||||
*/
|
||||
function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
|
||||
const result: CursorMessage[] = [];
|
||||
let pendingToolResults: CursorToolResult[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const toolCallMetaMap = new Map<string, { name: string }>();
|
||||
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
||||
const msg = messages[messageIndex];
|
||||
if (msg.role === 'system') {
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text' && part.text) content += part.text;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: `[System Instructions]\n${content}`,
|
||||
content: `[System Instructions]\n${extractTextContent(msg.content)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
let toolContent = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
toolContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
toolContent += part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolName = msg.name || 'tool';
|
||||
const toolCallId = msg.tool_call_id || '';
|
||||
const normalizedToolCallId = normalizeToolCallId(toolCallId);
|
||||
const rememberedToolName =
|
||||
toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedToolCallId)?.name;
|
||||
const toolName = msg.name || rememberedToolName || 'tool';
|
||||
|
||||
// Accumulate tool result
|
||||
pendingToolResults.push({
|
||||
tool_call_id: toolCallId,
|
||||
name: toolName,
|
||||
index: pendingToolResults.length,
|
||||
raw_args: toolContent,
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: buildToolResultBlock(toolName, toolCallId, extractTextContent(msg.content)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
let content = '';
|
||||
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
content += part.text;
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
const assistantToolCalls = mergeAssistantToolCalls(
|
||||
msg.tool_calls || [],
|
||||
extractToolCallsFromContent(msg.content, messageIndex)
|
||||
);
|
||||
if (msg.tool_calls?.length) {
|
||||
rememberToolCallMeta(toolCallMetaMap, msg.tool_calls);
|
||||
}
|
||||
}
|
||||
rememberToolUseParts(toolCallMetaMap, msg.content, messageIndex);
|
||||
|
||||
// Keep tool_calls structure for assistant messages
|
||||
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
const assistantMsg: CursorMessage = { role: 'assistant', content: '' };
|
||||
const content = extractTextContent(msg.content);
|
||||
if (assistantToolCalls.length > 0) {
|
||||
result.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
tool_calls: assistantToolCalls,
|
||||
});
|
||||
} else if (content) {
|
||||
result.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const content = renderUserContent(msg.content, toolCallMetaMap);
|
||||
if (content) {
|
||||
assistantMsg.content = content;
|
||||
result.push({
|
||||
role: 'user',
|
||||
content,
|
||||
});
|
||||
}
|
||||
assistantMsg.tool_calls = msg.tool_calls;
|
||||
|
||||
// Attach pending tool results to assistant message with tool_calls
|
||||
if (pendingToolResults.length > 0) {
|
||||
assistantMsg.tool_results = pendingToolResults;
|
||||
pendingToolResults = [];
|
||||
}
|
||||
|
||||
result.push(assistantMsg);
|
||||
} else if (content || pendingToolResults.length > 0) {
|
||||
const msgObj: CursorMessage = {
|
||||
role: msg.role,
|
||||
content: content || '',
|
||||
};
|
||||
|
||||
// Attach pending tool results to this message
|
||||
if (pendingToolResults.length > 0) {
|
||||
msgObj.tool_results = pendingToolResults;
|
||||
pendingToolResults = [];
|
||||
}
|
||||
|
||||
result.push(msgObj);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown role - skip with debug warning
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[cursor] Unknown message role: ${msg.role}, skipping`);
|
||||
}
|
||||
@@ -133,10 +374,6 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform OpenAI request to Cursor format
|
||||
* Returns modified body with converted messages
|
||||
*/
|
||||
export function buildCursorRequest(
|
||||
_model: string,
|
||||
body: OpenAIRequestBody,
|
||||
@@ -146,10 +383,8 @@ export function buildCursorRequest(
|
||||
messages: CursorMessage[];
|
||||
tools?: CursorTool[];
|
||||
} {
|
||||
const messages = convertMessages(body.messages || []);
|
||||
|
||||
return {
|
||||
...body,
|
||||
messages,
|
||||
messages: convertMessages(body.messages || []),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user