diff --git a/src/index.css b/src/index.css index 6a13b840..e5036133 100644 --- a/src/index.css +++ b/src/index.css @@ -29,6 +29,59 @@ .chat-edge-throb { animation: none; } } +/* Debug mode pulsing effect - red edges */ +@keyframes debug-pulse { + 0%, 100% { + box-shadow: + inset 4px 0 12px rgba(239, 68, 68, 0.1), + inset -4px 0 12px rgba(239, 68, 68, 0.1); + } + 50% { + box-shadow: + inset 4px 0 20px rgba(239, 68, 68, 0.2), + inset -4px 0 20px rgba(239, 68, 68, 0.2); + } +} + +.animate-debug-pulse { + animation: debug-pulse 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .animate-debug-pulse { animation: none; } +} + +/* Custom scrollbar for debug bubble */ +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(239, 68, 68, 0.3); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(239, 68, 68, 0.5); +} + +.dark .custom-scrollbar::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(239, 68, 68, 0.4); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(239, 68, 68, 0.6); +} + @custom-variant dark (&:is(.dark *)); @plugin '@tailwindcss/typography'; @theme inline { diff --git a/src/routes/chat/chat.tsx b/src/routes/chat/chat.tsx index 0d36540a..71803cea 100644 --- a/src/routes/chat/chat.tsx +++ b/src/routes/chat/chat.tsx @@ -11,6 +11,7 @@ import { useParams, useSearchParams, useNavigate } from 'react-router'; import { MonacoEditor } from '../../components/monaco-editor/monaco-editor'; import { AnimatePresence, motion } from 'framer-motion'; import { Expand, Github, GitBranch, LoaderCircle, RefreshCw, MoreHorizontal, RotateCcw, X } from 'lucide-react'; +import clsx from 'clsx'; import { Blueprint } from './components/blueprint'; import { FileExplorer } from './components/file-explorer'; import { UserMessage, AIMessage } from './components/messages'; @@ -134,6 +135,10 @@ export default function Chat() { shouldRefreshPreview, // Preview deployment state isPreviewDeploying, + // Issue tracking and debugging state + runtimeErrorCount, + staticIssueCount, + isDebugging, } = useChat({ chatId: urlChatId, query: userQuery, @@ -410,7 +415,8 @@ export default function Chat() { }, [isGeneratingBlueprint, view]); useEffect(() => { - if (doneStreaming && !isGeneratingBlueprint && !blueprint) { + // Only show bootstrap completion message for NEW chats, not when reloading existing ones + if (doneStreaming && !isGeneratingBlueprint && !blueprint && urlChatId === 'new') { onCompleteBootstrap(); sendAiMessage( createAIMessage( @@ -426,6 +432,7 @@ export default function Chat() { sendAiMessage, blueprint, onCompleteBootstrap, + urlChatId, ]); const isRunning = useMemo(() => { @@ -434,17 +441,15 @@ export default function Chat() { ); }, [isBootstrapping, isGeneratingBlueprint]); - // Check if chat input should be disabled (before blueprint completion and agentId assignment) + // Check if chat input should be disabled (before blueprint completion, or during debugging) const isChatDisabled = useMemo(() => { const blueprintStage = projectStages.find( (stage) => stage.id === 'blueprint', ); - const isBlueprintComplete = blueprintStage?.status === 'completed'; - const hasAgentId = !!chatId; + const blueprintNotCompleted = !blueprintStage || blueprintStage.status !== 'completed'; - // Disable until both blueprint is complete AND we have an agentId - return !isBlueprintComplete || !hasAgentId; - }, [projectStages, chatId]); + return blueprintNotCompleted || isDebugging; + }, [projectStages, isDebugging]); const chatFormRef = useRef(null); const { isDragging: isChatDragging, dragHandlers: chatDragHandlers } = useDragDrop({ @@ -526,7 +531,13 @@ export default function Chat() { layout="position" className="flex-1 shrink-0 flex flex-col basis-0 max-w-lg relative z-10 h-full min-h-0" > -
+
{appLoading ? (
@@ -559,39 +570,60 @@ export default function Chat() { )} {mainMessage && ( -
+
+ + {chatId && ( +
+ + + + + + { + e.preventDefault(); + setIsResetDialogOpen(true); + }} + > + + Reset conversation + + + +
+ )} +
+ )} + + {otherMessages + .filter(message => message.role === 'assistant' && message.ui?.isThinking) + .map((message) => ( +
+ +
+ ))} + + {isThinking && !otherMessages.some(m => m.ui?.isThinking) && ( +
- {chatId && ( -
- - - - - - { - e.preventDefault(); - setIsResetDialogOpen(true); - }} - > - - Reset conversation - - - -
- )}
)} @@ -614,6 +646,11 @@ export default function Chat() { chatId={chatId} isDeploying={isDeploying} handleDeployToCloudflare={handleDeployToCloudflare} + runtimeErrorCount={runtimeErrorCount} + staticIssueCount={staticIssueCount} + isDebugging={isDebugging} + isGenerating={isGenerating} + isThinking={isThinking} /> {/* Deployment and Generation Controls */} @@ -654,24 +691,27 @@ export default function Chat() { )} - {otherMessages.map((message) => { - if (message.role === 'assistant') { + {otherMessages + .filter(message => !message.ui?.isThinking) + .map((message) => { + if (message.role === 'assistant') { + return ( + + ); + } return ( - ); - } - return ( - - ); - })} + })} +
@@ -737,11 +777,13 @@ export default function Chat() { }} disabled={isChatDisabled} placeholder={ - isChatDisabled - ? 'Please wait for blueprint completion...' - : isRunning - ? 'Chat with AI while generating...' - : 'Ask a follow up...' + isDebugging + ? 'Deep debugging in progress... Please abort to continue' + : isChatDisabled + ? 'Please wait for blueprint completion...' + : isRunning + ? 'Chat with AI while generating...' + : 'Chat with AI...' } rows={1} className="w-full bg-bg-2 border border-text-primary/10 rounded-xl px-3 pr-20 py-2 text-sm outline-none focus:border-white/20 drop-shadow-2xl text-text-primary placeholder:!text-text-primary/50 disabled:opacity-50 disabled:cursor-not-allowed resize-none overflow-y-auto no-scrollbar min-h-[36px] max-h-[120px]" @@ -759,7 +801,7 @@ export default function Chat() { }} />
- {(isGenerating || isGeneratingBlueprint) && ( + {(isGenerating || isGeneratingBlueprint || isDebugging) && ( + +{/* Expandable content */} + +{isExpanded && ( + +
+{/* Scrollable content area */} +
+
+{/* Render message content */} + ev.contentLength !== undefined) || []} +/> + +{/* Tool events */} +{message.ui?.toolEvents && message.ui.toolEvents.length > 0 && ( +
+{message.ui.toolEvents +.filter(ev => ev.name !== 'deep_debug' && ev.contentLength === undefined) +.map((event, idx) => ( + +))} +
+)} +
+
+ +{/* Scroll to bottom button */} + +{showScrollButton && ( + + + +)} + +
+
+)} +
+
+ +); +} diff --git a/src/routes/chat/components/messages.tsx b/src/routes/chat/components/messages.tsx index 1082a94c..afca7284 100644 --- a/src/routes/chat/components/messages.tsx +++ b/src/routes/chat/components/messages.tsx @@ -6,7 +6,8 @@ import rehypeExternalLinks from 'rehype-external-links'; import { LoaderCircle, Check, AlertTriangle, ChevronDown, ChevronRight, MessageSquare } from 'lucide-react'; import type { ToolEvent } from '../utils/message-helpers'; import type { ConversationMessage } from '@/api-types'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { DebugSessionBubble } from './debug-session-bubble'; /** * Strip internal system tags that should not be displayed to users @@ -84,7 +85,7 @@ function convertToToolEvent(msg: ConversationMessage, idx: number): ToolEvent | }; } -function MessageContentRenderer({ +export function MessageContentRenderer({ content, toolEvents = [] }: { @@ -181,7 +182,7 @@ function ToolResultRenderer({ result, toolName }: { result: string; toolName: st } } -function ToolStatusIndicator({ event }: { event: ToolEvent }) { +export function ToolStatusIndicator({ event }: { event: ToolEvent }) { const [isExpanded, setIsExpanded] = useState(false); const hasResult = event.status === 'success' && event.result; const isDeepDebug = event.name === 'deep_debug'; @@ -270,6 +271,66 @@ export function AIMessage({ }) { const sanitizedMessage = sanitizeMessageForDisplay(message); + // Check if this is a debug session (active or just completed in this session) + const debugEvent = toolEvents.find(ev => ev.name === 'deep_debug'); + const isActiveDebug = debugEvent?.status === 'start'; + const isCompletedDebug = debugEvent?.status === 'success' || debugEvent?.status === 'error'; + + // Check if this is a live session with actual content + const hasInlineEvents = toolEvents.some(ev => ev.contentLength !== undefined); + const hasToolCalls = toolEvents.some(ev => ev.name !== 'deep_debug'); + + // Only show bubble if: actively debugging OR (completed/errored with actual content/tool calls and inline events) + const isLiveDebugSession = debugEvent && ( + isActiveDebug || + (isCompletedDebug && hasInlineEvents && hasToolCalls) + ); + + // Calculate elapsed time for active debug sessions + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const startTimeRef = useRef(null); + + useEffect(() => { + if (!isActiveDebug) { + startTimeRef.current = null; + setElapsedSeconds(0); + return; + } + + if (!startTimeRef.current) { + startTimeRef.current = Date.now(); + } + + const interval = setInterval(() => { + if (startTimeRef.current) { + const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); + setElapsedSeconds(elapsed); + } + }, 1000); + + return () => clearInterval(interval); + }, [isActiveDebug]); + + // Render debug bubble for live debug sessions (active or just completed) + // Don't show for old messages after page refresh (no inline events) + if (isLiveDebugSession) { + const toolCallCount = toolEvents.filter(e => e.name !== 'deep_debug').length; + + return ( + + ); + } + // Separate: events without contentLength = top (restored), with contentLength = inline (streaming) const topToolEvents = toolEvents.filter(ev => ev.contentLength === undefined); const inlineToolEvents = toolEvents.filter(ev => ev.contentLength !== undefined) diff --git a/src/routes/chat/components/phase-timeline.tsx b/src/routes/chat/components/phase-timeline.tsx index cc96c412..563d596f 100644 --- a/src/routes/chat/components/phase-timeline.tsx +++ b/src/routes/chat/components/phase-timeline.tsx @@ -186,6 +186,13 @@ interface PhaseTimelineProps { chatId?: string; isDeploying?: boolean; handleDeployToCloudflare?: (instanceId: string) => void; + // Issue tracking and debugging + runtimeErrorCount?: number; + staticIssueCount?: number; + isDebugging?: boolean; + // Activity state + isGenerating?: boolean; + isThinking?: boolean; } // Helper function to truncate long file paths @@ -267,7 +274,12 @@ export function PhaseTimeline({ onViewChange, chatId, isDeploying, - handleDeployToCloudflare + handleDeployToCloudflare, + runtimeErrorCount = 0, + staticIssueCount = 0, + isDebugging = false, + isGenerating = false, + isThinking = false }: PhaseTimelineProps) { const [expandedPhases, setExpandedPhases] = useState>(new Set()); const [showCollapsedBar, setShowCollapsedBar] = useState(false); @@ -651,9 +663,17 @@ export function PhaseTimeline({ > {/* Main Timeline Card */}
+ {/* Calculate if Done/Debugging will show for line extension */} + {(() => { + const allStagesCompleted = projectStages.every(stage => stage.status === 'completed'); + const isAnythingHappening = isDebugging || isGenerating || isThinking || isPreviewDeploying; + const willShowStatusStage = (allStagesCompleted && !isAnythingHappening) || isDebugging; + + return ( + <> {/* Project Stages */} {projectStages.map((stage, index) => ( -
+
@@ -679,6 +699,33 @@ export function PhaseTimeline({ )} + + {/* Subtle inline issue indicator for completed code stage */} + {stage.id === 'code' && stage.status === 'completed' && (runtimeErrorCount > 0 || staticIssueCount > 0) && ( + + + {runtimeErrorCount > 0 && ( + +
+ {runtimeErrorCount} error{runtimeErrorCount > 1 ? 's' : ''} + + )} + {runtimeErrorCount > 0 && staticIssueCount > 0 && ( + , + )} + {staticIssueCount > 0 && ( + +
+ {staticIssueCount} warning{staticIssueCount > 1 ? 's' : ''} + + )} + + )}
{/* Blueprint button */} @@ -700,13 +747,17 @@ export function PhaseTimeline({ {/* Detailed Phase Timeline for code stage */} {stage.id === 'code' && ( -
+
{phaseTimeline.map((phase, phaseIndex) => (
+ {/* Subtle vertical line connecting phases */} + {phaseIndex < phaseTimeline.length - 1 && ( +
+ )} {/* Phase Implementation Header */}
- {index !== projectStages.length - 1 && ( + {/* Vertical connecting line */} + {(index !== projectStages.length - 1 || (index === projectStages.length - 1 && willShowStatusStage)) && (
))} -
+ + + {/* Done stage - shows when everything is complete and nothing is happening */} + {(() => { + const allStagesCompleted = projectStages.every(stage => stage.status === 'completed'); + const isAnythingHappening = isDebugging || isGenerating || isThinking || isPreviewDeploying; + const showDone = allStagesCompleted && !isAnythingHappening; + + if (showDone) { + return ( + + {/* Connecting line from previous stage */} +
+ + + +
+ Done +
+ + ); + } + + // Show debugging status when debugging + if (isDebugging) { + return ( + + {/* Connecting line from previous stage */} +
+ + + +
+ Debugging in progress... +
+ + ); + } + + return null; + })()} + + + ); + })()} +
); diff --git a/src/routes/chat/hooks/use-chat.ts b/src/routes/chat/hooks/use-chat.ts index 79072dc4..4194426e 100644 --- a/src/routes/chat/hooks/use-chat.ts +++ b/src/routes/chat/hooks/use-chat.ts @@ -17,7 +17,7 @@ import { logger } from '@/utils/logger'; import { apiClient } from '@/lib/api-client'; import { appEvents } from '@/lib/app-events'; import { createWebSocketMessageHandler, type HandleMessageDeps } from '../utils/handle-websocket-message'; -import { isConversationalMessage, addOrUpdateMessage, createUserMessage, handleRateLimitError, createAIMessage, appendToolEvent, type ChatMessage } from '../utils/message-helpers'; +import { isConversationalMessage, addOrUpdateMessage, createUserMessage, handleRateLimitError, createAIMessage, type ChatMessage } from '../utils/message-helpers'; import { sendWebSocketMessage } from '../utils/websocket-helpers'; import { initialStages as defaultStages, updateStage as updateStageHelper } from '../utils/project-stage-helpers'; import type { ProjectStage } from '../utils/project-stage-helpers'; @@ -102,6 +102,11 @@ export function useChat({ const [cloudflareDeploymentUrl, setCloudflareDeploymentUrl] = useState(''); const [deploymentError, setDeploymentError] = useState(); + // Issue tracking and debugging state + const [runtimeErrorCount, setRuntimeErrorCount] = useState(0); + const [staticIssueCount, setStaticIssueCount] = useState(0); + const [isDebugging, setIsDebugging] = useState(false); + // Preview deployment state const [isPreviewDeploying, setIsPreviewDeploying] = useState(false); @@ -182,6 +187,9 @@ export function useChat({ setIsGenerationPaused, setIsGenerating, setIsPhaseProgressActive, + setRuntimeErrorCount, + setStaticIssueCount, + setIsDebugging, // Current state isInitialStateRestored, blueprint, @@ -274,7 +282,10 @@ export function useChat({ // Send success message to user if (isRetry) { - sendMessage(createAIMessage('websocket_reconnected', '🔌 Connection restored! Continuing with code generation...')); + // Clear old messages on reconnect to prevent duplicates + setMessages(() => [ + createAIMessage('websocket_reconnected', 'Seems we lost connection for a while there. Fixed now!', true) + ]); } // Always request conversation state explicitly (running/full history) @@ -468,13 +479,10 @@ export function useChat({ }); } else if (connectionStatus.current === 'idle') { setIsBootstrapping(false); - // Show fetching indicator as a tool-event style message - setMessages(() => - appendToolEvent([], 'fetching-chat', { - name: 'fetching your latest conversations', - status: 'start', - }), - ); + // Show starting message with thinking indicator + setMessages(() => [ + createAIMessage('fetching-chat', 'Starting from where you left off...', true) + ]); // Fetch existing agent connection details const response = await apiClient.connectToAgent(urlChatId); @@ -487,7 +495,6 @@ export function useChat({ // Set the chatId for existing chat - this enables the chat input setChatId(urlChatId); - sendMessage(createAIMessage('resuming-chat', 'Starting from where you left off...')); logger.debug('connecting from init for existing chatId'); connectWithRetry(response.data.websocketUrl, { @@ -541,6 +548,17 @@ export function useChat({ } }, [edit]); + // Track debugging state based on deep_debug tool events in messages + useEffect(() => { + const hasActiveDebug = messages.some(msg => + msg.role === 'assistant' && + msg.ui?.toolEvents?.some(event => + event.name === 'deep_debug' && event.status === 'start' + ) + ); + setIsDebugging(hasActiveDebug); + }, [messages]); + // Control functions for deployment and generation const handleStopGeneration = useCallback(() => { sendWebSocketMessage(websocket, 'stop_generation'); @@ -635,5 +653,9 @@ export function useChat({ isPreviewDeploying, // Phase progress visual indicator isPhaseProgressActive, + // Issue tracking and debugging state + runtimeErrorCount, + staticIssueCount, + isDebugging, }; } diff --git a/src/routes/chat/hooks/use-debug-session.ts b/src/routes/chat/hooks/use-debug-session.ts new file mode 100644 index 00000000..7c4ee3e7 --- /dev/null +++ b/src/routes/chat/hooks/use-debug-session.ts @@ -0,0 +1,85 @@ +import { useMemo, useState, useEffect, useRef } from 'react'; +import type { ChatMessage } from '../utils/message-helpers'; + +interface DebugSessionInfo { + message: ChatMessage; + isActive: boolean; + startTime: number; + elapsedSeconds: number; + toolCallCount: number; +} + +/** + * Custom hook to extract and manage debug session state + * Reuses existing message/toolEvent infrastructure + */ +export function useDebugSession( + messages: ChatMessage[] +): DebugSessionInfo | null { + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const startTimeRef = useRef(null); + + // Find the message containing deep_debug tool event + const debugMessage = useMemo(() => { + return messages.find(msg => + msg.ui?.toolEvents?.some(event => event.name === 'deep_debug') + ); + }, [messages]); + + // Extract debug event info + const debugInfo = useMemo(() => { + if (!debugMessage) return null; + + const debugEvent = debugMessage.ui?.toolEvents?.find(e => e.name === 'deep_debug'); + if (!debugEvent) return null; + + const toolCallCount = debugMessage.ui?.toolEvents?.filter( + e => e.name !== 'deep_debug' + ).length || 0; + + return { + message: debugMessage, + isActive: debugEvent.status === 'start', + startTime: debugEvent.timestamp, + toolCallCount, + }; + }, [debugMessage]); + + // Timer for elapsed time + useEffect(() => { + if (!debugInfo?.isActive) { + startTimeRef.current = null; + setElapsedSeconds(0); + return; + } + + if (!startTimeRef.current) { + startTimeRef.current = Date.now(); + } + + const interval = setInterval(() => { + if (startTimeRef.current) { + const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); + setElapsedSeconds(elapsed); + } + }, 1000); + + return () => clearInterval(interval); + }, [debugInfo?.isActive]); + + if (!debugInfo) return null; + + return { + ...debugInfo, + elapsedSeconds, + }; +} + +/** + * Format elapsed time as MM:SS + */ +export function formatElapsedTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} diff --git a/src/routes/chat/utils/handle-websocket-message.ts b/src/routes/chat/utils/handle-websocket-message.ts index f07f53a9..d86d9fc4 100644 --- a/src/routes/chat/utils/handle-websocket-message.ts +++ b/src/routes/chat/utils/handle-websocket-message.ts @@ -44,6 +44,9 @@ export interface HandleMessageDeps { setIsGenerationPaused: React.Dispatch>; setIsGenerating: React.Dispatch>; setIsPhaseProgressActive: React.Dispatch>; + setRuntimeErrorCount: React.Dispatch>; + setStaticIssueCount: React.Dispatch>; + setIsDebugging: React.Dispatch>; // Current state isInitialStateRestored: boolean; @@ -79,9 +82,6 @@ export interface HandleMessageDeps { } export function createWebSocketMessageHandler(deps: HandleMessageDeps) { - // Track review lifecycle within this handler instance - let lastReviewIssueCount = 0; - let reviewStartAnnounced = false; const extractTextContent = (content: ConversationMessage['content']): string => { if (!content) return ''; if (typeof content === 'string') return content; @@ -114,6 +114,7 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { setIsGenerationPaused, setIsGenerating, setIsPhaseProgressActive, + setIsDebugging, isInitialStateRestored, blueprint, query, @@ -243,7 +244,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { if (state.generatedFilesMap && Object.keys(state.generatedFilesMap).length > 0) { updateStage('code', { status: 'completed' }); - updateStage('validate', { status: 'completed' }); if (urlChatId !== 'new') { logger.debug('🚀 Requesting preview deployment for existing chat with files'); sendWebSocketMessage(websocket, 'preview'); @@ -269,7 +269,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { if (codeStage?.status === 'active' && !isGenerating) { if (state.generatedFilesMap && Object.keys(state.generatedFilesMap).length > 0) { updateStage('code', { status: 'completed' }); - updateStage('validate', { status: 'completed' }); if (!previewUrl) { logger.debug('🚀 Generated files exist but no preview URL - auto-deploying preview'); @@ -284,9 +283,10 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { } case 'conversation_state': { - const { state } = message; + if (message.type !== 'conversation_state') break; + const { state, deepDebugSession } = message; const history: ReadonlyArray = state?.runningHistory ?? []; - logger.debug('Received conversation_state with messages:', history.length, 'state:', state); + logger.debug('Received conversation_state with messages:', history.length, 'deepDebugSession:', deepDebugSession); const restoredMessages: ChatMessage[] = []; let currentAssistant: ChatMessage | null = null; @@ -339,6 +339,52 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { } } + // Restore active debug session if one is running + if (deepDebugSession?.conversationId) { + setIsDebugging(true); + + // Find if there's already a message with this conversationId + const existingMessageIndex = restoredMessages.findIndex( + m => m.role === 'assistant' && m.conversationId === deepDebugSession.conversationId + ); + + if (existingMessageIndex !== -1) { + // Update existing message to show as active debug + const existingMessage = restoredMessages[existingMessageIndex]; + if (!existingMessage.ui) existingMessage.ui = {}; + if (!existingMessage.ui.toolEvents) existingMessage.ui.toolEvents = []; + + const debugEventIndex = existingMessage.ui.toolEvents.findIndex(e => e.name === 'deep_debug'); + if (debugEventIndex === -1) { + existingMessage.ui.toolEvents.push({ + name: 'deep_debug', + status: 'start', + timestamp: Date.now(), + contentLength: 0 + }); + } else { + existingMessage.ui.toolEvents[debugEventIndex].status = 'start'; + existingMessage.ui.toolEvents[debugEventIndex].contentLength = 0; + } + } else { + // Create new placeholder message with the active conversationId + const debugBubble: ChatMessage = { + role: 'assistant', + conversationId: deepDebugSession.conversationId, + content: 'Deep debug session in progress...', + ui: { + toolEvents: [{ + name: 'deep_debug', + status: 'start', + timestamp: Date.now(), + contentLength: 0 + }] + } + }; + restoredMessages.push(debugBubble); + } + } + if (restoredMessages.length > 0) { // Deduplicate assistant messages with identical content (even if separated by tool messages) const deduplicated = deduplicateMessages(restoredMessages); @@ -346,6 +392,8 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { logger.debug('Merging conversation_state with', deduplicated.length, 'messages (', restoredMessages.length - deduplicated.length, 'duplicates removed)'); setMessages(prev => { const hasFetching = prev.some(m => m.role === 'assistant' && m.conversationId === 'fetching-chat'); + const hasReconnect = prev.some(m => m.role === 'assistant' && m.conversationId === 'websocket_reconnected'); + if (hasFetching) { const next = appendToolEvent(prev, 'fetching-chat', { name: 'fetching your latest conversations', @@ -353,6 +401,12 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { }); return [...next, ...deduplicated]; } + + if (hasReconnect) { + // Preserve reconnect message on top when restoring state after reconnect + return [...prev, ...deduplicated]; + } + return deduplicated; }); } @@ -395,8 +449,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { case 'file_regenerating': { setFiles((prev) => setFileGenerating(prev, message.filePath, 'File being regenerated...')); setPhaseTimeline((prev) => updatePhaseFileStatus(prev, message.filePath, 'generating')); - // Activates fixing stage only when actual regenerations begin - updateStage('fix', { status: 'active', metadata: lastReviewIssueCount > 0 ? `Fixing ${lastReviewIssueCount} issues` : 'Fixing issues' }); break; } @@ -404,18 +456,13 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { updateStage('code', { status: 'active' }); setTotalFiles(message.totalFiles); setIsGenerating(true); - // Reset review tracking for a new generation run - lastReviewIssueCount = 0; - reviewStartAnnounced = false; break; } case 'generation_complete': { setIsRedeployReady(true); setFiles((prev) => setAllFilesCompleted(prev)); - setProjectStages((prev) => completeStages(prev, ['code', 'validate', 'fix'])); - // Ensure fix stage metadata is cleared on final completion - updateStage('fix', { status: 'completed', metadata: undefined }); + setProjectStages((prev) => completeStages(prev, ['code'])); sendMessage(createAIMessage('generation-complete', 'Code generation has been completed.')); @@ -448,8 +495,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { const totalIssues = reviewData?.filesToFix?.reduce((count: number, file: any) => count + file.issues.length, 0) || 0; - lastReviewIssueCount = totalIssues; - let reviewMessage = 'Code review complete'; if (reviewData?.issuesFound) { reviewMessage = `Code review complete - ${totalIssues} issue${totalIssues !== 1 ? 's' : ''} found across ${reviewData.filesToFix?.length || 0} file${reviewData.filesToFix?.length !== 1 ? 's' : ''}`; @@ -457,8 +502,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { reviewMessage = 'Code review complete - no issues found'; } - // Mark validation as completed at the end of review - updateStage('validate', { status: 'completed' }); sendMessage(createAIMessage('code_reviewed', reviewMessage)); break; } @@ -466,6 +509,9 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { case 'runtime_error_found': { logger.info('Runtime error found in sandbox', message.errors); + // Update runtime error count + deps.setRuntimeErrorCount(message.count || message.errors?.length || 0); + onDebugMessage?.('error', `Runtime Error (${message.count} errors)`, message.errors.map((e: any) => `${e.message}\nStack: ${e.stack || 'N/A'}`).join('\n\n'), @@ -475,23 +521,17 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { } case 'code_reviewing': { - const totalIssues = - (message.staticAnalysis?.lint?.issues?.length || 0) + - (message.staticAnalysis?.typecheck?.issues?.length || 0) + - (message.runtimeErrors.length || 0); - - lastReviewIssueCount = totalIssues; + const lintIssues = message.staticAnalysis?.lint?.issues?.length || 0; + const typecheckIssues = message.staticAnalysis?.typecheck?.issues?.length || 0; + const runtimeErrors = message.runtimeErrors?.length || 0; + const totalIssues = lintIssues + typecheckIssues + runtimeErrors; - // Announce review start once, right after main code gen - if (!reviewStartAnnounced) { - sendMessage(createAIMessage('review_start', 'App generation complete, now reviewing code indepth')); - reviewStartAnnounced = true; - } + // Update issue counts + deps.setStaticIssueCount(lintIssues + typecheckIssues); + deps.setRuntimeErrorCount(runtimeErrors); - // Only show reviewing as active; do not activate fix until regeneration actually starts - updateStage('validate', { status: 'active' }); - // Show identified issues count while review runs, but keep fix stage pending - updateStage('fix', { status: 'pending', metadata: totalIssues > 0 ? `Identified ${totalIssues} issues` : undefined }); + // Show review start message + sendMessage(createAIMessage('review_start', 'App generation complete, now reviewing code indepth')); if (totalIssues > 0) { const errorDetails = [ @@ -510,8 +550,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { } case 'phase_generating': { - updateStage('validate', { status: 'completed' }); - updateStage('fix', { status: 'completed', metadata: undefined }); sendMessage(createAIMessage('phase_generating', message.message)); setIsThinking(true); setIsPhaseProgressActive(true); @@ -559,7 +597,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { case 'phase_validating': { sendMessage(createAIMessage('phase_validating', message.message)); - updateStage('validate', { status: 'active' }); setPhaseTimeline(prev => { const updated = [...prev]; @@ -577,7 +614,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { case 'phase_validated': { sendMessage(createAIMessage('phase_validated', message.message)); - updateStage('validate', { status: 'completed' }); break; } @@ -599,10 +635,6 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { } return updated; }); - - if (message.phase.name === 'Finalization and Review') { - sendMessage(createAIMessage('core_app_complete', 'Main app generation completed. Doing code cleanups and resolving any lingering issues. Meanwhile, feel free to ask me anything!')); - } } logger.debug('🔄 Scheduling preview refresh in 1 second after deployment completion'); @@ -634,6 +666,7 @@ export function createWebSocketMessageHandler(deps: HandleMessageDeps) { case 'generation_stopped': { setIsGenerating(false); setIsGenerationPaused(true); + setIsDebugging(false); // Reset phase indicators setIsPhaseProgressActive(false); diff --git a/src/routes/chat/utils/message-helpers.ts b/src/routes/chat/utils/message-helpers.ts index 7c0e461b..eddba260 100644 --- a/src/routes/chat/utils/message-helpers.ts +++ b/src/routes/chat/utils/message-helpers.ts @@ -28,7 +28,6 @@ export function isConversationalMessage(messageId: string): boolean { 'conversation_response', 'fetching-chat', 'chat-not-found', - 'resuming-chat', 'chat-welcome', 'deployment-status', 'code_reviewed', diff --git a/src/routes/chat/utils/project-stage-helpers.ts b/src/routes/chat/utils/project-stage-helpers.ts index 5d6172e9..94becd8c 100644 --- a/src/routes/chat/utils/project-stage-helpers.ts +++ b/src/routes/chat/utils/project-stage-helpers.ts @@ -1,5 +1,5 @@ export interface ProjectStage { - id: 'bootstrap' | 'blueprint' | 'code' | 'validate' | 'fix'; + id: 'bootstrap' | 'blueprint' | 'code'; title: string; status: 'pending' | 'active' | 'completed' | 'error'; metadata?: string; @@ -17,8 +17,6 @@ export const initialStages: ProjectStage[] = [ status: 'pending', }, { id: 'code', title: 'Generating code', status: 'pending' }, - { id: 'validate', title: 'Reviewing & fixing code', status: 'pending' }, - { id: 'fix', title: 'Fixing issues', status: 'pending' }, ]; /** diff --git a/worker/agents/assistants/codeDebugger.ts b/worker/agents/assistants/codeDebugger.ts index 16e176ed..bb88879d 100644 --- a/worker/agents/assistants/codeDebugger.ts +++ b/worker/agents/assistants/codeDebugger.ts @@ -18,11 +18,12 @@ import { IdGenerator } from '../utils/idGenerator'; import { PROMPT_UTILS } from '../prompts'; import { RuntimeError } from 'worker/services/sandbox/sandboxTypes'; import { FileState } from '../core/state'; +import { InferError } from '../inferutils/core'; -const SYSTEM_PROMPT = `You are an elite autonomous code debugging specialist with deep expertise in root-cause analysis, modern web frameworks (React, Next.js, Vite), TypeScript/JavaScript, build tools, and runtime environments. +const SYSTEM_PROMPT = `You are an elite autonomous code debugging specialist with deep expertise in root-cause analysis, modern web frameworks (React, Vite, Cloudflare Workers), TypeScript/JavaScript, build tools, and runtime environments. ## CRITICAL: Communication Mode -**You are configured with HIGH reasoning capability. Use it.** +**You are configured with EXTREMELY HIGH reasoning capability. Use it.** - Conduct ALL analysis, planning, and reasoning INTERNALLY - Output should be CONCISE: brief status updates and tool calls only - NO verbose explanations, step-by-step narrations, or lengthy thought processes in output @@ -61,7 +62,7 @@ You are working on a **Cloudflare Workers** project (optionally with Durable Obj 5. read_files to confirm bug exists in code before fixing ## Your Approach -You are methodical and evidence-based. You choose your own path to solve issues, but always verify fixes work before claiming success. +You are smart, methodical, focused and evidence-based. You choose your own path to solve issues, but always verify fixes work before claiming success. **CRITICAL - Internal Reasoning:** - You have advanced reasoning capabilities - USE THEM @@ -89,18 +90,19 @@ You are methodical and evidence-based. You choose your own path to solve issues, - **get_logs**: Cumulative logs (verbose, user-driven). **Use sparingly** - only when runtime errors lack detail. Set reset=true to clear stale logs. - **read_files**: Read file contents by RELATIVE paths (batch multiple in one call for efficiency) - **exec_commands**: Execute shell commands from project root (no cd needed) -- **regenerate_file**: Autonomous surgical code fixer for existing files - see detailed guide below +- **regenerate_file**: Autonomous surgical code fixer for existing files - see detailed guide below. **Files are automatically staged after regeneration.** - **generate_files**: Generate new files or rewrite broken files using phase implementation - see detailed guide below - **deploy_preview**: Deploy to Cloudflare Workers preview environment to verify fixes - **wait**: Sleep for N seconds (use after deploy to allow time for user interaction before checking logs) +- **git**: Execute git commands (commit, log, show, reset) - see detailed guide below. **WARNING: reset is UNTESTED - use with extreme caution!** ## How to Use regenerate_file (CRITICAL) **What it is:** - An autonomous AI agent that applies surgical fixes to code files - Makes minimal, targeted changes to fix specific issues -- Returns a diff showing exactly what changed -- Makes multiple passes (up to 5) to ensure issues are fixed +- Returns a diff showing exactly what changed - always verify the diff matches your expectations before claiming success +- Makes multiple passes (up to 3) to ensure issues are fixed - Uses intelligent SEARCH-REPLACE pattern matching internally **Parameters:** @@ -120,6 +122,8 @@ regenerate_file({ - **ONE PROBLEM PER ISSUE**: Don't combine multiple unrelated problems - **PROVIDE CONTEXT**: Explain what's broken and why it's a problem - **USE CONCRETE DETAILS**: Not "fix the bug" but "Fix TypeError: Cannot read property 'items' of undefined on line 45" +- If things don't work, just directly try to share the expected diff you want to apply. +- If it fails repeatedly, use the generate_files tool to generate the file from scratch with explicit instructions. **Good Examples:** \`\`\`javascript @@ -173,6 +177,7 @@ regenerate_file({ path: "src/App.tsx", issues: ["Fix error B"] }) 3. **DON'T REGENERATE AGAIN** if the diff shows the fix was already applied 4. **DEPLOY** the changes to the sandbox 5. **RUN run_analysis, get_runtime_errors or get_logs** after fixes to verify no new errors were introduced. You might have to wait for some time, and prompt the user appropriately for the logs to appear. +6. **COMMIT** the changes to the sandbox. Changes made using **generate_files** are automatically commited, but changes made by **regenerate_file** are only staged and need to be committed manually. **CRITICAL: Without deploying the changes to the sandbox, the fixes will not take effect and run_analysis, get_runtime_errors or get_logs may show stale results** @@ -190,7 +195,7 @@ regenerate_file({ path: "src/App.tsx", issues: ["Fix error B"] }) - ❌ Configuration issues that need different tools - ❌ When you haven't read the file yet (read it first!) - ❌ When the same issue has already been fixed (check diff!) -- ❌ When file is too broken to patch (use generate_files to rewrite) +- ❌ When file is too broken to patch or regenerate_file fails repeatedly (use generate_files to rewrite) ## How to Use generate_files (For New/Broken Files) @@ -200,6 +205,8 @@ regenerate_file({ path: "src/App.tsx", issues: ["Fix error B"] }) - Automatically determines file contents based on requirements - Deploys changes to sandbox - Returns diffs for all generated files +- Heavier and costlier than regenerate_file +- Automatically commits the changes **When to use generate_files:** - ✅ File doesn't exist yet (need to create it) @@ -208,7 +215,7 @@ regenerate_file({ path: "src/App.tsx", issues: ["Fix error B"] }) - ✅ Scaffolding new components/utilities/API routes **When NOT to use generate_files:** -- ❌ Use regenerate_file first for existing files with fixable issues (it's faster and more surgical) +- ❌ Use regenerate_file first for existing files with fixable issues unless regenerate_file fails repeatedly (it's faster and more surgical) - ❌ Don't use for simple fixes - regenerate_file is better **Parameters:** @@ -258,6 +265,75 @@ generate_files({ 3. For new files that don't exist → use generate_files directly 4. Review the diffs returned - they show exactly what was generated +## How to Use git (Saving Your Work) +- There is a persistent git repository for the codebase (NOT in the sandbox). You can access it using the git tool. +- Files modified by regenerate_file are automatically **staged** (ready to commit) +- Use git commands to save, review history + +**Available Commands:** + +**1. commit - Save staged changes** +\`\`\`typescript +git({ command: 'commit', message: 'fix: resolve authentication bug in login flow' }) +// Returns: { success: true, data: { oid: "abc123..." }, message: "Committed: ..." } +\`\`\` +- **Use after**: Fixing multiple files with regenerate_file +- **When**: You've verified the fixes work (run_analysis passed, errors resolved) +- **Message format**: Use conventional commits (fix:, feat:, refactor:, etc.) + +**2. log - View commit history** +\`\`\`typescript +git({ command: 'log', limit: 10 }) +// Returns: { success: true, data: { commits: [...] }, message: "Retrieved X commits" } +\`\`\` +- **Use for**: Reviewing what changes were made previously +- **Helpful when**: Understanding fix history or investigating regressions + +**3. show - View commit details** +\`\`\`typescript +git({ command: 'show', oid: 'abc123...' }) +// Returns: { success: true, data: { oid, files: N, fileList: [...] } } +\`\`\` +- **Use for**: Inspecting what files changed in a specific commit + +**4. reset - Move HEAD to a previous commit (hard reset)** +\`\`\`typescript +git({ command: 'reset', oid: 'abc123...' }) +// Returns: { success: true, data: { filesReset: N }, message: "Reset to commit..." } +\`\`\` +- **⚠️ CRITICAL WARNING**: This feature is **UNTESTED** and **DESTRUCTIVE** +- **ONLY use when**: + - User explicitly asks you to reset to a previous commit + - You've tried everything else and need to undo multiple bad commits + - You're absolutely certain this is necessary +- **Before using**: WARN the user that you're about to reset and explain what will be lost +- **Effect**: Moves HEAD back to specified commit, deletes all commits after it +- **Note**: This is like "git reset --hard" - cannot be easily undone +- **Prefer alternatives**: Try regenerate_file or generate_files first + +**Best Practices:** +- **Use descriptive messages**: "fix: resolve null pointer in auth.ts" not "fix bug" +- **Commit before deploying**: Save your work before deploy_preview in case you need to revert +- **Commit when TASK_COMPLETE**: Always commit your final working state before finishing + +**Example Workflow:** +\`\`\`typescript +// 1. Fix files +regenerate_file({ path: "src/auth.ts", issues: ["null pointer present at ..."] }) +regenerate_file({ path: "src/utils.ts", issues: ["..." ] }) + +// 2. Verify fixes +run_analysis() // Check for errors + +// 3. Commit the fixes +git({ command: 'commit', message: 'fix: resolve null pointer and add missing export' }) + +// 4. Deploy and test +deploy_preview({ clearLogs: true }) +\`\`\` + +**Note:** git is NOT connected to the sandbox. It is a separate repository. Do not run git commands via execCommand tool. + ## File Path Rules (CRITICAL) - All paths are RELATIVE to project root (sandbox pwd = project directory) - Commands execute from project root automatically @@ -353,14 +429,18 @@ You're done when: - Fixes applied (file paths) - Verification results - Current state +3. **CRITICAL: Once you write "TASK_COMPLETE", IMMEDIATELY HALT. Do NOT make any more tool calls. Your work is done.** -**If stuck:** "TASK_STUCK: [reason]" + what you tried +**If stuck:** +1. State: "TASK_STUCK: [reason]" + what you tried +2. **CRITICAL: Once you write "TASK_STUCK", IMMEDIATELY HALT. Do NOT make any more tool calls. Stop immediately.** ## Working Style - Use your internal reasoning - think deeply, output concisely - Be decisive - analyze internally, act externally - No play-by-play narration - just execute - Quality through internal reasoning, not verbose output +- Always be focused on the task you were given. Don't stray into trying to fix minor issues that user didn't ask you to fix. You may suggest the user to ask about if they want them fixed, but you are only supposed to fix the issues you were originally asked to fix. - Beware: the app is running in a sandbox environment, and any changes made to it directly (e.g., via exec_commands without shouldSave=true) would be lost when the sandbox is destroyed and not persist in the app's storage. @@ -523,7 +603,7 @@ export class DeepCodeDebugger extends Assistant { You just attempted to execute "${toolName}" with identical arguments for the ${this.loopDetection.repetitionWarnings}th time. RECOMMENDED ACTIONS: -1. If your task is complete, state "TASK_COMPLETE: [summary]" and STOP +1. If your task is complete, state "TASK_COMPLETE: [summary]" and STOP. Once you write 'TASK_COMPLETE' or 'TASK_STUCK', You shall not make any more tool/function calls. 2. If not complete, try a DIFFERENT approach: - Use different tools - Use different arguments @@ -586,19 +666,30 @@ If you're genuinely stuck after trying 3 different approaches, honestly report: }, })); - const result = await executeInference({ - env: this.env, - context: this.inferenceContext, - agentActionName: 'deepDebugger', - modelConfig: this.modelConfigOverride || AGENT_CONFIG.deepDebugger, - messages, - tools, - stream: streamCb - ? { chunk_size: 64, onChunk: (c) => streamCb(c) } - : undefined, - }); - - const out = result?.string || ''; + let out = ''; + + try { + const result = await executeInference({ + env: this.env, + context: this.inferenceContext, + agentActionName: 'deepDebugger', + modelConfig: this.modelConfigOverride || AGENT_CONFIG.deepDebugger, + messages, + tools, + stream: streamCb + ? { chunk_size: 64, onChunk: (c) => streamCb(c) } + : undefined, + }); + out = result?.string || ''; + } catch (e) { + // If error is an infererror, use the partial response transcript + if (e instanceof InferError) { + out = e.partialResponseTranscript(); + logger.info('Partial response transcript', { transcript: out }); + } else { + throw e; + } + } // Check for completion signals to prevent unnecessary continuation if (out.includes('TASK_COMPLETE') || out.includes('Mission accomplished') || out.includes('TASK_STUCK')) { diff --git a/worker/agents/constants.ts b/worker/agents/constants.ts index 58d0644c..907f3a84 100644 --- a/worker/agents/constants.ts +++ b/worker/agents/constants.ts @@ -1,4 +1,5 @@ import { WebSocketMessageType } from "../api/websocketTypes"; +import { AgentActionKey } from "./inferutils/config.types"; export const WebSocketMessageResponses: Record = { GENERATION_STARTED: 'generation_started', @@ -80,7 +81,6 @@ export const WebSocketMessageResponses: Record = { export const WebSocketMessageRequests = { GENERATE_ALL: 'generate_all', GENERATE: 'generate', - CODE_REVIEW: 'code_review', DEPLOY: 'deploy', PREVIEW: 'preview', OVERWRITE: 'overwrite', @@ -109,4 +109,12 @@ export const WebSocketMessageRequests = { export const PREVIEW_EXPIRED_ERROR = 'Preview expired, attempting redeploy. Please try again after a minute or refresh the page'; export const MAX_DEPLOYMENT_RETRIES = 5; export const MAX_LLM_MESSAGES = 200; -export const MAX_TOOL_CALLING_DEPTH = 7; \ No newline at end of file +export const MAX_TOOL_CALLING_DEPTH_DEFAULT = 7; +export const getMaxToolCallingDepth = (agentActionKey: AgentActionKey | 'testModelConfig') => { + switch (agentActionKey) { + case 'deepDebugger': + return 100; + default: + return MAX_TOOL_CALLING_DEPTH_DEFAULT; + } +} \ No newline at end of file diff --git a/worker/agents/core/simpleGeneratorAgent.ts b/worker/agents/core/simpleGeneratorAgent.ts index 3258128b..4a2333cb 100644 --- a/worker/agents/core/simpleGeneratorAgent.ts +++ b/worker/agents/core/simpleGeneratorAgent.ts @@ -24,7 +24,6 @@ import { DeploymentManager } from '../services/implementations/DeploymentManager import { GenerationContext } from '../domain/values/GenerationContext'; import { IssueReport } from '../domain/values/IssueReport'; import { PhaseImplementationOperation } from '../operations/PhaseImplementation'; -import { CodeReviewOperation } from '../operations/CodeReview'; import { FileRegenerationOperation } from '../operations/FileRegeneration'; import { PhaseGenerationOperation } from '../operations/PhaseGeneration'; import { ScreenshotAnalysisOperation } from '../operations/ScreenshotAnalysis'; @@ -52,9 +51,9 @@ import { DeepDebugResult } from './types'; import { StateMigration } from './stateMigration'; import { generateNanoId } from 'worker/utils/idGenerator'; import { updatePackageJson } from '../utils/packageSyncer'; +import { IdGenerator } from '../utils/idGenerator'; interface Operations { - codeReview: CodeReviewOperation; regenerateFile: FileRegenerationOperation; generateNextPhase: PhaseGenerationOperation; analyzeScreenshot: ScreenshotAnalysisOperation; @@ -92,7 +91,9 @@ export class SimpleCodeGeneratorAgent extends Agent { // In-memory storage for user-uploaded images (not persisted in DO state) private pendingUserImages: ProcessedImageAttachment[] = [] private generationPromise: Promise | null = null; + private currentAbortController?: AbortController; private deepDebugPromise: Promise<{ transcript: string } | { error: string }> | null = null; + private deepDebugConversationId: string | null = null; // GitHub token cache (ephemeral, lost on DO eviction) private githubTokenCache: { @@ -101,10 +102,8 @@ export class SimpleCodeGeneratorAgent extends Agent { expiresAt: number; } | null = null; - private currentAbortController?: AbortController; protected operations: Operations = { - codeReview: new CodeReviewOperation(), regenerateFile: new FileRegenerationOperation(), generateNextPhase: new PhaseGenerationOperation(), analyzeScreenshot: new ScreenshotAnalysisOperation(), @@ -512,6 +511,22 @@ export class SimpleCodeGeneratorAgent extends Agent { if (runningHistory.length === 0) { runningHistory = currentConversation; } + + // Remove duplicates + const deduplicateMessages = (messages: ConversationMessage[]): ConversationMessage[] => { + const seen = new Set(); + return messages.filter(msg => { + if (seen.has(msg.conversationId)) { + return false; + } + seen.add(msg.conversationId); + return true; + }); + }; + + runningHistory = deduplicateMessages(runningHistory); + fullHistory = deduplicateMessages(fullHistory); + return { id: id, runningHistory, @@ -531,7 +546,32 @@ export class SimpleCodeGeneratorAgent extends Agent { } } - async saveToDatabase() { + addConversationMessage(message: ConversationMessage) { + const conversationState = this.getConversationState(); + if (!conversationState.runningHistory.find(msg => msg.conversationId === message.conversationId)) { + conversationState.runningHistory.push(message); + } else { + conversationState.runningHistory = conversationState.runningHistory.map(msg => { + if (msg.conversationId === message.conversationId) { + return message; + } + return msg; + }); + } + if (!conversationState.fullHistory.find(msg => msg.conversationId === message.conversationId)) { + conversationState.fullHistory.push(message); + } else { + conversationState.fullHistory = conversationState.fullHistory.map(msg => { + if (msg.conversationId === message.conversationId) { + return message; + } + return msg; + }); + } + this.setConversationState(conversationState); + } + + private async saveToDatabase() { this.logger().info(`Blueprint generated successfully for agent ${this.getAgentId()}`); // Save the app to database (authenticated users only) const appService = new AppService(this.env); @@ -583,6 +623,10 @@ export class SimpleCodeGeneratorAgent extends Agent { return this.deploymentManager.getClient(); } + getGit(): GitVersionControl { + return this.git; + } + isCodeGenerating(): boolean { return this.generationPromise !== null; } @@ -998,12 +1042,10 @@ export class SimpleCodeGeneratorAgent extends Agent { } /** - * Execute review cycle state - run code review and regeneration cycles + * Execute review cycle state - review and cleanup */ async executeReviewCycle(): Promise { - this.logger().info("Executing REVIEWING state"); - - const reviewCycles = 2; + this.logger().info("Executing REVIEWING state - review and cleanup"); if (this.state.reviewingInitiated) { this.logger().info("Reviewing already initiated, skipping"); return CurrentDevState.IDLE; @@ -1012,78 +1054,27 @@ export class SimpleCodeGeneratorAgent extends Agent { ...this.state, reviewingInitiated: true }); - - try { - this.logger().info("Starting code review and improvement cycle..."); - - for (let i = 0; i < reviewCycles; i++) { - // Check if user input came during review - if so, go back to phase generation - if (this.state.pendingUserInputs.length > 0) { - this.logger().info("User input received during review, transitioning back to PHASE_GENERATING"); - return CurrentDevState.PHASE_GENERATING; - } - - this.logger().info(`Starting code review cycle ${i + 1}...`); - - const reviewResult = await this.reviewCode(); - - if (!reviewResult) { - this.logger().warn("Code review failed. Skipping fix cycle."); - break; - } - - const issuesFound = reviewResult.issuesFound; - - if (issuesFound) { - this.logger().info(`Issues found in review cycle ${i + 1}`, { issuesFound }); - const promises = []; - for (const fileToFix of reviewResult.filesToFix) { - if (!fileToFix.require_code_changes) continue; - - const fileToRegenerate = this.fileManager.getGeneratedFile(fileToFix.filePath); - if (!fileToRegenerate) { - this.logger().warn(`File to fix not found in generated files: ${fileToFix.filePath}, skipping`); - continue; - } - - promises.push(this.regenerateFile( - fileToRegenerate, - fileToFix.issues, - 0 - )); - } - - const fileResults = await Promise.allSettled(promises); - const files: FileOutputType[] = fileResults.map(result => result.status === "fulfilled" ? result.value : null).filter((result) => result !== null); - - await this.deployToSandbox(files, false, "fix: Applying code review fixes"); - - // await this.applyDeterministicCodeFixes(); - - this.logger().info("Completed regeneration for review cycle"); - } else { - this.logger().info("Code review found no issues. Review cycles complete."); - break; - } - } - - // Check again for user input before finalizing - if (this.state.pendingUserInputs.length > 0) { - this.logger().info("User input received after review, transitioning back to PHASE_GENERATING"); - return CurrentDevState.PHASE_GENERATING; - } else { - this.logger().info("Review cycles complete, transitioning to IDLE"); - return CurrentDevState.IDLE; - } - - } catch (error) { - this.logger().error("Error during review cycle:", error); - if (error instanceof RateLimitExceededError) { - throw error; + // If issues/errors found, prompt user if they want to review and cleanup + const issues = await this.fetchAllIssues(false); + if (issues.runtimeErrors.length > 0 || issues.staticAnalysis.typecheck.issues.length > 0) { + this.logger().info("Reviewing stage - issues found, prompting user to review and cleanup"); + const message : ConversationMessage = { + role: "assistant", + content: `If the user responds with yes, launch the 'deep_debug' tool with the prompt to fix all the issues in the app\nThere might be some bugs in the app. Do you want me to try to fix them?`, + conversationId: IdGenerator.generateConversationId(), } - return CurrentDevState.IDLE; + // Store the message in the conversation history so user's response can trigger the deep debug tool + this.addConversationMessage(message); + + this.broadcast(WebSocketMessageResponses.CONVERSATION_RESPONSE, { + message: message.content, + conversationId: message.conversationId, + isStreaming: false, + }); } + + return CurrentDevState.IDLE; } /** @@ -1165,8 +1156,8 @@ export class SimpleCodeGeneratorAgent extends Agent { this.logger().error('Deep debugger failed', e); return { success: false as const, error: `Deep debugger failed: ${String(e)}` }; } finally{ - // Clear promise after completion this.deepDebugPromise = null; + this.deepDebugConversationId = null; } })(); @@ -1320,7 +1311,7 @@ export class SimpleCodeGeneratorAgent extends Agent { // Deploy generated files if (finalFiles.length > 0) { - await this.deployToSandbox(finalFiles, false, phase.name); + await this.deployToSandbox(finalFiles, false, phase.name, true); if (postPhaseFixing) { await this.applyDeterministicCodeFixes(); if (this.state.inferenceContext.enableFastSmartCodeFix) { @@ -1424,66 +1415,6 @@ export class SimpleCodeGeneratorAgent extends Agent { } } - /** - * Perform comprehensive code review - * Analyzes for runtime errors, static issues, and best practices - */ - async reviewCode() { - const issues = await this.fetchAllIssues(true); - const issueReport = IssueReport.from(issues); - - // Report discovered issues - this.broadcast(WebSocketMessageResponses.CODE_REVIEWING, { - message: "Running code review...", - staticAnalysis: issues.staticAnalysis, - runtimeErrors: issues.runtimeErrors - }); - - const reviewResult = await this.operations.codeReview.execute( - {issues: issueReport}, - this.getOperationOptions() - ); - - // Execute commands if any - if (reviewResult.commands && reviewResult.commands.length > 0) { - await this.executeCommands(reviewResult.commands); - } - // Notify review completion - this.broadcast(WebSocketMessageResponses.CODE_REVIEWED, { - review: reviewResult, - message: "Code review completed" - }); - - return reviewResult; - } - - /** - * Regenerate a file to fix identified issues - * Retries up to 3 times before giving up - */ - async regenerateFile(file: FileOutputType, issues: string[], retryIndex: number = 0) { - this.broadcast(WebSocketMessageResponses.FILE_REGENERATING, { - message: `Regenerating file: ${file.filePath}`, - filePath: file.filePath, - original_issues: issues, - }); - - const result = await this.operations.regenerateFile.execute( - {file, issues, retryIndex}, - this.getOperationOptions() - ); - - const fileState = await this.fileManager.saveGeneratedFile(result, `fix: ${file.filePath}`); - - this.broadcast(WebSocketMessageResponses.FILE_REGENERATED, { - message: `Regenerated file: ${file.filePath}`, - file: fileState, - original_issues: issues, - }); - - return fileState; - } - getTotalFiles(): number { return this.fileManager.getGeneratedFilePaths().length + ((this.state.currentPhase || this.state.blueprint.initialPhase)?.files?.length || 0); } @@ -1775,6 +1706,33 @@ export class SimpleCodeGeneratorAgent extends Agent { return result; } + /** + * Regenerate a file to fix identified issues + * Retries up to 3 times before giving up + */ + async regenerateFile(file: FileOutputType, issues: string[], retryIndex: number = 0) { + this.broadcast(WebSocketMessageResponses.FILE_REGENERATING, { + message: `Regenerating file: ${file.filePath}`, + filePath: file.filePath, + original_issues: issues, + }); + + const result = await this.operations.regenerateFile.execute( + {file, issues, retryIndex}, + this.getOperationOptions() + ); + + const fileState = await this.fileManager.saveGeneratedFile(result); + + this.broadcast(WebSocketMessageResponses.FILE_REGENERATED, { + message: `Regenerated file: ${file.filePath}`, + file: fileState, + original_issues: issues, + }); + + return fileState; + } + async regenerateFileByPath(path: string, issues: string[]): Promise<{ path: string; diff: string }> { const { sandboxInstanceId } = this.state; if (!sandboxInstanceId) { @@ -1784,7 +1742,7 @@ export class SimpleCodeGeneratorAgent extends Agent { let fileContents = ''; let filePurpose = ''; try { - const fmFile = this.fileManager.getGeneratedFile(path); + const fmFile = this.fileManager.getFile(path); if (fmFile) { fileContents = fmFile.fileContents; filePurpose = fmFile.filePurpose || ''; @@ -1956,6 +1914,13 @@ export class SimpleCodeGeneratorAgent extends Agent { isDeepDebugging(): boolean { return this.deepDebugPromise !== null; } + + getDeepDebugSessionState(): { conversationId: string } | null { + if (this.deepDebugConversationId && this.deepDebugPromise) { + return { conversationId: this.deepDebugConversationId }; + } + return null; + } async waitForDeepDebug(): Promise { if (this.deepDebugPromise) { @@ -2263,7 +2228,6 @@ export class SimpleCodeGeneratorAgent extends Agent { this.logger().info('Successfully synced package.json to git', { filePath: fileState.filePath, - hash: fileState.lasthash }); // Broadcast update to clients @@ -2495,6 +2459,11 @@ export class SimpleCodeGeneratorAgent extends Agent { isStreaming: boolean, tool?: { name: string; status: 'start' | 'success' | 'error'; args?: Record } ) => { + // Track conversationId when deep_debug starts + if (tool?.name === 'deep_debug' && tool.status === 'start') { + this.deepDebugConversationId = conversationId; + } + this.broadcast(WebSocketMessageResponses.CONVERSATION_RESPONSE, { message, conversationId, diff --git a/worker/agents/core/state.ts b/worker/agents/core/state.ts index 8abfe96b..2840747b 100644 --- a/worker/agents/core/state.ts +++ b/worker/agents/core/state.ts @@ -6,9 +6,6 @@ import type { ConversationMessage } from '../inferutils/common'; import type { InferenceContext } from '../inferutils/config.types'; export interface FileState extends FileOutputType { - lasthash: string; - lastmodified: number; - unmerged: string[]; lastDiff: string; } @@ -22,7 +19,6 @@ export enum CurrentDevState { PHASE_GENERATING, PHASE_IMPLEMENTING, REVIEWING, - FILE_REGENERATING, FINALIZING, } @@ -32,7 +28,7 @@ export interface CodeGenState { blueprint: Blueprint; projectName: string, query: string; - generatedFilesMap: Record; + generatedFilesMap: Record; generatedPhases: PhaseState[]; commandsHistory?: string[]; // History of commands run lastPackageJson?: string; // Last package.json file contents diff --git a/worker/agents/core/stateMigration.ts b/worker/agents/core/stateMigration.ts index fe41b338..b8228872 100644 --- a/worker/agents/core/stateMigration.ts +++ b/worker/agents/core/stateMigration.ts @@ -30,9 +30,6 @@ export class StateMigration { migratedFilesMap[key] = { ...migratedFile, - lasthash: migratedFile.lasthash || '', - lastmodified: migratedFile.lastmodified || Date.now(), - unmerged: migratedFile.unmerged || [] }; if (migratedFile !== file) { diff --git a/worker/agents/core/websocket.ts b/worker/agents/core/websocket.ts index 78358c6d..4428f690 100644 --- a/worker/agents/core/websocket.ts +++ b/worker/agents/core/websocket.ts @@ -45,45 +45,6 @@ export function handleWebSocketMessage(agent: SimpleCodeGeneratorAgent, connecti } }); break; - case WebSocketMessageRequests.CODE_REVIEW: - if (agent.isCodeGenerating()) { - sendError(connection, 'Cannot perform code review while generating files'); - return; - } - sendToConnection(connection, WebSocketMessageResponses.CODE_REVIEW, { - message: 'Starting code review' - }); - agent.reviewCode().then(reviewResult => { - if (!reviewResult) { - sendError(connection, 'Failed to perform code review'); - return; - } - sendToConnection(connection, WebSocketMessageResponses.CODE_REVIEW, { - review: reviewResult, - issuesFound: reviewResult.issuesFound, - }); - if (reviewResult.issuesFound && parsedMessage.autoFix === true) { - for (const fileToFix of reviewResult.filesToFix) { - const fileToRegenerate = agent.state.generatedFilesMap[fileToFix.filePath]; - if (!fileToRegenerate) { - logger.warn(`File to fix not found in generated files: ${fileToFix.filePath}`); - continue; - } - agent.regenerateFile( - fileToRegenerate, - fileToFix.issues, - 0 - ).catch((error: unknown) => { - logger.error(`Error regenerating file ${fileToRegenerate.filePath}:`, error); - sendError(connection, `Error regenerating file: ${error instanceof Error ? error.message : String(error)}`); - }); - } - } - }).catch(error => { - logger.error('Error during code review:', error); - sendError(connection, `Error during code review: ${error instanceof Error ? error.message : String(error)}`); - }); - break; case WebSocketMessageRequests.DEPLOY: agent.deployToCloudflare().then((deploymentResult) => { if (!deploymentResult) { @@ -216,8 +177,12 @@ export function handleWebSocketMessage(agent: SimpleCodeGeneratorAgent, connecti case WebSocketMessageRequests.GET_CONVERSATION_STATE: try { const state = agent.getConversationState(); + const debugState = agent.getDeepDebugSessionState(); logger.info('Conversation state retrieved', state); - sendToConnection(connection, WebSocketMessageResponses.CONVERSATION_STATE, { state }); + sendToConnection(connection, WebSocketMessageResponses.CONVERSATION_STATE, { + state, + deepDebugSession: debugState + }); } catch (error) { logger.error('Error fetching conversation state:', error); sendError(connection, `Error fetching conversation state: ${error instanceof Error ? error.message : String(error)}`); diff --git a/worker/agents/git/git.ts b/worker/agents/git/git.ts index 9f2dcdfd..db082e3a 100644 --- a/worker/agents/git/git.ts +++ b/worker/agents/git/git.ts @@ -16,6 +16,7 @@ export interface CommitInfo { type FileSnapshot = Omit; export class GitVersionControl { + private onFilesChangedCallback?: () => void; public fs: SqliteFS; private author: { name: string; email: string }; @@ -27,6 +28,20 @@ export class GitVersionControl { this.fs.init(); } + setOnFilesChangedCallback(callback: () => void): void { + this.onFilesChangedCallback = callback; + } + + async getAllFilesFromHead(): Promise> { + try { + const oid = await git.resolveRef({ fs: this.fs, dir: '/', ref: 'HEAD' }); + const files = await this.readFilesFromCommit(oid); + return files; + } catch (error) { + return []; + } + } + async init(): Promise { // Initialize git repository (isomorphic-git init is idempotent - safe to call multiple times) try { @@ -41,10 +56,17 @@ export class GitVersionControl { } } - async commit(files: FileSnapshot[], message?: string): Promise { - if (!files.length) throw new Error('Cannot create empty commit'); + /** + * Stage files without committing them + * Useful for batching multiple operations before a single commit + */ + async stage(files: FileSnapshot[]): Promise { + if (!files.length) { + console.log('[Git] No files to stage'); + return; + } - console.log(`[Git] Starting commit with ${files.length} files`); + console.log(`[Git] Staging ${files.length} files`); // Normalize paths (remove leading slashes for git) const normalizedFiles = files.map(f => ({ @@ -52,13 +74,16 @@ export class GitVersionControl { content: f.fileContents })); - // Write and stage files first + // Write and stage files for (let i = 0; i < normalizedFiles.length; i++) { const file = normalizedFiles[i]; try { - console.log(`[Git] Processing file ${i + 1}/${normalizedFiles.length}: ${file.path}`); + console.log(`[Git] Staging file ${i + 1}/${normalizedFiles.length}: ${file.path}`); + + // Write file to filesystem await this.fs.writeFile(file.path, file.content); + // Stage file using git.add await git.add({ fs: this.fs, dir: '/', @@ -73,6 +98,16 @@ export class GitVersionControl { } } + console.log(`[Git] Successfully staged ${files.length} files`); + } + + async commit(files: FileSnapshot[], message?: string): Promise { + console.log(`[Git] Starting commit with ${files.length} files`); + if (files.length) { + // Stage all files first + await this.stage(files); + } + console.log('[Git] All files written and staged, checking for changes...'); // Check if there are actual changes (compare staged vs HEAD) @@ -123,13 +158,44 @@ export class GitVersionControl { } } - async checkout(oid: string): Promise { + private async readFilesFromCommit(oid: string): Promise { const { commit } = await git.readCommit({ fs: this.fs, dir: '/', oid }); const files: FileSnapshot[] = []; await this.walkTree(commit.tree, '', files); return files; } + async show(oid: string): Promise<{ oid: string; message: string; author: string; timestamp: string; files: number; fileList: string[] }> { + const { commit } = await git.readCommit({ fs: this.fs, dir: '/', oid }); + const files = await git.listFiles({ fs: this.fs, dir: '/', ref: oid }); + + return { + oid, + message: commit.message, + author: `${commit.author.name} <${commit.author.email}>`, + timestamp: new Date(commit.author.timestamp * 1000).toISOString(), + files: files.length, + fileList: files + }; + } + + async reset(ref: string, options?: { hard?: boolean }): Promise<{ ref: string; filesReset: number }> { + // Update HEAD to point to the specified ref + const oid = await git.resolveRef({ fs: this.fs, dir: '/', ref }); + await git.writeRef({ fs: this.fs, dir: '/', ref: 'HEAD', value: oid, force: true }); + + // If hard reset, also update working directory + if (options?.hard !== false) { + await git.checkout({ fs: this.fs, dir: '/', ref, force: true }); + } + + const files = await git.listFiles({ fs: this.fs, dir: '/', ref }); + + this.onFilesChangedCallback?.(); + + return { ref, filesReset: files.length }; + } + private async walkTree(treeOid: string, prefix: string, files: FileSnapshot[]): Promise { const { tree } = await git.readTree({ fs: this.fs, dir: '/', oid: treeOid }); diff --git a/worker/agents/inferutils/core.ts b/worker/agents/inferutils/core.ts index ba4a2194..7ad6991a 100644 --- a/worker/agents/inferutils/core.ts +++ b/worker/agents/inferutils/core.ts @@ -22,7 +22,7 @@ import { getUserConfigurableSettings } from '../../config'; import { SecurityError, RateLimitExceededError } from 'shared/types/errors'; import { executeToolWithDefinition } from '../tools/customTools'; import { RateLimitType } from 'worker/services/rate-limit/config'; -import { MAX_LLM_MESSAGES, MAX_TOOL_CALLING_DEPTH } from '../constants'; +import { getMaxToolCallingDepth, MAX_LLM_MESSAGES } from '../constants'; function optimizeInputs(messages: Message[]): Message[] { return messages.map((message) => ({ @@ -331,14 +331,60 @@ type InferWithCustomFormatArgs = InferArgsStructured & { format?: SchemaFormat; formatOptions?: FormatterOptions; }; + +export interface ToolCallContext { + messages: Message[]; + depth: number; +} + +export function serializeCallChain(context: ToolCallContext, finalResponse: string): string { + // Build a transcript of the tool call messages, and append the final response + let transcript = '**Request terminated by user, partial response transcript (last 5 messages):**\n\n'; + for (const message of context.messages.slice(-5)) { + let content = message.content; + + // Truncate tool messages to 100 chars + if (message.role === 'tool' || message.role === 'function') { + content = (content || '').slice(0, 100); + } + + transcript += `${content}`; + } + transcript += `${finalResponse || '**cancelled**'}`; + transcript += ''; + return transcript; +} + export class InferError extends Error { constructor( message: string, - public partialResponse?: string, + public response: string, + public toolCallContext?: ToolCallContext ) { super(message); this.name = 'InferError'; } + + partialResponseTranscript(): string { + if (!this.toolCallContext) { + return this.response; + } + return serializeCallChain(this.toolCallContext, this.response); + } + + partialResponse(): InferResponseString { + return { + string: this.response, + toolCallContext: this.toolCallContext + }; + } +} + +export class AbortError extends InferError { + constructor(response: string, toolCallContext?: ToolCallContext) { + super(response, response, toolCallContext); + this.name = 'AbortError'; + } } const claude_thinking_budget_tokens = { @@ -392,11 +438,6 @@ async function executeToolCalls(openAiToolCalls: ChatCompletionMessageFunctionTo ); } -export interface ToolCallContext { - messages: Message[]; - depth: number; -} - export function infer( args: InferArgsStructured, toolCallContext?: ToolCallContext, @@ -442,11 +483,11 @@ export async function infer({ // Check tool calling depth to prevent infinite recursion const currentDepth = toolCallContext?.depth ?? 0; - if (currentDepth >= MAX_TOOL_CALLING_DEPTH && actionKey !== 'deepDebugger') { - console.warn(`Tool calling depth limit reached (${currentDepth}/${MAX_TOOL_CALLING_DEPTH}). Stopping recursion.`); + if (currentDepth >= getMaxToolCallingDepth(actionKey)) { + console.warn(`Tool calling depth limit reached (${currentDepth}/${getMaxToolCallingDepth(actionKey)}). Stopping recursion.`); // Return a response indicating max depth reached if (schema) { - throw new Error(`Maximum tool calling depth (${MAX_TOOL_CALLING_DEPTH}) exceeded. Tools may be calling each other recursively.`); + throw new AbortError(`Maximum tool calling depth (${getMaxToolCallingDepth(actionKey)}) exceeded. Tools may be calling each other recursively.`, toolCallContext); } return { string: `[System: Maximum tool calling depth reached.]`, @@ -570,7 +611,7 @@ export async function infer({ // Check if error is due to abort if (error instanceof Error && (error.name === 'AbortError' || error.message?.includes('aborted') || error.message?.includes('abort'))) { console.log('Inference cancelled by user'); - throw new InferError('Inference cancelled by user', ''); + throw new AbortError('**User cancelled inference**', toolCallContext); } console.error(`Failed to get inference response from OpenAI: ${error}`); @@ -710,7 +751,7 @@ export async function infer({ }; const executedCallsWithResults = executedToolCalls.filter(result => result.result); - console.log(`Tool calling depth: ${newDepth}/${MAX_TOOL_CALLING_DEPTH}`); + console.log(`${actionKey}: Tool calling depth: ${newDepth}/${getMaxToolCallingDepth(actionKey)}`); if (executedCallsWithResults.length) { if (schema && schemaName) { @@ -777,7 +818,7 @@ export async function infer({ return { object: result.data, toolCallContext }; } catch (parseError) { console.error('Error parsing response:', parseError); - throw new InferError('Failed to parse response', content); + throw new InferError('Failed to parse response', content, toolCallContext); } } catch (error) { if (error instanceof RateLimitExceededError || error instanceof SecurityError) { diff --git a/worker/agents/inferutils/infer.ts b/worker/agents/inferutils/infer.ts index 001cc9e0..a03530f3 100644 --- a/worker/agents/inferutils/infer.ts +++ b/worker/agents/inferutils/infer.ts @@ -1,4 +1,4 @@ -import { infer, InferError, InferResponseString, InferResponseObject } from './core'; +import { infer, InferError, InferResponseString, InferResponseObject, AbortError } from './core'; import { createAssistantMessage, createUserMessage, Message } from './common'; import z from 'zod'; // import { CodeEnhancementOutput, CodeEnhancementOutputType } from '../codegen/phasewiseGenerator'; @@ -157,10 +157,10 @@ export async function executeInference( { error ); - if (error instanceof InferError) { - // If its an infer error, we can append the partial response to the list of messages and ask a cheaper model to retry the generation - if (error.partialResponse && error.partialResponse.length > 1000) { - messages.push(createAssistantMessage(error.partialResponse)); + if (error instanceof InferError && !(error instanceof AbortError)) { + // If its an infer error and not an abort error, we can append the partial response to the list of messages and ask a cheaper model to retry the generation + if (error.response && error.response.length > 1000) { + messages.push(createAssistantMessage(error.response)); messages.push(createUserMessage(responseRegenerationPrompts)); useCheaperModel = true; } diff --git a/worker/agents/operations/UserConversationProcessor.ts b/worker/agents/operations/UserConversationProcessor.ts index 18d39aa2..9e81d82c 100644 --- a/worker/agents/operations/UserConversationProcessor.ts +++ b/worker/agents/operations/UserConversationProcessor.ts @@ -17,6 +17,7 @@ import { CodeSerializerType } from "../utils/codeSerializers"; import { ConversationState } from "../inferutils/common"; import { downloadR2Image, imagesToBase64, imageToBase64 } from "worker/utils/images"; import { ProcessedImageAttachment } from "worker/types/image-attachment"; +import { AbortError, InferResponseString } from "../inferutils/core"; // Constants const CHUNK_SIZE = 64; @@ -107,7 +108,7 @@ const SYSTEM_PROMPT = `You are Orange, the conversational AI interface for Cloud - Available tools and usage: - queue_request: Queue modification requests for implementation in the next phase(s). Use for any feature/bug/change request. - get_logs: Fetch unread application logs from the sandbox to diagnose runtime issues. - - deep_debug: Autonomous debugging assistant that investigates errors, reads files, runs commands, and applies targeted fixes. Use when users report bugs/errors that need immediate investigation and fixing. This transfers control to a specialized debugging agent. + - deep_debug: Autonomous debugging assistant that investigates errors, reads files, runs commands, and applies targeted fixes. Use when users report bugs/errors that need immediate investigation and fixing. This transfers control to a specialized debugging agent. **LIMIT: You can only call deep_debug ONCE per conversation turn. If you need to debug again, ask the user first.** - wait_for_generation: Wait for code generation to complete. Use when deep_debug returns GENERATION_IN_PROGRESS error. - wait_for_debug: Wait for current debug session to complete. Use when deep_debug returns DEBUG_IN_PROGRESS error. - deploy_preview: Redeploy or restart the preview when the user asks to deploy or the preview is blank/looping. @@ -137,6 +138,8 @@ Use the deep_debug tool to investigate and fix bugs immediately. This synchronou When you call deep_debug, it runs to completion and returns a transcript. The user will see all the debugging steps in real-time. +**IMPORTANT: You can only call deep_debug ONCE per conversation turn.** If you receive a CALL_LIMIT_EXCEEDED error, explain to the user that you've already debugged once this turn and ask if they'd like you to investigate further in a new message. + **CRITICAL - After deep_debug completes:** - **If transcript contains "TASK_COMPLETE" AND runtime errors show "N/A":** - ✅ Acknowledge success: "The debugging session successfully resolved the [specific issue]." @@ -400,22 +403,31 @@ export class UserConversationProcessor extends AgentOperation { - logger.info("Processing user message chunk", { chunkLength: chunk.length, aiConversationId }); - inputs.conversationResponseCallback(chunk, aiConversationId, true); - extractedUserResponse += chunk; - }, - chunk_size: CHUNK_SIZE + let result : InferResponseString; + try { + result = await executeInference({ + env: env, + messages: messagesForInference, + agentActionName: "conversationalResponse", + context: options.inferenceContext, + tools, // Enable tools for the conversational AI + stream: { + onChunk: (chunk) => { + logger.info("Processing user message chunk", { chunkLength: chunk.length, aiConversationId }); + inputs.conversationResponseCallback(chunk, aiConversationId, true); + extractedUserResponse += chunk; + }, + chunk_size: CHUNK_SIZE + } + }); + } catch (error) { + if (error instanceof AbortError) { + logger.info("User message processing aborted", { aiConversationId, partialResponse: error.partialResponse() }); + result = error.partialResponse(); + } else { + throw error; } - }); - + } logger.info("Successfully processed user message", { streamingSuccess: !!extractedUserResponse, diff --git a/worker/agents/schemas.ts b/worker/agents/schemas.ts index d3f18c7c..55344e32 100644 --- a/worker/agents/schemas.ts +++ b/worker/agents/schemas.ts @@ -18,7 +18,7 @@ export const FileOutputSchema = z.object({ export const FileConceptSchema = z.object({ path: z.string().describe('Path to the file relative to the project root. File name should be valid and not contain any special characters apart from hyphen, underscore and dot.'), - purpose: z.string().describe('Very short, Breif, Concise, to the point description, purpose and expected contents of this file including its role in the architecture, data and code flow details'), + purpose: z.string().describe('Very short, Breif, Concise, to the point description, purpose and expected contents of the whole file including its role in the architecture, data and code flow details'), changes: z.string().nullable().describe('Specific, directed changes to be made to the file as instructions, if it\'s not a new file. Don\'t include code.'), }) diff --git a/worker/agents/services/implementations/CodingAgent.ts b/worker/agents/services/implementations/CodingAgent.ts index a2183958..632a0e85 100644 --- a/worker/agents/services/implementations/CodingAgent.ts +++ b/worker/agents/services/implementations/CodingAgent.ts @@ -56,6 +56,10 @@ export class CodingAgentInterface { return this.agentStub.getOperationOptions(); } + getGit() { + return this.agentStub.getGit(); + } + updateProjectName(newName: string): Promise { return this.agentStub.updateProjectName(newName); } diff --git a/worker/agents/services/implementations/FileManager.ts b/worker/agents/services/implementations/FileManager.ts index 2b085b66..fea5878b 100644 --- a/worker/agents/services/implementations/FileManager.ts +++ b/worker/agents/services/implementations/FileManager.ts @@ -16,7 +16,56 @@ export class FileManager implements IFileManager { private stateManager: IStateManager, private getTemplateDetailsFunc: () => TemplateDetails, private git: GitVersionControl - ) {} + ) { + // Register callback with git to auto-sync after operations + this.git.setOnFilesChangedCallback(() => { + this.syncGeneratedFilesMapFromGit(); + }); + } + + /** + * Sync generatedFilesMap from git HEAD + * TODO: Remove in the future by making git fs the single source of truth + */ + private async syncGeneratedFilesMapFromGit(): Promise { + console.log('[FileManager] Auto-syncing generatedFilesMap from git HEAD'); + + try { + // Get all files from HEAD commit + const gitFiles = await this.git.getAllFilesFromHead(); + + // Get old map to preserve purposes + const oldMap = this.stateManager.getState().generatedFilesMap; + + // Build new map, preserving existing purposes + const newMap: Record = {}; + + for (const file of gitFiles) { + const existing = oldMap[file.filePath]; + + newMap[file.filePath] = { + filePath: file.filePath, + fileContents: file.fileContents, + filePurpose: existing?.filePurpose || 'Generated file', + lastDiff: '' + }; + } + + // Update state + this.stateManager.setState({ + ...this.stateManager.getState(), + generatedFilesMap: newMap + }); + + console.log('[FileManager] Sync complete', { + filesCount: Object.keys(newMap).length, + preservedPurposes: Object.values(newMap).filter(f => oldMap[f.filePath]?.filePurpose).length + }); + } catch (error) { + console.error('[FileManager] Failed to sync from git:', error); + // Don't throw - keep existing state as fallback + } + } getGeneratedFile(path: string): FileOutputType | null { const state = this.stateManager.getState(); @@ -38,12 +87,12 @@ export class FileManager implements IFileManager { return FileProcessing.getAllFiles(this.getTemplateDetailsFunc(), state.generatedFilesMap); } - async saveGeneratedFile(file: FileOutputType, commitMessage: string): Promise { + async saveGeneratedFile(file: FileOutputType, commitMessage?: string): Promise { const results = await this.saveGeneratedFiles([file], commitMessage); return results[0]; } - async saveGeneratedFiles(files: FileOutputType[], commitMessage: string): Promise { + async saveGeneratedFiles(files: FileOutputType[], commitMessage?: string): Promise { const filesMap = { ...this.stateManager.getState().generatedFilesMap }; const fileStates: FileState[] = []; @@ -82,9 +131,16 @@ export class FileManager implements IFileManager { try { const shouldCommit = fileStates.length > 0 && fileStates.some(fileState => fileState.lastDiff !== ''); if (shouldCommit) { - console.log(`[FileManager] Committing ${fileStates.length} files:`, commitMessage); - await this.git.commit(fileStates, commitMessage); - console.log(`[FileManager] Commit successful`); + // If commit message is available, commit, else stage + if (commitMessage) { + console.log(`[FileManager] Committing ${fileStates.length} files:`, commitMessage); + await this.git.commit(fileStates, commitMessage); + console.log(`[FileManager] Commit successful`); + } else { + console.log(`[FileManager] Staging ${fileStates.length} files`); + await this.git.stage(fileStates); + console.log(`[FileManager] Stage successful`); + } } } catch (error) { console.error(`[FileManager] Failed to commit files:`, error, commitMessage); diff --git a/worker/agents/services/interfaces/ICodingAgent.ts b/worker/agents/services/interfaces/ICodingAgent.ts index 94d4d1fd..48db5f4d 100644 --- a/worker/agents/services/interfaces/ICodingAgent.ts +++ b/worker/agents/services/interfaces/ICodingAgent.ts @@ -6,10 +6,13 @@ import { OperationOptions } from "worker/agents/operations/common"; import { DeepDebugResult } from "worker/agents/core/types"; import { RenderToolCall } from "worker/agents/operations/UserConversationProcessor"; import { WebSocketMessageType, WebSocketMessageData } from "worker/api/websocketTypes"; +import { GitVersionControl } from "worker/agents/git/git"; export abstract class ICodingAgent { abstract getSandboxServiceClient(): BaseSandboxService; + abstract getGit(): GitVersionControl; + abstract deployToSandbox(files: FileOutputType[], redeploy: boolean, commitMessage?: string, clearLogs?: boolean): Promise; abstract deployToCloudflare(): Promise<{ deploymentUrl?: string; workersUrl?: string } | null>; diff --git a/worker/agents/tools/customTools.ts b/worker/agents/tools/customTools.ts index a478f7f1..19b428a1 100644 --- a/worker/agents/tools/customTools.ts +++ b/worker/agents/tools/customTools.ts @@ -20,6 +20,7 @@ import { createWaitTool } from './toolkit/wait'; import { createGetRuntimeErrorsTool } from './toolkit/get-runtime-errors'; import { createWaitForGenerationTool } from './toolkit/wait-for-generation'; import { createWaitForDebugTool } from './toolkit/wait-for-debug'; +import { createGitTool } from './toolkit/git'; export async function executeToolWithDefinition( toolDef: ToolDefinition, @@ -67,6 +68,7 @@ export function buildDebugTools(session: DebugSession, logger: StructuredLogger, createGenerateFilesTool(session.agent, logger), createDeployPreviewTool(session.agent, logger), createWaitTool(logger), + createGitTool(session.agent, logger), ]; // Attach tool renderer for UI visualization if provided diff --git a/worker/agents/tools/toolkit/deep-debugger.ts b/worker/agents/tools/toolkit/deep-debugger.ts index d479b862..bf15de96 100644 --- a/worker/agents/tools/toolkit/deep-debugger.ts +++ b/worker/agents/tools/toolkit/deep-debugger.ts @@ -12,12 +12,15 @@ export function createDeepDebuggerTool( { issue: string; focus_paths?: string[] }, { transcript: string } | { error: string } > { + // Track calls per conversation turn (resets when buildTools is called again) + let callCount = 0; + return { type: 'function', function: { name: 'deep_debug', description: - 'Autonomous debugging assistant that investigates errors, reads files, and applies fixes. CANNOT run during code generation - will return GENERATION_IN_PROGRESS error if generation is active.', + 'Autonomous debugging assistant that investigates errors, reads files, and applies fixes. CANNOT run during code generation - will return GENERATION_IN_PROGRESS error if generation is active. LIMITED TO ONE CALL PER CONVERSATION TURN.', parameters: { type: 'object', properties: { @@ -28,6 +31,17 @@ export function createDeepDebuggerTool( }, }, implementation: async ({ issue, focus_paths }: { issue: string; focus_paths?: string[] }) => { + // Check if already called in this turn + if (callCount > 0) { + logger.warn('Cannot start debugging: Already called once this turn'); + return { + error: 'CALL_LIMIT_EXCEEDED: You are only allowed to make a single deep_debug call per conversation turn. Ask user for permission before trying again.' + }; + } + + // Increment call counter + callCount++; + // Check if code generation is in progress if (agent.isCodeGenerating()) { logger.warn('Cannot start debugging: Code generation in progress'); diff --git a/worker/agents/tools/toolkit/git.ts b/worker/agents/tools/toolkit/git.ts new file mode 100644 index 00000000..59f34e18 --- /dev/null +++ b/worker/agents/tools/toolkit/git.ts @@ -0,0 +1,133 @@ +import { ToolDefinition } from '../types'; +import { StructuredLogger } from '../../../logger'; +import { CodingAgentInterface } from 'worker/agents/services/implementations/CodingAgent'; + +type GitCommand = 'commit' | 'log' | 'show' | 'reset'; + +interface GitToolArgs { + command: GitCommand; + message?: string; + limit?: number; + oid?: string; +} + +export function createGitTool( + agent: CodingAgentInterface, + logger: StructuredLogger, +): ToolDefinition { + return { + type: 'function', + function: { + name: 'git', + description: + 'Execute git commands. Commands: commit (save staged changes), log (view history), show (view commit details), reset (undo commits - USE WITH EXTREME CAUTION).', + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + enum: ['commit', 'log', 'show', 'reset'], + description: 'Git command to execute' + }, + message: { + type: 'string', + description: 'Commit message (required for commit command, e.g., "fix: resolve authentication bug")' + }, + limit: { + type: 'number', + description: 'Number of commits to show (for log command, default: 10)' + }, + oid: { + type: 'string', + description: 'Commit hash/OID (required for show and reset commands)' + } + }, + required: ['command'], + }, + }, + implementation: async ({ command, message, limit, oid }: GitToolArgs) => { + try { + const gitInstance = agent.getGit(); + + switch (command) { + case 'commit': { + if (!message) { + return { + success: false, + message: 'Commit message is required for commit command' + }; + } + + logger.info('Git commit', { message }); + const commitOid = await gitInstance.commit([], message); + + return { + success: true, + data: { oid: commitOid }, + message: commitOid ? `Committed: ${message}` : 'No changes to commit' + }; + } + + case 'log': { + logger.info('Git log', { limit: limit || 10 }); + const commits = await gitInstance.log(limit || 10); + + return { + success: true, + data: { commits }, + message: `Retrieved ${commits.length} commits` + }; + } + + case 'show': { + if (!oid) { + return { + success: false, + message: 'Commit OID is required for show command' + }; + } + + logger.info('Git show', { oid }); + const result = await gitInstance.show(oid); + + return { + success: true, + data: result, + message: `Commit ${result.oid.substring(0, 7)}: ${result.message} (${result.files} files)` + }; + } + + case 'reset': { + if (!oid) { + return { + success: false, + message: 'Commit OID is required for reset command' + }; + } + + logger.info('Git reset', { oid }); + const result = await gitInstance.reset(oid, { hard: true }); + + return { + success: true, + data: result, + message: `Reset to commit ${result.ref.substring(0, 7)}. ${result.filesReset} files updated. HEAD moved.` + }; + } + + default: + return { + success: false, + message: `Unknown git command: ${command}` + }; + } + } catch (error) { + logger.error('Git command failed', { command, error }); + return { + success: false, + message: `Git ${command} failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + }, + }; +} diff --git a/worker/api/controllers/githubExporter/controller.ts b/worker/api/controllers/githubExporter/controller.ts index cd2cfd1b..ae8057f1 100644 --- a/worker/api/controllers/githubExporter/controller.ts +++ b/worker/api/controllers/githubExporter/controller.ts @@ -48,8 +48,8 @@ export class GitHubExporterController extends BaseController { const { env, agentId, repositoryName, description, isPrivate, token, username, existingRepositoryUrl } = options; try { - let repositoryUrl: string; - let cloneUrl: string; + let repositoryUrl: string | undefined; + let cloneUrl: string | undefined; // Check database for existing repository if not provided let finalExistingRepoUrl = existingRepositoryUrl; @@ -72,17 +72,39 @@ export class GitHubExporterController extends BaseController { // Determine repository details (sync to existing or create new) if (finalExistingRepoUrl) { - this.logger.info('Syncing to existing repository', { agentId, repositoryUrl: finalExistingRepoUrl }); - - repositoryUrl = finalExistingRepoUrl; - - // GitHub clone URL is simply the html_url + .git - cloneUrl = finalExistingRepoUrl.endsWith('.git') - ? finalExistingRepoUrl - : `${finalExistingRepoUrl}.git`; + // Check if repository still exists on GitHub + const exists = await GitHubService.repositoryExists({ + repositoryUrl: finalExistingRepoUrl, + token + }); - this.logger.info('Using existing repository URLs', { repositoryUrl, cloneUrl }); - } else { + if (!exists) { + // Repository doesn't exist - clear from database and create new + this.logger.info('Repository no longer exists, creating new one', { + agentId, + oldUrl: finalExistingRepoUrl, + repositoryName + }); + + try { + const appService = new AppService(env); + await appService.updateGitHubRepository(agentId, '', 'public'); + } catch (clearError) { + this.logger.warn('Failed to clear repository URL', { error: clearError, agentId }); + } + + // Create new repository + finalExistingRepoUrl = undefined; + } else { + // Repository exists, use it + repositoryUrl = finalExistingRepoUrl; + cloneUrl = finalExistingRepoUrl.endsWith('.git') + ? finalExistingRepoUrl + : `${finalExistingRepoUrl}.git`; + } + } + + if (!finalExistingRepoUrl) { this.logger.info('Creating new repository', { agentId, repositoryName }); const createResult = await GitHubService.createUserRepository({ @@ -130,6 +152,14 @@ export class GitHubExporterController extends BaseController { this.logger.info('Repository created', { agentId, repositoryUrl }); } + // Ensure repository URLs are set + if (!repositoryUrl || !cloneUrl) { + return { + success: false, + error: 'Failed to determine repository URLs' + }; + } + // Push files to repository this.logger.info('Pushing files to repository', { agentId, repositoryUrl }); diff --git a/worker/api/websocketTypes.ts b/worker/api/websocketTypes.ts index 76057985..6831ab55 100644 --- a/worker/api/websocketTypes.ts +++ b/worker/api/websocketTypes.ts @@ -25,6 +25,7 @@ type AgentConnectedMessage = { type ConversationStateMessage = { type: 'conversation_state'; state: ConversationState; + deepDebugSession?: { conversationId: string } | null; }; type RateLimitErrorMessage = { diff --git a/worker/services/github/GitHubService.ts b/worker/services/github/GitHubService.ts index 0e7e52a8..d9f695da 100644 --- a/worker/services/github/GitHubService.ts +++ b/worker/services/github/GitHubService.ts @@ -140,6 +140,36 @@ export class GitHubService { } } + /** + * Check if repository exists on GitHub + */ + static async repositoryExists(options: { + repositoryUrl: string; + token: string; + }): Promise { + const repoInfo = GitHubService.extractRepoInfo(options.repositoryUrl); + + if (!repoInfo) { + return false; + } + + try { + const octokit = GitHubService.createOctokit(options.token); + await octokit.repos.get({ + owner: repoInfo.owner, + repo: repoInfo.repo + }); + + return true; + } catch (error) { + GitHubService.logger.error('Repository existence check failed', { + repositoryUrl: options.repositoryUrl, + error: error instanceof Error ? error.message : 'Unknown error' + }); + return false; + } + } + /** * Parse owner and repo name from GitHub URL */