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;