From 3c6736bc442fef4f04ef662cc5cfa6d9e7f9ef3b Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 30 Dec 2025 17:35:39 +0100 Subject: [PATCH] feat(cursor): Enhance Cursor tool handling with a registry and improved processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced a registry for Cursor tool handlers to streamline the processing of various tool calls, including read, write, edit, delete, grep, ls, glob, semantic search, and read lints. This refactor allows for better organization and normalization of tool inputs and outputs. Additionally, updated the CursorToolCallEvent interface to accommodate new tool calls and their respective arguments. Enhanced logging for raw events and unrecognized tool call structures for improved debugging. Affected files: - cursor-provider.ts: Added CURSOR_TOOL_HANDLERS and refactored tool call processing. - log-parser.ts: Updated tool categories and added summaries for new tools. - cursor-cli.ts: Expanded CursorToolCallEvent interface to include new tool calls. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- apps/server/src/providers/cursor-provider.ts | 263 ++++++++++++++++--- apps/ui/src/lib/log-parser.ts | 228 +++++++++++++++- libs/types/src/cursor-cli.ts | 86 +++++- 3 files changed, 542 insertions(+), 35 deletions(-) diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 941eb967..71198456 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -44,6 +44,189 @@ import { spawnJSONLProcess, execInWsl } from '@automaker/platform'; // Create logger for this module const logger = createLogger('CursorProvider'); +// ============================================================================= +// Cursor Tool Handler Registry +// ============================================================================= + +/** + * Tool handler definition for mapping Cursor tool calls to normalized format + */ +interface CursorToolHandler { + /** The normalized tool name (e.g., 'Read', 'Write') */ + name: string; + /** Extract and normalize input from Cursor's args format */ + mapInput: (args: TArgs) => unknown; + /** Format the result content for display (optional) */ + formatResult?: (result: TResult, args?: TArgs) => string; + /** Format rejected result (optional) */ + formatRejected?: (reason: string) => string; +} + +/** + * Registry of Cursor tool handlers + * Each handler knows how to normalize its specific tool call type + */ +const CURSOR_TOOL_HANDLERS: Record> = { + readToolCall: { + name: 'Read', + mapInput: (args: { path: string }) => ({ file_path: args.path }), + formatResult: (result: { content: string }) => result.content, + }, + + writeToolCall: { + name: 'Write', + mapInput: (args: { path: string; fileText: string }) => ({ + file_path: args.path, + content: args.fileText, + }), + formatResult: (result: { linesCreated: number; path: string }) => + `Wrote ${result.linesCreated} lines to ${result.path}`, + }, + + editToolCall: { + name: 'Edit', + mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({ + file_path: args.path, + old_string: args.oldText, + new_string: args.newText, + }), + formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`, + }, + + shellToolCall: { + name: 'Bash', + mapInput: (args: { command: string }) => ({ command: args.command }), + formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => { + let content = `Exit code: ${result.exitCode}`; + if (result.stdout) content += `\n${result.stdout}`; + if (result.stderr) content += `\nStderr: ${result.stderr}`; + return content; + }, + formatRejected: (reason: string) => `Rejected: ${reason}`, + }, + + deleteToolCall: { + name: 'Delete', + mapInput: (args: { path: string }) => ({ file_path: args.path }), + formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`, + formatRejected: (reason: string) => `Delete rejected: ${reason}`, + }, + + grepToolCall: { + name: 'Grep', + mapInput: (args: { pattern: string; path?: string }) => ({ + pattern: args.pattern, + path: args.path, + }), + formatResult: (result: { matchedLines: number }) => + `Found ${result.matchedLines} matching lines`, + }, + + lsToolCall: { + name: 'Ls', + mapInput: (args: { path: string }) => ({ path: args.path }), + formatResult: (result: { childrenFiles: number; childrenDirs: number }) => + `Found ${result.childrenFiles} files, ${result.childrenDirs} directories`, + }, + + globToolCall: { + name: 'Glob', + mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({ + pattern: args.globPattern, + path: args.targetDirectory, + }), + formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`, + }, + + semSearchToolCall: { + name: 'SemanticSearch', + mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({ + query: args.query, + targetDirectories: args.targetDirectories, + explanation: args.explanation, + }), + formatResult: (result: { results: string; codeResults?: unknown[] }) => { + const resultCount = result.codeResults?.length || 0; + return resultCount > 0 + ? `Found ${resultCount} semantic search result(s)` + : result.results || 'No results found'; + }, + }, + + readLintsToolCall: { + name: 'ReadLints', + mapInput: (args: { paths: string[] }) => ({ paths: args.paths }), + formatResult: (result: { totalDiagnostics: number; totalFiles: number }) => + `Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`, + }, +}; + +/** + * Process a Cursor tool call using the handler registry + * Returns { toolName, toolInput } or null if tool type is unknown + */ +function processCursorToolCall( + toolCall: CursorToolCallEvent['tool_call'] +): { toolName: string; toolInput: unknown } | null { + // Check each registered handler + for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { + const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined; + if (toolData) { + // Skip if args not yet populated (partial streaming event) + if (!toolData.args) return null; + return { + toolName: handler.name, + toolInput: handler.mapInput(toolData.args), + }; + } + } + + // Handle generic function call (fallback) + if (toolCall.function) { + let toolInput: unknown; + try { + toolInput = JSON.parse(toolCall.function.arguments || '{}'); + } catch { + toolInput = { raw: toolCall.function.arguments }; + } + return { + toolName: toolCall.function.name, + toolInput, + }; + } + + return null; +} + +/** + * Format the result content for a completed Cursor tool call + */ +function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string { + for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { + const toolData = toolCall[key as keyof typeof toolCall] as + | { + args?: unknown; + result?: { success?: unknown; rejected?: { reason: string } }; + } + | undefined; + + if (toolData?.result) { + if (toolData.result.success && handler.formatResult) { + return handler.formatResult(toolData.result.success, toolData.args); + } + if (toolData.result.rejected && handler.formatRejected) { + return handler.formatRejected(toolData.result.rejected.reason); + } + } + } + + return ''; +} + +// ============================================================================= +// Error Codes +// ============================================================================= + /** * Cursor-specific error codes for detailed error handling */ @@ -198,34 +381,20 @@ export class CursorProvider extends CliProvider { const toolEvent = cursorEvent as CursorToolCallEvent; const toolCall = toolEvent.tool_call; - // Determine tool name and input - let toolName: string; - let toolInput: unknown; - - if (toolCall.readToolCall) { - // Skip if args not yet populated (partial streaming event) - if (!toolCall.readToolCall.args) return null; - toolName = 'Read'; - toolInput = { file_path: toolCall.readToolCall.args.path }; - } else if (toolCall.writeToolCall) { - // Skip if args not yet populated (partial streaming event) - if (!toolCall.writeToolCall.args) return null; - toolName = 'Write'; - toolInput = { - file_path: toolCall.writeToolCall.args.path, - content: toolCall.writeToolCall.args.fileText, - }; - } else if (toolCall.function) { - toolName = toolCall.function.name; - try { - toolInput = JSON.parse(toolCall.function.arguments || '{}'); - } catch { - toolInput = { raw: toolCall.function.arguments }; - } - } else { + // Use the tool handler registry to process the tool call + const processed = processCursorToolCall(toolCall); + if (!processed) { + // Log unrecognized tool call structure for debugging + const toolCallKeys = Object.keys(toolCall); + logger.warn( + `[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` + + `Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}` + ); return null; } + const { toolName, toolInput } = processed; + // For started events, emit tool_use if (toolEvent.subtype === 'started') { return { @@ -247,13 +416,7 @@ export class CursorProvider extends CliProvider { // For completed events, emit both tool_use and tool_result if (toolEvent.subtype === 'completed') { - let resultContent = ''; - - if (toolCall.readToolCall?.result?.success) { - resultContent = toolCall.readToolCall.result.success.content; - } else if (toolCall.writeToolCall?.result?.success) { - resultContent = `Wrote ${toolCall.writeToolCall.result.success.linesCreated} lines to ${toolCall.writeToolCall.result.success.path}`; - } + const resultContent = formatCursorToolResult(toolCall); return { type: 'assistant', @@ -469,10 +632,43 @@ export class CursorProvider extends CliProvider { logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`); + // Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled + const debugRawEvents = + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; + try { for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { const event = rawEvent as CursorStreamEvent; + // Log raw event for debugging + if (debugRawEvents) { + logger.info(`[RAW EVENT] type=${event.type} subtype=${(event as any).subtype || 'none'}`); + if (event.type === 'tool_call') { + const toolEvent = event as CursorToolCallEvent; + const tc = toolEvent.tool_call; + const toolTypes = + [ + tc.readToolCall && 'read', + tc.writeToolCall && 'write', + tc.editToolCall && 'edit', + tc.shellToolCall && 'shell', + tc.deleteToolCall && 'delete', + tc.grepToolCall && 'grep', + tc.lsToolCall && 'ls', + tc.globToolCall && 'glob', + tc.function && `function:${tc.function.name}`, + ] + .filter(Boolean) + .join(',') || 'unknown'; + logger.info( + `[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` + + (tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') + + (tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '') + ); + } + } + // Capture session ID from system init if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') { sessionId = event.session_id; @@ -481,6 +677,9 @@ export class CursorProvider extends CliProvider { // Normalize and yield the event const normalized = this.normalizeEvent(event); + if (!normalized && debugRawEvents) { + logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`); + } if (normalized) { // Ensure session_id is always set if (!normalized.session_id && sessionId) { diff --git a/apps/ui/src/lib/log-parser.ts b/apps/ui/src/lib/log-parser.ts index 2fa54934..0c9473b8 100644 --- a/apps/ui/src/lib/log-parser.ts +++ b/apps/ui/src/lib/log-parser.ts @@ -58,12 +58,16 @@ const TOOL_CATEGORIES: Record = { Bash: 'bash', Grep: 'search', Glob: 'search', + Ls: 'read', + Delete: 'write', WebSearch: 'search', WebFetch: 'read', TodoWrite: 'todo', Task: 'task', NotebookEdit: 'edit', KillShell: 'bash', + SemanticSearch: 'search', + ReadLints: 'read', }; /** @@ -323,6 +327,15 @@ export function generateToolSummary(toolName: string, content: string): string | case 'KillShell': { return 'Terminating shell session'; } + case 'SemanticSearch': { + const query = parsed.query as string | undefined; + return `Semantic search: "${query?.slice(0, 30) || ''}"`; + } + case 'ReadLints': { + const paths = parsed.paths as string[] | undefined; + const pathCount = paths?.length || 0; + return `Reading lints for ${pathCount} file(s)`; + } default: return undefined; } @@ -363,7 +376,7 @@ function normalizeCursorToolCall( // Read tool if (toolCall.readToolCall) { - const path = toolCall.readToolCall.args.path; + const path = toolCall.readToolCall.args?.path || 'unknown'; const result = toolCall.readToolCall.result?.success; return { @@ -412,7 +425,218 @@ function normalizeCursorToolCall( }; } - // Generic function tool + // Edit tool + if (toolCall.editToolCall) { + const path = toolCall.editToolCall.args?.path || 'unknown'; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Editing ${path}` : `Edited ${path}`, + content: `Path: ${path}`, + collapsed: true, + metadata: { + toolName: 'Edit', + toolCategory: 'edit' as ToolCategory, + filePath: path, + summary: isCompleted ? `Edited file` : `Editing file...`, + }, + }; + } + + // Shell/Bash tool + if (toolCall.shellToolCall) { + const command = toolCall.shellToolCall.args?.command || ''; + const result = toolCall.shellToolCall.result; + const shortCmd = command.length > 50 ? command.slice(0, 50) + '...' : command; + + let content = `Command: ${command}`; + if (isCompleted && result?.success) { + content += `\nExit code: ${result.success.exitCode}`; + } else if (isCompleted && result?.rejected) { + content += `\nRejected: ${result.rejected.reason}`; + } + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Running: ${shortCmd}` : `Ran: ${shortCmd}`, + content, + collapsed: true, + metadata: { + toolName: 'Bash', + toolCategory: 'bash' as ToolCategory, + summary: isCompleted + ? result?.success + ? `Exit ${result.success.exitCode}` + : result?.rejected + ? 'Rejected' + : 'Completed' + : `Running...`, + }, + }; + } + + // Delete tool + if (toolCall.deleteToolCall) { + const path = toolCall.deleteToolCall.args?.path || 'unknown'; + const result = toolCall.deleteToolCall.result; + + let content = `Path: ${path}`; + if (isCompleted && result?.rejected) { + content += `\nRejected: ${result.rejected.reason}`; + } + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Deleting ${path}` : `Deleted ${path}`, + content, + collapsed: true, + metadata: { + toolName: 'Delete', + toolCategory: 'write' as ToolCategory, + filePath: path, + summary: isCompleted ? (result?.rejected ? 'Rejected' : 'Deleted') : `Deleting...`, + }, + }; + } + + // Grep tool + if (toolCall.grepToolCall) { + const pattern = toolCall.grepToolCall.args?.pattern || ''; + const searchPath = toolCall.grepToolCall.args?.path; + const result = toolCall.grepToolCall.result?.success; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Searching: "${pattern}"` : `Searched: "${pattern}"`, + content: `Pattern: ${pattern}${searchPath ? `\nPath: ${searchPath}` : ''}${ + isCompleted && result ? `\nMatched ${result.matchedLines} lines` : '' + }`, + collapsed: true, + metadata: { + toolName: 'Grep', + toolCategory: 'search' as ToolCategory, + summary: isCompleted ? `Found ${result?.matchedLines || 0} matches` : `Searching...`, + }, + }; + } + + // Ls tool + if (toolCall.lsToolCall) { + const path = toolCall.lsToolCall.args?.path || '.'; + const result = toolCall.lsToolCall.result?.success; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Listing ${path}` : `Listed ${path}`, + content: `Path: ${path}${ + isCompleted && result + ? `\n${result.childrenFiles} files, ${result.childrenDirs} directories` + : '' + }`, + collapsed: true, + metadata: { + toolName: 'Ls', + toolCategory: 'read' as ToolCategory, + filePath: path, + summary: isCompleted + ? `${result?.childrenFiles || 0} files, ${result?.childrenDirs || 0} dirs` + : `Listing...`, + }, + }; + } + + // Glob tool + if (toolCall.globToolCall) { + const pattern = toolCall.globToolCall.args?.globPattern || ''; + const targetDir = toolCall.globToolCall.args?.targetDirectory; + const result = toolCall.globToolCall.result?.success; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Finding: ${pattern}` : `Found: ${pattern}`, + content: `Pattern: ${pattern}${targetDir ? `\nDirectory: ${targetDir}` : ''}${ + isCompleted && result ? `\nFound ${result.totalFiles} files` : '' + }`, + collapsed: true, + metadata: { + toolName: 'Glob', + toolCategory: 'search' as ToolCategory, + summary: isCompleted ? `Found ${result?.totalFiles || 0} files` : `Finding...`, + }, + }; + } + + // Semantic Search tool + if (toolCall.semSearchToolCall) { + const query = toolCall.semSearchToolCall.args?.query || ''; + const targetDirs = toolCall.semSearchToolCall.args?.targetDirectories; + const result = toolCall.semSearchToolCall.result?.success; + const shortQuery = query.length > 40 ? query.slice(0, 40) + '...' : query; + const resultCount = result?.codeResults?.length || 0; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Semantic search: "${shortQuery}"` : `Searched: "${shortQuery}"`, + content: `Query: ${query}${targetDirs?.length ? `\nDirectories: ${targetDirs.join(', ')}` : ''}${ + isCompleted + ? `\n${resultCount > 0 ? `Found ${resultCount} result(s)` : result?.results || 'No results'}` + : '' + }`, + collapsed: true, + metadata: { + toolName: 'SemanticSearch', + toolCategory: 'search' as ToolCategory, + summary: isCompleted + ? resultCount > 0 + ? `Found ${resultCount} result(s)` + : 'No results' + : `Searching...`, + }, + }; + } + + // Read Lints tool + if (toolCall.readLintsToolCall) { + const paths = toolCall.readLintsToolCall.args?.paths || []; + const result = toolCall.readLintsToolCall.result?.success; + const pathCount = paths.length; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Reading lints for ${pathCount} file(s)` : `Read lints`, + content: `Paths: ${paths.join(', ')}${ + isCompleted && result + ? `\nFound ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)` + : '' + }`, + collapsed: true, + metadata: { + toolName: 'ReadLints', + toolCategory: 'read' as ToolCategory, + summary: isCompleted + ? `${result?.totalDiagnostics || 0} diagnostic(s)` + : `Reading lints...`, + }, + }; + } + + // Generic function tool (fallback) if (toolCall.function) { const name = toolCall.function.name; const args = toolCall.function.arguments; diff --git a/libs/types/src/cursor-cli.ts b/libs/types/src/cursor-cli.ts index 6929abd1..d5b423d3 100644 --- a/libs/types/src/cursor-cli.ts +++ b/libs/types/src/cursor-cli.ts @@ -264,7 +264,7 @@ export interface CursorToolCallEvent { call_id: string; tool_call: { readToolCall?: { - args: { path: string }; + args: { path: string; offset?: number; limit?: number }; result?: { success?: { content: string; @@ -285,6 +285,90 @@ export interface CursorToolCallEvent { }; }; }; + editToolCall?: { + args: { path: string; oldText?: string; newText?: string }; + result?: { + success?: Record; + }; + }; + shellToolCall?: { + args: { command: string }; + result?: { + success?: { + exitCode: number; + stdout?: string; + stderr?: string; + }; + rejected?: { + reason: string; + }; + }; + }; + deleteToolCall?: { + args: { path: string }; + result?: { + success?: Record; + rejected?: { + reason: string; + }; + }; + }; + grepToolCall?: { + args: { pattern: string; path?: string }; + result?: { + success?: { + matchedLines: number; + }; + }; + }; + lsToolCall?: { + args: { path: string; ignore?: string[] }; + result?: { + success?: { + childrenFiles: number; + childrenDirs: number; + }; + }; + }; + globToolCall?: { + args: { globPattern: string; targetDirectory?: string }; + result?: { + success?: { + totalFiles: number; + }; + }; + }; + semSearchToolCall?: { + args: { query: string; targetDirectories?: string[]; explanation?: string }; + result?: { + success?: { + results: string; + codeResults?: Array<{ + path: string; + content: string; + score?: number; + }>; + }; + }; + }; + readLintsToolCall?: { + args: { paths: string[] }; + result?: { + success?: { + fileDiagnostics: Array<{ + path: string; + diagnostics: Array<{ + message: string; + severity: string; + line?: number; + column?: number; + }>; + }>; + totalFiles: number; + totalDiagnostics: number; + }; + }; + }; function?: { name: string; arguments: string;