Merge pull request #137 from AutoMaker-Org/fix/agent-output

refactor: remove direct file saving from AgentOutputModal and impleme…
This commit is contained in:
Web Dev Cody
2025-12-17 12:24:14 -05:00
committed by GitHub
5 changed files with 926 additions and 99 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -14,13 +14,26 @@ import {
Info, Info,
FileOutput, FileOutput,
Brain, Brain,
Eye,
Pencil,
Terminal,
Search,
ListTodo,
Layers,
X,
Filter,
Circle,
Play,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
parseLogOutput, parseLogOutput,
getLogTypeColors, getLogTypeColors,
shouldCollapseByDefault,
type LogEntry, type LogEntry,
type LogEntryType, type LogEntryType,
type ToolCategory,
} from "@/lib/log-parser"; } from "@/lib/log-parser";
interface LogViewerProps { 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 <Eye className="w-4 h-4" />;
case "edit":
return <Pencil className="w-4 h-4" />;
case "write":
return <FileOutput className="w-4 h-4" />;
case "bash":
return <Terminal className="w-4 h-4" />;
case "search":
return <Search className="w-4 h-4" />;
case "todo":
return <ListTodo className="w-4 h-4" />;
case "task":
return <Layers className="w-4 h-4" />;
default:
return <Wrench className="w-4 h-4" />;
}
};
/**
* 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 <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case "in_progress":
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
case "pending":
return <Circle className="w-4 h-4 text-zinc-500" />;
default:
return <Circle className="w-4 h-4 text-zinc-500" />;
}
};
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 (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 ml-auto">
Done
</span>
);
case "in_progress":
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-auto">
In Progress
</span>
);
default:
return null;
}
};
return (
<div className="space-y-1">
{todos.map((todo, index) => (
<div
key={index}
className={cn(
"flex items-start gap-2 p-2 rounded-md transition-colors",
todo.status === "in_progress" && "bg-amber-500/5 border border-amber-500/20",
todo.status === "completed" && "bg-emerald-500/5",
todo.status === "pending" && "bg-zinc-800/30"
)}
>
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
<div className="flex-1 min-w-0">
<p className={cn("text-sm", getStatusColor(todo.status))}>
{todo.content}
</p>
{todo.status === "in_progress" && todo.activeForm && (
<p className="text-xs text-amber-400/70 mt-0.5 italic">
{todo.activeForm}
</p>
)}
</div>
{getStatusBadge(todo.status)}
</div>
))}
</div>
);
}
interface LogEntryItemProps { interface LogEntryItemProps {
entry: LogEntry; entry: LogEntry;
isExpanded: boolean; isExpanded: boolean;
@@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const colors = getLogTypeColors(entry.type); const colors = getLogTypeColors(entry.type);
const hasContent = entry.content.length > 100; 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 // Format content - detect and highlight JSON
const formattedContent = useMemo(() => { 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 <summary> and </summary> tags
if (entry.title === "Summary") {
content = content.replace(/^<summary>\s*/i, "");
content = content.replace(/\s*<\/summary>\s*$/i, "");
content = content.trim();
}
// Try to find and format JSON blocks // Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; 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 }]; 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 ( return (
<div <div
className={cn( className={cn(
"rounded-lg border-l-4 transition-all duration-200", "rounded-lg border-l-4 transition-all duration-200",
colors.bg, bgColor,
colors.border, borderColor,
"hover:brightness-110" "hover:brightness-110"
)} )}
data-testid={`log-entry-${entry.type}`} data-testid={`log-entry-${entry.type}`}
@@ -130,14 +348,14 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<span className="w-4 flex-shrink-0" /> <span className="w-4 flex-shrink-0" />
)} )}
<span className={cn("flex-shrink-0", colors.icon)}> <span className={cn("flex-shrink-0", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
{getLogIcon(entry.type)} {icon}
</span> </span>
<span <span
className={cn( className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0", "text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
colors.badge isToolCall ? toolCategoryColors : colors.badge
)} )}
data-testid="log-entry-badge" data-testid="log-entry-badge"
> >
@@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
</span> </span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2"> <span className="text-xs text-zinc-400 truncate flex-1 ml-2">
{!isExpanded && {collapsedPreview}
entry.content.slice(0, 80) +
(entry.content.length > 80 ? "..." : "")}
</span> </span>
</button> </button>
@@ -156,6 +372,10 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
className="px-4 pb-3 pt-1" className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`} data-testid={`log-entry-content-${entry.id}`}
> >
{/* Render TodoWrite entries with special formatting */}
{parsedTodos ? (
<TodoListRenderer todos={parsedTodos} />
) : (
<div className="font-mono text-xs space-y-1"> <div className="font-mono text-xs space-y-1">
{formattedContent.map((part, index) => ( {formattedContent.map((part, index) => (
<div key={index}> <div key={index}>
@@ -167,7 +387,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<pre <pre
className={cn( className={cn(
"whitespace-pre-wrap break-words", "whitespace-pre-wrap break-words",
colors.text textColor
)} )}
> >
{part.content} {part.content}
@@ -176,16 +396,116 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
)} )}
</div> </div>
); );
} }
interface ToolCategoryStats {
read: number;
edit: number;
write: number;
bash: number;
search: number;
todo: number;
task: number;
other: number;
}
export function LogViewer({ output, className }: LogViewerProps) { export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(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<Set<string>>(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) => { const toggleEntry = (id: string) => {
setExpandedIds((prev) => { setExpandedIds((prev) => {
@@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) {
}; };
const expandAll = () => { const expandAll = () => {
setExpandedIds(new Set(entries.map((e) => e.id))); setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
}; };
const collapseAll = () => { const collapseAll = () => {
setExpandedIds(new Set()); 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) { if (entries.length === 0) {
return ( return (
<div className="flex items-center justify-center p-8 text-muted-foreground"> <div className="flex items-center justify-center p-8 text-muted-foreground">
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) {
return acc; return acc;
}, {} as Record<string, number>); }, {} as Record<string, number>);
// 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 ( return (
<div className={cn("flex flex-col gap-2", className)}> <div className={cn("flex flex-col", className)}>
{/* 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 */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
{/* Search bar */}
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
data-testid="log-search-clear"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
Clear Filters
</button>
)}
</div>
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
{toolCategoryLabels.map(({ key, label }) => {
const count = stats.byCategory[key];
if (count === 0) return null;
const isHidden = hiddenCategories.has(key);
const colorClasses = getToolCategoryColor(key);
return (
<button
key={key}
onClick={() => toggleCategoryFilter(key)}
className={cn(
"text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1",
colorClasses,
isHidden && "opacity-40 line-through"
)}
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
data-testid={`log-category-filter-${key}`}
>
{getToolCategoryIcon(key)}
<span>{count}</span>
</button>
);
})}
{stats.errors > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{stats.errors}
</span>
)}
</div>
)}
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header"> <div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => { {Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType); const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
return ( return (
<span <button
key={type} key={type}
onClick={() => toggleTypeFilter(type as LogEntryType)}
className={cn( className={cn(
"text-xs px-2 py-0.5 rounded-full", "text-xs px-2 py-0.5 rounded-full transition-all",
colors.badge 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} {type}: {count}
</span> </button>
); );
})} })}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
{filteredEntries.length}/{entries.length}
</span>
<button <button
onClick={expandAll} onClick={expandAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors" className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
@@ -267,17 +714,32 @@ export function LogViewer({ output, className }: LogViewerProps) {
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Log entries */} {/* Log entries */}
<div className="space-y-2" data-testid="log-entries-container"> <div className="space-y-2 mt-2" data-testid="log-entries-container">
{entries.map((entry) => ( {filteredEntries.length === 0 ? (
<div className="text-center py-4 text-zinc-500 text-sm">
No entries match your filters.
{hasActiveFilters && (
<button
onClick={clearFilters}
className="ml-2 text-primary hover:underline"
>
Clear filters
</button>
)}
</div>
) : (
filteredEntries.map((entry) => (
<LogEntryItem <LogEntryItem
key={entry.id} key={entry.id}
entry={entry} entry={entry}
isExpanded={expandedIds.has(entry.id)} isExpanded={effectiveExpandedIds.has(entry.id)}
onToggle={() => toggleEntry(entry.id)} onToggle={() => toggleEntry(entry.id)}
/> />
))} ))
)}
</div> </div>
</div> </div>
); );

View File

@@ -99,24 +99,6 @@ export function AgentOutputModal({
loadOutput(); loadOutput();
}, [open, featureId]); }, [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 // Listen to auto mode events and update output
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -142,7 +124,7 @@ export function AgentOutputModal({
? JSON.stringify(event.input, null, 2) ? JSON.stringify(event.input, null, 2)
: ""; : "";
newContent = `\n🔧 Tool: ${toolName}\n${ newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : "" toolInput ? `Input: ${toolInput}\n` : ""
}`; }`;
break; break;
case "auto_mode_phase": case "auto_mode_phase":
@@ -202,11 +184,8 @@ export function AgentOutputModal({
} }
if (newContent) { if (newContent) {
setOutput((prev) => { // Only update local state - server is the single source of truth for file writes
const updated = prev + newContent; setOutput((prev) => prev + newContent);
saveOutput(updated);
return updated;
});
} }
}); });

