From fe56ba133e91ce77d6a3fca74628ef6040b87915 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 14:30:24 +0100 Subject: [PATCH] feat: enhance log viewer with tool category support and filtering - Added tool category icons and colors for log entries based on their metadata, improving visual differentiation. - Implemented search functionality and filters for log entry types and tool categories, allowing users to customize their view. - Enhanced log entry parsing to include tool-specific summaries and file paths, providing more context in the logs. - Introduced a clear filters button to reset search and category filters, improving user experience. - Updated the log viewer UI to accommodate new features, including a sticky header for better accessibility. --- apps/app/src/components/ui/log-viewer.tsx | 396 ++++++++++++++++-- apps/app/src/lib/log-parser.ts | 300 ++++++++++++- apps/server/src/services/auto-mode-service.ts | 5 +- 3 files changed, 658 insertions(+), 43 deletions(-) diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx index 169c626f..059ebc78 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,23 @@ import { Info, FileOutput, Brain, + Eye, + Pencil, + Terminal, + Search, + ListTodo, + Layers, + X, + Filter, } 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 +63,54 @@ 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 LogEntryItemProps { entry: LogEntry; isExpanded: boolean; @@ -63,9 +121,40 @@ 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) : ""; + + // 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(); + } // Try to find and format JSON blocks const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; @@ -103,14 +192,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { } return parts.length > 0 ? parts : [{ type: "text" as const, content }]; - }, [entry.content]); + }, [entry.content, 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 +240,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { - {!isExpanded && - entry.content.slice(0, 80) + - (entry.content.length > 80 ? "..." : "")} + {collapsedPreview} @@ -167,7 +260,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
                     {part.content}
@@ -182,10 +275,109 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
   );
 }
 
+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 +392,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 +453,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/lib/log-parser.ts b/apps/app/src/lib/log-parser.ts index 872b814d..725b9b24 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,14 @@ 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") || + // Markdown summary headers + trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || + trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) ) { return "success"; } @@ -135,9 +166,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 +192,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 +344,16 @@ function generateTitle(type: LogEntryType, content: string): string { } case "error": return "Error"; - case "success": + case "success": { + // Check if it's a summary section + 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 +367,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 +415,30 @@ 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; + 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 +448,9 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { } } currentContent = []; + inJsonAccumulation = false; + jsonBraceDepth = 0; + jsonBracketDepth = 0; }; let lineIndex = 0; @@ -238,6 +463,23 @@ 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; + } + // Detect if this line starts a new entry const lineType = detectEntryType(trimmedLine); const isNewEntry = @@ -257,7 +499,14 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { trimmedLine.match(/\[Status\]/i) || trimmedLine.toLowerCase().includes("ultrathink preparation") || trimmedLine.toLowerCase().includes("thinking level") || - (trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"); + // Agent summary sections (markdown headers after tool calls) + 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 +526,40 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { }, }; currentContent.push(trimmedLine); + } 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 c40c91ff..1f81ff0c 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1352,8 +1352,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Handle error messages throw new Error(msg.error || "Unknown error"); } else if (msg.type === "result" && msg.subtype === "success") { - responseText = msg.result || responseText; - // Schedule write for final result + // 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(); } }