diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx index 169c626f..a926e2d9 100644 --- a/apps/app/src/components/ui/log-viewer.tsx +++ b/apps/app/src/components/ui/log-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { ChevronDown, ChevronRight, @@ -14,13 +14,26 @@ import { Info, FileOutput, Brain, + Eye, + Pencil, + Terminal, + Search, + ListTodo, + Layers, + X, + Filter, + Circle, + Play, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseLogOutput, getLogTypeColors, + shouldCollapseByDefault, type LogEntry, type LogEntryType, + type ToolCategory, } from "@/lib/log-parser"; interface LogViewerProps { @@ -53,6 +66,160 @@ const getLogIcon = (type: LogEntryType) => { } }; +/** + * Returns a tool-specific icon based on the tool category + */ +const getToolCategoryIcon = (category: ToolCategory | undefined) => { + switch (category) { + case "read": + return ; + case "edit": + return ; + case "write": + return ; + case "bash": + return ; + case "search": + return ; + case "todo": + return ; + case "task": + return ; + default: + return ; + } +}; + +/** + * Returns color classes for a tool category + */ +const getToolCategoryColor = (category: ToolCategory | undefined): string => { + switch (category) { + case "read": + return "text-blue-400 bg-blue-500/10 border-blue-500/30"; + case "edit": + return "text-amber-400 bg-amber-500/10 border-amber-500/30"; + case "write": + return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30"; + case "bash": + return "text-purple-400 bg-purple-500/10 border-purple-500/30"; + case "search": + return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30"; + case "todo": + return "text-green-400 bg-green-500/10 border-green-500/30"; + case "task": + return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30"; + default: + return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30"; + } +}; + +/** + * Interface for parsed todo items from TodoWrite tool + */ +interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + +/** + * Parses TodoWrite JSON content and extracts todo items + */ +function parseTodoContent(content: string): TodoItem[] | null { + try { + // Find the JSON object in the content + const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/); + if (!jsonMatch) return null; + + const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] }; + if (!parsed.todos || !Array.isArray(parsed.todos)) return null; + + return parsed.todos; + } catch { + return null; + } +} + +/** + * Renders a list of todo items with status icons and colors + */ +function TodoListRenderer({ todos }: { todos: TodoItem[] }) { + const getStatusIcon = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "pending": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return "text-emerald-300 line-through opacity-70"; + case "in_progress": + return "text-amber-300"; + case "pending": + return "text-zinc-400"; + default: + return "text-zinc-400"; + } + }; + + const getStatusBadge = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return ( + + Done + + ); + case "in_progress": + return ( + + In Progress + + ); + default: + return null; + } + }; + + return ( +
+ {todos.map((todo, index) => ( +
+
{getStatusIcon(todo.status)}
+
+

+ {todo.content} +

+ {todo.status === "in_progress" && todo.activeForm && ( +

+ {todo.activeForm} +

+ )} +
+ {getStatusBadge(todo.status)} +
+ ))} +
+ ); +} + interface LogEntryItemProps { entry: LogEntry; isExpanded: boolean; @@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { const colors = getLogTypeColors(entry.type); const hasContent = entry.content.length > 100; + // For tool_call entries, use tool-specific styling + const isToolCall = entry.type === "tool_call"; + const toolCategory = entry.metadata?.toolCategory; + const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : ""; + + // Check if this is a TodoWrite entry and parse the todos + const isTodoWrite = entry.metadata?.toolName === "TodoWrite"; + const parsedTodos = useMemo(() => { + if (!isTodoWrite) return null; + return parseTodoContent(entry.content); + }, [isTodoWrite, entry.content]); + + // Get the appropriate icon based on entry type and tool category + const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type); + + // Get collapsed preview text - prefer smart summary for tool calls + const collapsedPreview = useMemo(() => { + if (isExpanded) return ""; + + // Use smart summary if available + if (entry.metadata?.summary) { + return entry.metadata.summary; + } + + // Fallback to truncated content + return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : ""); + }, [isExpanded, entry.metadata?.summary, entry.content]); + // Format content - detect and highlight JSON const formattedContent = useMemo(() => { - const content = entry.content; + let content = entry.content; + + // For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes + // since we already show the tool name in the header badge + if (isToolCall) { + // Remove "šŸ”§ Tool: ToolName\n" or "Tool: ToolName\n" prefix + content = content.replace(/^(?:šŸ”§\s*)?Tool:\s*\w+\s*\n?/i, ""); + // Remove standalone "Input:" label (keep the JSON that follows) + content = content.replace(/^Input:\s*\n?/i, ""); + content = content.trim(); + } + + // For summary entries, remove the and tags + if (entry.title === "Summary") { + content = content.replace(/^\s*/i, ""); + content = content.replace(/\s*<\/summary>\s*$/i, ""); + content = content.trim(); + } // Try to find and format JSON blocks const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; @@ -103,14 +315,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { } return parts.length > 0 ? parts : [{ type: "text" as const, content }]; - }, [entry.content]); + }, [entry.content, entry.title, isToolCall]); + + // Get colors - use tool category colors for tool_call entries + const colorParts = toolCategoryColors.split(" "); + const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text; + const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg; + const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border; return (
)} - - {getLogIcon(entry.type)} + + {icon} @@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { - {!isExpanded && - entry.content.slice(0, 80) + - (entry.content.length > 80 ? "..." : "")} + {collapsedPreview} @@ -156,36 +372,140 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { className="px-4 pb-3 pt-1" data-testid={`log-entry-content-${entry.id}`} > -
- {formattedContent.map((part, index) => ( -
- {part.type === "json" ? ( -
-                    {part.content}
-                  
- ) : ( -
-                    {part.content}
-                  
- )} -
- ))} -
+ {/* Render TodoWrite entries with special formatting */} + {parsedTodos ? ( + + ) : ( +
+ {formattedContent.map((part, index) => ( +
+ {part.type === "json" ? ( +
+                      {part.content}
+                    
+ ) : ( +
+                      {part.content}
+                    
+ )} +
+ ))} +
+ )}
)} ); } +interface ToolCategoryStats { + read: number; + edit: number; + write: number; + bash: number; + search: number; + todo: number; + task: number; + other: number; +} + export function LogViewer({ output, className }: LogViewerProps) { const [expandedIds, setExpandedIds] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const [hiddenTypes, setHiddenTypes] = useState>(new Set()); + const [hiddenCategories, setHiddenCategories] = useState>(new Set()); - const entries = useMemo(() => parseLogOutput(output), [output]); + // Parse entries and compute initial expanded state together + const { entries, initialExpandedIds } = useMemo(() => { + const parsedEntries = parseLogOutput(output); + const toExpand: string[] = []; + + parsedEntries.forEach((entry) => { + // If entry should NOT collapse by default, mark it for expansion + if (!shouldCollapseByDefault(entry)) { + toExpand.push(entry.id); + } + }); + + return { + entries: parsedEntries, + initialExpandedIds: new Set(toExpand), + }; + }, [output]); + + // Merge initial expanded IDs with user-toggled ones + // Use a ref to track if we've applied initial state + const appliedInitialRef = useRef>(new Set()); + + // Apply initial expanded state for new entries + const effectiveExpandedIds = useMemo(() => { + const result = new Set(expandedIds); + initialExpandedIds.forEach((id) => { + if (!appliedInitialRef.current.has(id)) { + appliedInitialRef.current.add(id); + result.add(id); + } + }); + return result; + }, [expandedIds, initialExpandedIds]); + + // Calculate stats for tool categories + const stats = useMemo(() => { + const toolCalls = entries.filter((e) => e.type === "tool_call"); + const byCategory: ToolCategoryStats = { + read: 0, + edit: 0, + write: 0, + bash: 0, + search: 0, + todo: 0, + task: 0, + other: 0, + }; + + toolCalls.forEach((tc) => { + const cat = tc.metadata?.toolCategory || "other"; + byCategory[cat]++; + }); + + return { + total: toolCalls.length, + byCategory, + errors: entries.filter((e) => e.type === "error").length, + }; + }, [entries]); + + // Filter entries based on search and hidden types/categories + const filteredEntries = useMemo(() => { + return entries.filter((entry) => { + // Filter by hidden types + if (hiddenTypes.has(entry.type)) return false; + + // Filter by hidden tool categories (for tool_call entries) + if (entry.type === "tool_call" && entry.metadata?.toolCategory) { + if (hiddenCategories.has(entry.metadata.toolCategory)) return false; + } + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + entry.content.toLowerCase().includes(query) || + entry.title.toLowerCase().includes(query) || + entry.metadata?.toolName?.toLowerCase().includes(query) || + entry.metadata?.summary?.toLowerCase().includes(query) || + entry.metadata?.filePath?.toLowerCase().includes(query) + ); + } + + return true; + }); + }, [entries, hiddenTypes, hiddenCategories, searchQuery]); const toggleEntry = (id: string) => { setExpandedIds((prev) => { @@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) { }; const expandAll = () => { - setExpandedIds(new Set(entries.map((e) => e.id))); + setExpandedIds(new Set(filteredEntries.map((e) => e.id))); }; const collapseAll = () => { setExpandedIds(new Set()); }; + const toggleTypeFilter = (type: LogEntryType) => { + setHiddenTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }; + + const toggleCategoryFilter = (category: ToolCategory) => { + setHiddenCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + const clearFilters = () => { + setSearchQuery(""); + setHiddenTypes(new Set()); + setHiddenCategories(new Set()); + }; + + const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0; + if (entries.length === 0) { return (
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) { return acc; }, {} as Record); + // Tool categories to display in stats bar + const toolCategoryLabels: { key: ToolCategory; label: string }[] = [ + { key: "read", label: "Read" }, + { key: "edit", label: "Edit" }, + { key: "write", label: "Write" }, + { key: "bash", label: "Bash" }, + { key: "search", label: "Search" }, + { key: "todo", label: "Todo" }, + { key: "task", label: "Task" }, + { key: "other", label: "Other" }, + ]; + return ( -
- {/* Header with controls */} +
+ {/* Sticky header with search, stats, and filters */} + {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */} +
+ {/* Search bar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search logs..." + className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600" + data-testid="log-search-input" + /> + {searchQuery && ( + + )} +
+ {hasActiveFilters && ( + + )} +
+ + {/* Tool category stats bar */} + {stats.total > 0 && ( +
+ + + {stats.total} tools: + + {toolCategoryLabels.map(({ key, label }) => { + const count = stats.byCategory[key]; + if (count === 0) return null; + const isHidden = hiddenCategories.has(key); + const colorClasses = getToolCategoryColor(key); + return ( + + ); + })} + {stats.errors > 0 && ( + + + {stats.errors} + + )} +
+ )} + + {/* Header with type filters and controls */}
-
+
+ {Object.entries(typeCounts).map(([type, count]) => { const colors = getLogTypeColors(type as LogEntryType); + const isHidden = hiddenTypes.has(type as LogEntryType); return ( - toggleTypeFilter(type as LogEntryType)} className={cn( - "text-xs px-2 py-0.5 rounded-full", - colors.badge + "text-xs px-2 py-0.5 rounded-full transition-all", + colors.badge, + isHidden && "opacity-40 line-through" )} - data-testid={`log-type-count-${type}`} + title={isHidden ? `Show ${type}` : `Hide ${type}`} + data-testid={`log-type-filter-${type}`} > {type}: {count} - + ); })}
+ + {filteredEntries.length}/{entries.length} +
+
{/* Log entries */} -
- {entries.map((entry) => ( - toggleEntry(entry.id)} - /> - ))} +
+ {filteredEntries.length === 0 ? ( +
+ No entries match your filters. + {hasActiveFilters && ( + + )} +
+ ) : ( + filteredEntries.map((entry) => ( + toggleEntry(entry.id)} + /> + )) + )}
); diff --git a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx index 0dd6429c..d92cabcc 100644 --- a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -99,24 +99,6 @@ export function AgentOutputModal({ loadOutput(); }, [open, featureId]); - // Save output to file - const saveOutput = async (newContent: string) => { - if (!projectPathRef.current) return; - - const api = getElectronAPI(); - if (!api) return; - - try { - // Use features API - agent output is stored in features/{id}/agent-output.md - // We need to write it directly since there's no updateAgentOutput method - // The context-manager handles this on the backend, but for frontend edits we write directly - const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`; - await api.writeFile(outputPath, newContent); - } catch (error) { - console.error("Failed to save output:", error); - } - }; - // Listen to auto mode events and update output useEffect(() => { if (!open) return; @@ -142,7 +124,7 @@ export function AgentOutputModal({ ? JSON.stringify(event.input, null, 2) : ""; newContent = `\nšŸ”§ Tool: ${toolName}\n${ - toolInput ? `Input: ${toolInput}` : "" + toolInput ? `Input: ${toolInput}\n` : "" }`; break; case "auto_mode_phase": @@ -202,11 +184,8 @@ export function AgentOutputModal({ } if (newContent) { - setOutput((prev) => { - const updated = prev + newContent; - saveOutput(updated); - return updated; - }); + // Only update local state - server is the single source of truth for file writes + setOutput((prev) => prev + newContent); } }); diff --git a/apps/app/src/lib/agent-context-parser.ts b/apps/app/src/lib/agent-context-parser.ts index 925c56fd..feb33678 100644 --- a/apps/app/src/lib/agent-context-parser.ts +++ b/apps/app/src/lib/agent-context-parser.ts @@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification /** * Extracts a summary from completed feature context + * Looks for content between and tags */ function extractSummary(content: string): string | undefined { - // Look for summary sections - capture everything including subsections (###) + // Look for tags - capture everything between opening and closing tags + const summaryTagMatch = content.match(/([\s\S]*?)<\/summary>/i); + if (summaryTagMatch) { + return summaryTagMatch[1].trim(); + } + + // Fallback: Look for summary sections - capture everything including subsections (###) // Stop at same-level ## sections (but not ###), or tool markers, or end const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\nšŸ”§|$)/i); if (summaryMatch) { diff --git a/apps/app/src/lib/log-parser.ts b/apps/app/src/lib/log-parser.ts index 872b814d..85fa96c6 100644 --- a/apps/app/src/lib/log-parser.ts +++ b/apps/app/src/lib/log-parser.ts @@ -15,6 +15,38 @@ export type LogEntryType = | "warning" | "thinking"; +export type ToolCategory = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'todo' | 'task' | 'other'; + +const TOOL_CATEGORIES: Record = { + 'Read': 'read', + 'Edit': 'edit', + 'Write': 'write', + 'Bash': 'bash', + 'Grep': 'search', + 'Glob': 'search', + 'WebSearch': 'search', + 'WebFetch': 'read', + 'TodoWrite': 'todo', + 'Task': 'task', + 'NotebookEdit': 'edit', + 'KillShell': 'bash', +}; + +/** + * Categorizes a tool name into a predefined category + */ +export function categorizeToolName(toolName: string): ToolCategory { + return TOOL_CATEGORIES[toolName] || 'other'; +} + +export interface LogEntryMetadata { + toolName?: string; + toolCategory?: ToolCategory; + filePath?: string; + summary?: string; + phase?: string; +} + export interface LogEntry { id: string; type: LogEntryType; @@ -22,11 +54,7 @@ export interface LogEntry { content: string; timestamp?: string; collapsed?: boolean; - metadata?: { - toolName?: string; - phase?: string; - [key: string]: string | undefined; - }; + metadata?: LogEntryMetadata; } /** @@ -93,11 +121,16 @@ function detectEntryType(content: string): LogEntryType { return "error"; } - // Success messages + // Success messages and summary sections if ( trimmed.startsWith("āœ…") || trimmed.toLowerCase().includes("success") || - trimmed.toLowerCase().includes("completed") + trimmed.toLowerCase().includes("completed") || + // Summary tags (preferred format from agent) + trimmed.startsWith("") || + // Markdown summary headers (fallback) + trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || + trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) ) { return "success"; } @@ -107,10 +140,11 @@ function detectEntryType(content: string): LogEntryType { return "warning"; } - // Thinking/Preparation info + // Thinking/Preparation info (be specific to avoid matching summary content) if ( trimmed.toLowerCase().includes("ultrathink") || - trimmed.toLowerCase().includes("thinking level") || + trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || + trimmed.match(/^thinking level\s*$/i) || trimmed.toLowerCase().includes("estimated cost") || trimmed.toLowerCase().includes("estimated time") || trimmed.toLowerCase().includes("budget tokens") || @@ -135,9 +169,11 @@ function detectEntryType(content: string): LogEntryType { /** * Extracts tool name from a tool call entry + * Matches both "šŸ”§ Tool: Name" and "Tool: Name" formats */ function extractToolName(content: string): string | undefined { - const match = content.match(/šŸ”§\s*Tool:\s*(\S+)/); + // Try emoji format first, then plain format + const match = content.match(/(?:šŸ”§\s*)?Tool:\s*(\S+)/); return match?.[1]; } @@ -159,6 +195,134 @@ function extractPhase(content: string): string | undefined { return match?.[1]?.toLowerCase(); } +/** + * Extracts file path from tool input JSON + */ +function extractFilePath(content: string): string | undefined { + try { + const inputMatch = content.match(/Input:\s*([\s\S]*)/); + if (!inputMatch) return undefined; + + const jsonStr = inputMatch[1].trim(); + const parsed = JSON.parse(jsonStr) as Record; + + if (typeof parsed.file_path === 'string') return parsed.file_path; + if (typeof parsed.path === 'string') return parsed.path; + if (typeof parsed.notebook_path === 'string') return parsed.notebook_path; + + return undefined; + } catch { + return undefined; + } +} + +/** + * Generates a smart summary for tool calls based on the tool name and input + */ +export function generateToolSummary(toolName: string, content: string): string | undefined { + try { + // Try to parse JSON input + const inputMatch = content.match(/Input:\s*([\s\S]*)/); + if (!inputMatch) return undefined; + + const jsonStr = inputMatch[1].trim(); + const parsed = JSON.parse(jsonStr) as Record; + + switch (toolName) { + case 'Read': { + const filePath = parsed.file_path as string | undefined; + return `Reading ${filePath?.split('/').pop() || 'file'}`; + } + case 'Edit': { + const filePath = parsed.file_path as string | undefined; + const fileName = filePath?.split('/').pop() || 'file'; + return `Editing ${fileName}`; + } + case 'Write': { + const filePath = parsed.file_path as string | undefined; + return `Writing ${filePath?.split('/').pop() || 'file'}`; + } + case 'Bash': { + const command = parsed.command as string | undefined; + const cmd = command?.slice(0, 50) || ''; + return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`; + } + case 'Grep': { + const pattern = parsed.pattern as string | undefined; + return `Searching for "${pattern?.slice(0, 30) || ''}"`; + } + case 'Glob': { + const pattern = parsed.pattern as string | undefined; + return `Finding files: ${pattern || ''}`; + } + case 'TodoWrite': { + const todos = parsed.todos as unknown[] | undefined; + const todoCount = todos?.length || 0; + return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`; + } + case 'Task': { + const subagentType = parsed.subagent_type as string | undefined; + const description = parsed.description as string | undefined; + return `${subagentType || 'Agent'}: ${description || ''}`; + } + case 'WebSearch': { + const query = parsed.query as string | undefined; + return `Searching: "${query?.slice(0, 40) || ''}"`; + } + case 'WebFetch': { + const url = parsed.url as string | undefined; + return `Fetching: ${url?.slice(0, 40) || ''}`; + } + case 'NotebookEdit': { + const notebookPath = parsed.notebook_path as string | undefined; + return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`; + } + case 'KillShell': { + return 'Terminating shell session'; + } + default: + return undefined; + } + } catch { + return undefined; + } +} + +/** + * Determines if an entry should be collapsed by default + */ +export function shouldCollapseByDefault(entry: LogEntry): boolean { + // Collapse if content is long + if (entry.content.length > 200) return true; + + // Collapse if contains multi-line JSON (> 5 lines) + const lineCount = entry.content.split('\n').length; + if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) { + return true; + } + + // Collapse TodoWrite with multiple items + if (entry.metadata?.toolName === 'TodoWrite') { + try { + const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/); + if (inputMatch) { + const parsed = JSON.parse(inputMatch[1].trim()) as Record; + const todos = parsed.todos as unknown[] | undefined; + if (todos && todos.length > 1) return true; + } + } catch { + // Ignore parse errors + } + } + + // Collapse Edit with code blocks + if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) { + return true; + } + + return false; +} + /** * Generates a title for a log entry */ @@ -183,8 +347,19 @@ function generateTitle(type: LogEntryType, content: string): string { } case "error": return "Error"; - case "success": + case "success": { + // Check if it's a summary section + if (content.startsWith("") || content.includes("")) { + return "Summary"; + } + if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) { + return "Summary"; + } + if (content.match(/^All tasks completed/i) || content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)) { + return "Summary"; + } return "Success"; + } case "warning": return "Warning"; case "thinking": @@ -198,6 +373,39 @@ function generateTitle(type: LogEntryType, content: string): string { } } +/** + * Tracks bracket depth for JSON accumulation + */ +function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } { + let braceChange = 0; + let bracketChange = 0; + let inString = false; + let escapeNext = false; + + for (const char of line) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (char === '\\') { + escapeNext = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (inString) continue; + + if (char === '{') braceChange++; + else if (char === '}') braceChange--; + else if (char === '[') bracketChange++; + else if (char === ']') bracketChange--; + } + + return { braceChange, bracketChange }; +} + /** * Parses raw log output into structured entries */ @@ -213,10 +421,33 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { let currentContent: string[] = []; let entryStartLine = 0; // Track the starting line for deterministic ID generation + // JSON accumulation state + let inJsonAccumulation = false; + let jsonBraceDepth = 0; + let jsonBracketDepth = 0; + + // Summary tag accumulation state + let inSummaryAccumulation = false; + const finalizeEntry = () => { if (currentEntry && currentContent.length > 0) { currentEntry.content = currentContent.join("\n").trim(); if (currentEntry.content) { + // Populate enhanced metadata for tool calls + const toolName = currentEntry.metadata?.toolName; + if (toolName && currentEntry.type === 'tool_call') { + const toolCategory = categorizeToolName(toolName); + const filePath = extractFilePath(currentEntry.content); + const summary = generateToolSummary(toolName, currentEntry.content); + + currentEntry.metadata = { + ...currentEntry.metadata, + toolCategory, + filePath, + summary, + }; + } + // Generate deterministic ID based on content and position const entryWithId: LogEntry = { ...currentEntry as Omit, @@ -226,6 +457,10 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { } } currentContent = []; + inJsonAccumulation = false; + jsonBraceDepth = 0; + jsonBracketDepth = 0; + inSummaryAccumulation = false; }; let lineIndex = 0; @@ -238,6 +473,35 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { continue; } + // If we're in JSON accumulation mode, keep accumulating until depth returns to 0 + if (inJsonAccumulation) { + currentContent.push(line); + const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); + jsonBraceDepth += braceChange; + jsonBracketDepth += bracketChange; + + // JSON is complete when depth returns to 0 + if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) { + inJsonAccumulation = false; + jsonBraceDepth = 0; + jsonBracketDepth = 0; + } + lineIndex++; + continue; + } + + // If we're in summary accumulation mode, keep accumulating until + if (inSummaryAccumulation) { + currentContent.push(line); + // Summary is complete when we see closing tag + if (trimmedLine.includes("")) { + inSummaryAccumulation = false; + // Don't finalize here - let normal flow handle it + } + lineIndex++; + continue; + } + // Detect if this line starts a new entry const lineType = detectEntryType(trimmedLine); const isNewEntry = @@ -256,8 +520,17 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { trimmedLine.match(/\[ERROR\]/i) || trimmedLine.match(/\[Status\]/i) || trimmedLine.toLowerCase().includes("ultrathink preparation") || - trimmedLine.toLowerCase().includes("thinking level") || - (trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"); + trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || + // Summary tags (preferred format from agent) + trimmedLine.startsWith("") || + // Agent summary sections (markdown headers - fallback) + trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || + // Summary introduction lines + trimmedLine.match(/^All tasks completed/i) || + trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i); + + // Check if this is an Input: line that should trigger JSON accumulation + const isInputLine = trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"; if (isNewEntry) { // Finalize previous entry @@ -277,9 +550,45 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { }, }; currentContent.push(trimmedLine); + + // If this is a tag, start summary accumulation mode + if (trimmedLine.startsWith("") && !trimmedLine.includes("")) { + inSummaryAccumulation = true; + } + } else if (isInputLine && currentEntry) { + // Start JSON accumulation mode + currentContent.push(trimmedLine); + + // Check if there's JSON on the same line after "Input:" + const inputContent = trimmedLine.replace(/^Input:\s*/, ''); + if (inputContent) { + const { braceChange, bracketChange } = calculateBracketDepth(inputContent); + jsonBraceDepth = braceChange; + jsonBracketDepth = bracketChange; + + // Only enter accumulation mode if JSON is incomplete + if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { + inJsonAccumulation = true; + } + } else { + // Input: line with JSON starting on next line + inJsonAccumulation = true; + } } else if (currentEntry) { // Continue current entry currentContent.push(line); + + // Check if this line starts a JSON block + if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) { + const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); + if (braceChange > 0 || bracketChange > 0) { + jsonBraceDepth = braceChange; + jsonBracketDepth = bracketChange; + if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { + inJsonAccumulation = true; + } + } + } } else { // Track starting line for deterministic ID entryStartLine = lineIndex; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index cc7c4bd8..5c9f6785 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -531,13 +531,15 @@ Address the follow-up instructions above. Review the previous work and make the } // Use fullPrompt (already built above) with model and all images + // Pass previousContext so the history is preserved in the output file await this.runAgent( workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, - model + model, + previousContext || undefined ); // Mark as waiting_approval for user review @@ -1137,7 +1139,22 @@ Implement this feature by: 4. Add or update tests as needed 5. Ensure the code follows existing patterns and conventions -When done, summarize what you implemented and any notes for the developer.`; +When done, wrap your final summary in tags like this: + + +## Summary: [Feature Title] + +### Changes Implemented +- [List of changes made] + +### Files Modified +- [List of files] + +### Notes for Developer +- [Any important notes] + + +This helps parse your summary correctly in the output logs.`; return prompt; } @@ -1148,7 +1165,8 @@ When done, summarize what you implemented and any notes for the developer.`; prompt: string, abortController: AbortController, imagePaths?: string[], - model?: string + model?: string, + previousContent?: string ): Promise { // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // This prevents actual API calls during automated testing @@ -1250,7 +1268,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Execute via provider const stream = provider.executeQuery(options); - let responseText = ""; + // Initialize with previous content if this is a follow-up, with a separator + let responseText = previousContent + ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` + : ""; // Agent output goes to .automaker directory // Note: We use the original projectPath here (from config), not workDir // because workDir might be a worktree path @@ -1258,10 +1279,43 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. const featureDirForOutput = getFeatureDir(configProjectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); + // Incremental file writing state + let writeTimeout: ReturnType | null = null; + const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms + + // Helper to write current responseText to file + const writeToFile = async (): Promise => { + try { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, responseText); + } catch (error) { + // Log but don't crash - file write errors shouldn't stop execution + console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error); + } + }; + + // Debounced write - schedules a write after WRITE_DEBOUNCE_MS + const scheduleWrite = (): void => { + if (writeTimeout) { + clearTimeout(writeTimeout); + } + writeTimeout = setTimeout(() => { + writeToFile(); + }, WRITE_DEBOUNCE_MS); + }; + for await (const msg of stream) { if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { + // Add separator before new text if we already have content and it doesn't end with newlines + if (responseText.length > 0 && !responseText.endsWith('\n\n')) { + if (responseText.endsWith('\n')) { + responseText += '\n'; + } else { + responseText += '\n\n'; + } + } responseText += block.text || ""; // Check for authentication errors in the response @@ -1277,33 +1331,49 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); } + // Schedule incremental file write (debounced) + scheduleWrite(); + this.emitAutoModeEvent("auto_mode_progress", { featureId, content: block.text, }); } else if (block.type === "tool_use") { + // Emit event for real-time UI this.emitAutoModeEvent("auto_mode_tool", { featureId, tool: block.name, input: block.input, }); + + // Also add to file output for persistence + if (responseText.length > 0 && !responseText.endsWith('\n')) { + responseText += '\n'; + } + responseText += `\nšŸ”§ Tool: ${block.name}\n`; + if (block.input) { + responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; + } + scheduleWrite(); } } } else if (msg.type === "error") { // Handle error messages throw new Error(msg.error || "Unknown error"); } else if (msg.type === "result" && msg.subtype === "success") { - responseText = msg.result || responseText; + // Don't replace responseText - the accumulated content is the full history + // The msg.result is just a summary which would lose all tool use details + // Just ensure final write happens + scheduleWrite(); } } - // Save agent output - try { - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, responseText); - } catch { - // May fail if directory doesn't exist + // Clear any pending timeout and do a final write to ensure all content is saved + if (writeTimeout) { + clearTimeout(writeTimeout); } + // Final write - ensure all accumulated content is saved + await writeToFile(); } private async executeFeatureWithContext(