View File

@@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification
/** /**
* Extracts a summary from completed feature context * Extracts a summary from completed feature context
* Looks for content between <summary> and </summary> tags
*/ */
function extractSummary(content: string): string | undefined { function extractSummary(content: string): string | undefined {
// Look for summary sections - capture everything including subsections (###) // Look for <summary> tags - capture everything between opening and closing tags
const summaryTagMatch = content.match(/<summary>([\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 // Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i); const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) { if (summaryMatch) {

View File

@@ -15,6 +15,38 @@ export type LogEntryType =
| "warning" | "warning"
| "thinking"; | "thinking";
export type ToolCategory = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'todo' | 'task' | 'other';
const TOOL_CATEGORIES: Record<string, ToolCategory> = {
'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 { export interface LogEntry {
id: string; id: string;
type: LogEntryType; type: LogEntryType;
@@ -22,11 +54,7 @@ export interface LogEntry {
content: string; content: string;
timestamp?: string; timestamp?: string;
collapsed?: boolean; collapsed?: boolean;
metadata?: { metadata?: LogEntryMetadata;
toolName?: string;
phase?: string;
[key: string]: string | undefined;
};
} }
/** /**
@@ -93,11 +121,16 @@ function detectEntryType(content: string): LogEntryType {
return "error"; return "error";
} }
// Success messages // Success messages and summary sections
if ( if (
trimmed.startsWith("✅") || trimmed.startsWith("✅") ||
trimmed.toLowerCase().includes("success") || trimmed.toLowerCase().includes("success") ||
trimmed.toLowerCase().includes("completed") trimmed.toLowerCase().includes("completed") ||
// Summary tags (preferred format from agent)
trimmed.startsWith("<summary>") ||
// 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"; return "success";
} }
@@ -107,10 +140,11 @@ function detectEntryType(content: string): LogEntryType {
return "warning"; return "warning";
} }
// Thinking/Preparation info // Thinking/Preparation info (be specific to avoid matching summary content)
if ( if (
trimmed.toLowerCase().includes("ultrathink") || 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 cost") ||
trimmed.toLowerCase().includes("estimated time") || trimmed.toLowerCase().includes("estimated time") ||
trimmed.toLowerCase().includes("budget tokens") || trimmed.toLowerCase().includes("budget tokens") ||
@@ -135,9 +169,11 @@ function detectEntryType(content: string): LogEntryType {
/** /**
* Extracts tool name from a tool call entry * Extracts tool name from a tool call entry
* Matches both "🔧 Tool: Name" and "Tool: Name" formats
*/ */
function extractToolName(content: string): string | undefined { 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]; return match?.[1];
} }
@@ -159,6 +195,134 @@ function extractPhase(content: string): string | undefined {
return match?.[1]?.toLowerCase(); 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 * Generates a title for a log entry
*/ */
@@ -183,8 +347,19 @@ function generateTitle(type: LogEntryType, content: string): string {
} }
case "error": case "error":
return "Error"; return "Error";
case "success": case "success": {
// Check if it's a summary section
if (content.startsWith("<summary>") || content.includes("<summary>")) {
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"; return "Success";
}
case "warning": case "warning":
return "Warning"; return "Warning";
case "thinking": 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 * Parses raw log output into structured entries
*/ */
@@ -213,10 +421,33 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
let currentContent: string[] = []; let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation 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 = () => { const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) { if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim(); currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) { 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 // Generate deterministic ID based on content and position
const entryWithId: LogEntry = { const entryWithId: LogEntry = {
...currentEntry as Omit<LogEntry, 'id'>, ...currentEntry as Omit<LogEntry, 'id'>,
@@ -226,6 +457,10 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
} }
} }
currentContent = []; currentContent = [];
inJsonAccumulation = false;
jsonBraceDepth = 0;
jsonBracketDepth = 0;
inSummaryAccumulation = false;
}; };
let lineIndex = 0; let lineIndex = 0;
@@ -238,6 +473,35 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
continue; 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 </summary>
if (inSummaryAccumulation) {
currentContent.push(line);
// Summary is complete when we see closing tag
if (trimmedLine.includes("</summary>")) {
inSummaryAccumulation = false;
// Don't finalize here - let normal flow handle it
}
lineIndex++;
continue;
}
// Detect if this line starts a new entry // Detect if this line starts a new entry
const lineType = detectEntryType(trimmedLine); const lineType = detectEntryType(trimmedLine);
const isNewEntry = const isNewEntry =
@@ -256,8 +520,17 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
trimmedLine.match(/\[ERROR\]/i) || trimmedLine.match(/\[ERROR\]/i) ||
trimmedLine.match(/\[Status\]/i) || trimmedLine.match(/\[Status\]/i) ||
trimmedLine.toLowerCase().includes("ultrathink preparation") || trimmedLine.toLowerCase().includes("ultrathink preparation") ||
trimmedLine.toLowerCase().includes("thinking level") || trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"); // Summary tags (preferred format from agent)
trimmedLine.startsWith("<summary>") ||
// 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) { if (isNewEntry) {
// Finalize previous entry // Finalize previous entry
@@ -277,9 +550,45 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
}, },
}; };
currentContent.push(trimmedLine); currentContent.push(trimmedLine);
// If this is a <summary> tag, start summary accumulation mode
if (trimmedLine.startsWith("<summary>") && !trimmedLine.includes("</summary>")) {
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) { } else if (currentEntry) {
// Continue current entry // Continue current entry
currentContent.push(line); 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 { } else {
// Track starting line for deterministic ID // Track starting line for deterministic ID
entryStartLine = lineIndex; entryStartLine = lineIndex;

View File

@@ -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 // Use fullPrompt (already built above) with model and all images
// Pass previousContext so the history is preserved in the output file
await this.runAgent( await this.runAgent(
workDir, workDir,
featureId, featureId,
fullPrompt, fullPrompt,
abortController, abortController,
allImagePaths.length > 0 ? allImagePaths : imagePaths, allImagePaths.length > 0 ? allImagePaths : imagePaths,
model model,
previousContext || undefined
); );
// Mark as waiting_approval for user review // Mark as waiting_approval for user review
@@ -1137,7 +1139,22 @@ Implement this feature by:
4. Add or update tests as needed 4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions 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 <summary> tags like this:
<summary>
## Summary: [Feature Title]
### Changes Implemented
- [List of changes made]
### Files Modified
- [List of files]
### Notes for Developer
- [Any important notes]
</summary>
This helps parse your summary correctly in the output logs.`;
return prompt; return prompt;
} }
@@ -1148,7 +1165,8 @@ When done, summarize what you implemented and any notes for the developer.`;
prompt: string, prompt: string,
abortController: AbortController, abortController: AbortController,
imagePaths?: string[], imagePaths?: string[],
model?: string model?: string,
previousContent?: string
): Promise<void> { ): Promise<void> {
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing // 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 // Execute via provider
const stream = provider.executeQuery(options); 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 // Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir // Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path // 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 featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md"); const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
// Helper to write current responseText to file
const writeToFile = async (): Promise<void> => {
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) { for await (const msg of stream) {
if (msg.type === "assistant" && msg.message?.content) { if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === "text") { 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 || ""; responseText += block.text || "";
// Check for authentication errors in the response // 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", { this.emitAutoModeEvent("auto_mode_progress", {
featureId, featureId,
content: block.text, content: block.text,
}); });
} else if (block.type === "tool_use") { } else if (block.type === "tool_use") {
// Emit event for real-time UI
this.emitAutoModeEvent("auto_mode_tool", { this.emitAutoModeEvent("auto_mode_tool", {
featureId, featureId,
tool: block.name, tool: block.name,
input: block.input, 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") { } else if (msg.type === "error") {
// Handle error messages // Handle error messages
throw new Error(msg.error || "Unknown error"); throw new Error(msg.error || "Unknown error");
} else if (msg.type === "result" && msg.subtype === "success") { } 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 // Clear any pending timeout and do a final write to ensure all content is saved
try { if (writeTimeout) {
await fs.mkdir(path.dirname(outputPath), { recursive: true }); clearTimeout(writeTimeout);
await fs.writeFile(outputPath, responseText);
} catch {
// May fail if directory doesn't exist
} }
// Final write - ensure all accumulated content is saved
await writeToFile();
} }
private async executeFeatureWithContext( private async executeFeatureWithContext(