From d7b907ed9fb16668694e09cc43ed8eada67a4a2a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 20:28:50 -0400 Subject: [PATCH] fix(cursor): make bare cursor command useful and harden tool-result translation --- README.md | 2 +- docs/cursor-integration.md | 7 + src/commands/cursor-command-display.ts | 28 +- src/commands/cursor-command.ts | 1 + src/cursor/cursor-translator.ts | 401 +++++++++++++++++----- tests/unit/cursor/cursor-daemon.test.ts | 132 +++++++ tests/unit/cursor/cursor-protobuf.test.ts | 305 +++++++++++++++- 7 files changed, 781 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index c10672d9..fa1346e7 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The dashboard provides visual management for all account types: ccs # Default Claude session ccs gemini # Gemini (OAuth) ccs codex # OpenAI Codex (OAuth) -ccs cursor # Cursor IDE integration (token import + local daemon) +ccs cursor # Cursor status + local daemon connection details ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 8278544e..5e190e8c 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -49,6 +49,13 @@ ccs cursor start ccs cursor status ``` +Or use the bare command to see the same status view plus the local endpoint details +needed for OpenAI-compatible and Anthropic-compatible clients: + +```bash +ccs cursor +``` + ### 5) Stop daemon ```bash diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index 34260d88..5e1daf57 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -12,7 +12,7 @@ export function renderCursorHelp(): number { printLines([ 'Cursor IDE Integration', '', - 'Usage: ccs cursor [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 { diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index ae8d5363..05a9245b 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -56,6 +56,7 @@ export async function handleCursorCommand(args: string[]): Promise { case 'disable': return handleDisable(); case undefined: + return handleStatus(); case 'help': case '--help': case '-h': diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts index f709ab43..fa6617c7 100644 --- a/src/cursor/cursor-translator.ts +++ b/src/cursor/cursor-translator.ts @@ -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; +} + +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, '>'); +} + +function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string { + const cleanResult = truncateToolResultText(escapeXml(sanitizeToolResultText(resultText))); + + return [ + '', + `${escapeXml(toolName || 'tool')}`, + `${escapeXml(toolCallId)}`, + `${cleanResult}`, + '', + ].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, + toolCalls: NonNullable +): 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, + 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 { + 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, + secondaryToolCalls: NonNullable +): NonNullable { + const merged: NonNullable = []; + const seenIds = new Set(); + + 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 { + 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(); + 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 || []), }; } diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 5a2f15bb..8e17e2d0 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -19,7 +19,12 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; +import { + renderCursorHelp, + renderCursorStatus, +} from '../../../src/commands/cursor-command-display'; import { loadCredentials } from '../../../src/cursor/cursor-auth'; +import { DEFAULT_CURSOR_CONFIG } from '../../../src/config/unified-config-types'; // Test isolation let originalCcsHome: string | undefined; @@ -271,6 +276,34 @@ describe('stopDaemon', () => { }); describe('handleCursorCommand', () => { + it('treats bare ccs cursor as a status entrypoint instead of help', async () => { + const originalLog = console.log; + const originalError = console.error; + const logs: string[] = []; + const errors: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + console.error = (...args: unknown[]) => { + errors.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + const exitCode = await handleCursorCommand([]); + + expect(exitCode).toBe(0); + expect(errors).toHaveLength(0); + expect(logs.some((line) => line.includes('Cursor IDE Status'))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs cursor [options]'))).toBe( + false + ); + } finally { + console.log = originalLog; + console.error = originalError; + } + }); + it('returns exit code 1 for unknown subcommand', async () => { const exitCode = await handleCursorCommand(['nonexistent']); expect(exitCode).toBe(1); @@ -297,3 +330,102 @@ describe('handleCursorCommand', () => { expect(credentials?.machineId).toBe(machineId); }); }); + +describe('renderCursorStatus', () => { + it('shows runtime endpoint guidance when Cursor is ready', () => { + const originalLog = console.log; + const logs: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + renderCursorStatus( + { ...DEFAULT_CURSOR_CONFIG, enabled: true, port: 20129 }, + { + authenticated: true, + expired: false, + tokenAge: 0, + credentials: { + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }, + }, + { running: true, port: 20129, pid: 1234 } + ); + + expect(logs.some((line) => line.includes('OpenAI base: http://127.0.0.1:20129/v1'))).toBe( + true + ); + expect( + logs.some((line) => line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions')) + ).toBe(true); + expect( + logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129')) + ).toBe(true); + expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe( + true + ); + } finally { + console.log = originalLog; + } + }); + + it('shows runtime guidance even when setup is incomplete', () => { + const originalLog = console.log; + const logs: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + renderCursorStatus( + { ...DEFAULT_CURSOR_CONFIG, enabled: false, port: 20129 }, + { + authenticated: false, + expired: false, + tokenAge: undefined, + credentials: undefined, + }, + { running: false, port: 20129, pid: undefined } + ); + + expect(logs.some((line) => line.includes('OpenAI base: http://127.0.0.1:20129/v1'))).toBe( + true + ); + expect(logs.some((line) => line.includes('Next steps:'))).toBe(true); + expect(logs.some((line) => line.includes(' - Help: ccs cursor help'))).toBe(true); + } finally { + console.log = originalLog; + } + }); +}); + +describe('renderCursorHelp', () => { + it('shows bare ccs cursor as an optional entrypoint', () => { + const originalLog = console.log; + const logs: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + const exitCode = renderCursorHelp(); + + expect(exitCode).toBe(0); + expect(logs.some((line) => line.includes('Usage: ccs cursor [subcommand] [options]'))).toBe( + true + ); + expect( + logs.some((line) => line.includes('4. ccs cursor # Show status and runtime connection details')) + ).toBe(true); + } finally { + console.log = originalLog; + } + }); +}); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 70d9e973..e2af6027 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -13,6 +13,7 @@ import { import { decodeVarint, decodeField, + decodeMessage, parseConnectRPCFrame, } from '../../../src/cursor/cursor-protobuf-decoder'; import { buildCursorRequest } from '../../../src/cursor/cursor-translator'; @@ -251,11 +252,256 @@ describe('Message Translation', () => { {} ); + expect(result.messages).toHaveLength(3); + expect(result.messages[1].role).toBe('user'); + expect(result.messages[1].content).toContain(''); + expect(result.messages[1].content).toContain('get_weather'); + expect(result.messages[1].content).toContain('call_123'); + expect(result.messages[2]).toEqual({ role: 'user', content: 'What is the weather?' }); + }); + + it('should recover tool names for tool results without a name field', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + { + role: 'tool', + content: 'done', + tool_call_id: 'call_456', + }, + ], + }, + false, + {} + ); + expect(result.messages).toHaveLength(2); - // Tool result should be attached to next message - expect(result.messages[1].tool_results).toBeDefined(); - expect(result.messages[1].tool_results).toHaveLength(1); - expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123'); + expect(result.messages[1].content).toContain('search_docs'); + expect(result.messages[1].tool_results).toBeUndefined(); + }); + + it('should flatten user content arrays that include tool_result blocks', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_789', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Tool finished.' }, + { + type: 'tool_result', + tool_use_id: 'call_789', + content: { answer: 'Cursor integration ready' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].role).toBe('user'); + expect(result.messages[1].content).toContain('Tool finished.'); + expect(result.messages[1].content).toContain('search_docs'); + expect(result.messages[1].content).toContain('{"answer":"Cursor integration ready"}'); + }); + + it('should convert assistant tool_use content blocks into tool_calls', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Inspecting the workspace.' }, + { + type: 'tool_use', + id: 'call_999', + name: 'list_files', + input: { path: '/tmp' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('assistant'); + expect(result.messages[0].content).toBe('Inspecting the workspace.'); + expect(result.messages[0].tool_calls).toEqual([ + { + id: 'call_999', + type: 'function', + function: { name: 'list_files', arguments: '{"path":"/tmp"}' }, + }, + ]); + }); + + it('should synthesize fallback ids for tool_use blocks that omit them', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'search_docs', + input: { q: 'cursor' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].id).toBe('toolu_cursor_fallback_0_0'); + }); + + it('should dedupe tool calls when assistant messages contain both tool_calls and tool_use blocks', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_dupe', + name: 'search_docs', + input: { q: 'cursor' }, + }, + ], + tool_calls: [ + { + id: 'call_dupe', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].tool_calls).toEqual([ + { + id: 'call_dupe', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ]); + }); + + it('should truncate oversized tool result payloads', () => { + const oversizedResult = '&'.repeat(12_050); + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_big', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"big.txt"}' }, + }, + ], + }, + { + role: 'tool', + content: oversizedResult, + tool_call_id: 'call_big', + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].content).toContain('[truncated '); + expect(result.messages[1].content.length).toBeLessThan(12_250); + }); + + it('should mark unserializable structured tool results explicitly', () => { + const circular: Record = {}; + circular.self = circular; + + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_bad', + type: 'function', + function: { name: 'read_json', arguments: '{"path":"bad.json"}' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_bad', + content: circular, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].content).toContain('[unserializable content]'); }); it('should handle array content format', () => { @@ -344,6 +590,57 @@ describe('Request Encoding', () => { expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); }); + + it('should preserve flattened tool_result blocks through protobuf encoding', () => { + const translated = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_wire', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"notes.txt"}' }, + }, + ], + }, + { + role: 'tool', + content: 'workspace snapshot', + tool_call_id: 'call_wire', + }, + ], + }, + false, + {} + ); + + const body = generateCursorBody(translated.messages, 'gpt-4', [], null); + const frame = parseConnectRPCFrame(Buffer.from(body)); + expect(frame).not.toBeNull(); + + const topLevel = decodeMessage(frame!.payload); + const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; + const chatRequest = decodeMessage(requestPayload); + const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) => + decodeMessage(entry.value as Uint8Array) + ); + const decoder = new TextDecoder(); + const contents = encodedMessages.map((message) => + decoder.decode(message.get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array) + ); + + expect(contents.some((content) => content.includes(''))).toBe(true); + expect(contents.some((content) => content.includes('read_file'))).toBe( + true + ); + expect( + contents.some((content) => content.includes('call_wire')) + ).toBe(true); + }); }); describe('Edge cases', () => {