/**
* useAgentLoop — agent loop runner extracted from ChatApp
*
* All consumers should import from ChatApp (re-export facade).
*/
import { useCallback } from "react";
import type Anthropic from "@anthropic-ai/sdk";
import { runAgentLoop } from "../../services/agent-loop.js";
import { AgentEventEmitter, type AgentEvent } from "../../services/agent-events.js";
import { RewindManager, type FileChange } from "../../services/rewind.js";
import { listBackups } from "../../services/file-history.js";
import type { ChatMessage, ToolCall } from "../MessageList.js";
import type { SubagentActivityState, CompletedSubagentInfo } from "../SubagentPanel.js";
import type { ImageAttachment } from "../ChatInput.js";
function randomVerb(): string {
const THINKING_VERBS = [
"thinking…", "reasoning…", "considering…", "analyzing…", "evaluating…",
"pondering…", "processing…", "reflecting…", "examining…", "working…",
];
return THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)];
}
export interface AgentLoopDeps {
isStreaming: boolean;
thinkingEnabled: boolean;
conversationRef: React.MutableRefObject<Anthropic.MessageParam[]>;
abortRef: React.MutableRefObject<AbortController | null>;
accTextRef: React.MutableRefObject<string>;
textTimerRef: React.MutableRefObject<NodeJS.Timeout | null>;
teamTimerRef: React.MutableRefObject<NodeJS.Timeout | null>;
toolOutputTimerRef: React.MutableRefObject<NodeJS.Timeout | null>;
thinkingVerbRef: React.MutableRefObject<string>;
// Rewind
rewindManagerRef: React.MutableRefObject<RewindManager>;
turnIndexRef: React.MutableRefObject<number>;
// State setters
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
setStreamingText: React.Dispatch<React.SetStateAction<string>>;
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>;
setActiveTools: React.Dispatch<React.SetStateAction<ToolCall[]>>;
setSubagentActivity: React.Dispatch<React.SetStateAction<Map<string, SubagentActivityState>>>;
setCompletedSubagents: React.Dispatch<React.SetStateAction<CompletedSubagentInfo[]>>;
setTeamState: React.Dispatch<React.SetStateAction<{
name: string;
tasksCompleted: number;
tasksTotal: number;
teammates: Map<string, { name: string; status: string }>;
} | null>>;
}
export function useAgentLoop(deps: AgentLoopDeps) {
const {
isStreaming, thinkingEnabled, conversationRef, abortRef,
accTextRef, textTimerRef, teamTimerRef, toolOutputTimerRef, thinkingVerbRef,
rewindManagerRef, turnIndexRef,
setMessages, setStreamingText, setIsStreaming, setActiveTools,
setSubagentActivity, setCompletedSubagents, setTeamState,
} = deps;
const handleSend = useCallback(async (userMessage: string, images?: ImageAttachment[]) => {
if (isStreaming) return;
setMessages((prev) => [...prev, {
role: "user",
text: userMessage,
images: images?.map(img => img.name),
}]);
setStreamingText("");
setActiveTools([]);
setSubagentActivity(new Map());
setCompletedSubagents([]);
setTeamState(null);
setIsStreaming(true);
thinkingVerbRef.current = randomVerb();
const abort = new AbortController();
abortRef.current = abort;
accTextRef.current = "";
const toolCalls: ToolCall[] = [];
let usage: {
input_tokens: number;
output_tokens: number;
thinking_tokens?: number;
model?: string;
costUsd?: number;
cache_read_tokens?: number;
cache_creation_tokens?: number;
} | undefined;
const flushText = () => {
const text = accTextRef.current;
if (text.trim()) {
setMessages(prev => [...prev, { role: "assistant" as const, text }]);
}
accTextRef.current = "";
if (textTimerRef.current) { clearTimeout(textTimerRef.current); textTimerRef.current = null; }
setStreamingText("");
};
const emitter = new AgentEventEmitter();
// Team tracking
let teamName = "";
let teamTotal = 0;
let teamCompleted = 0;
const teammateStatus = new Map<string, { name: string; status: string }>();
const flushTeamState = () => {
if (teamTimerRef.current) { clearTimeout(teamTimerRef.current); teamTimerRef.current = null; }
setTeamState({ name: teamName, tasksCompleted: teamCompleted, tasksTotal: teamTotal, teammates: new Map(teammateStatus) });
};
const scheduleTeamFlush = () => {
if (!teamTimerRef.current) {
teamTimerRef.current = setTimeout(() => {
teamTimerRef.current = null;
flushTeamState();
}, 200);
}
};
const unsub = emitter.onEvent((event: AgentEvent) => {
switch (event.type) {
case "text":
accTextRef.current += event.text;
if (!textTimerRef.current) {
textTimerRef.current = setTimeout(() => {
textTimerRef.current = null;
setStreamingText(accTextRef.current);
}, 150);
}
break;
case "tool_output": {
const idx = toolCalls.findIndex(t => t.name === event.toolName && t.status === "running");
if (idx >= 0) {
const prev = toolCalls[idx].result || "";
const lines = (prev + "\n" + event.line).split("\n");
toolCalls[idx].result = lines.slice(-4).join("\n").trim();
if (!toolOutputTimerRef.current) {
toolOutputTimerRef.current = setTimeout(() => {
toolOutputTimerRef.current = null;
setActiveTools([...toolCalls]);
}, 250);
}
}
break;
}
case "team_start":
teamName = event.name;
teamTotal = event.taskCount;
teamCompleted = 0;
teammateStatus.clear();
flushTeamState();
break;
case "team_task":
if (event.status === "started") {
const existing = teammateStatus.get(event.teammateId);
teammateStatus.set(event.teammateId, { name: existing?.name || event.teammateId, status: event.taskDescription.slice(0, 50) });
} else if (event.status === "completed") {
teamCompleted++;
const existing = teammateStatus.get(event.teammateId);
teammateStatus.set(event.teammateId, { name: existing?.name || event.teammateId, status: "done" });
} else if (event.status === "failed") {
const existing = teammateStatus.get(event.teammateId);
teammateStatus.set(event.teammateId, { name: existing?.name || event.teammateId, status: "failed" });
}
flushTeamState();
break;
case "team_progress":
teammateStatus.set(event.teammateId, { name: event.teammateName || event.teammateId, status: event.message.slice(0, 50) });
scheduleTeamFlush();
break;
case "team_done":
if (teamTimerRef.current) { clearTimeout(teamTimerRef.current); teamTimerRef.current = null; }
setTeamState(null);
accTextRef.current = "";
if (textTimerRef.current) { clearTimeout(textTimerRef.current); textTimerRef.current = null; }
setStreamingText("");
break;
case "subagent_start":
setSubagentActivity(prev => {
const next = new Map(prev);
next.set(event.id, {
type: event.agentType,
model: event.model,
description: event.description,
turn: 0,
message: "starting…",
tools: [],
startTime: Date.now(),
});
return next;
});
break;
case "subagent_progress":
setSubagentActivity(prev => {
const next = new Map(prev);
const existing = next.get(event.id);
if (existing) {
next.set(event.id, {
...existing,
turn: event.turn || existing.turn,
message: event.message,
});
}
return next;
});
break;
case "subagent_tool_start":
setSubagentActivity(prev => {
const next = new Map(prev);
const existing = next.get(event.agentId);
if (existing) {
next.set(event.agentId, {
...existing,
tools: [...existing.tools, {
name: event.toolName,
status: "running" as const,
input: event.toolInput,
}],
});
}
return next;
});
break;
case "subagent_tool_end":
setSubagentActivity(prev => {
const next = new Map(prev);
const existing = next.get(event.agentId);
if (existing) {
const tools = [...existing.tools];
for (let i = tools.length - 1; i >= 0; i--) {
if (tools[i].name === event.toolName && tools[i].status === "running") {
tools[i] = { ...tools[i], status: event.success ? "success" : "error", durationMs: event.durationMs };
break;
}
}
next.set(event.agentId, { ...existing, tools });
}
return next;
});
break;
case "subagent_done":
setSubagentActivity(prev => {
const existing = prev.get(event.id);
const next = new Map(prev);
next.delete(event.id);
if (existing) {
setCompletedSubagents(prevCompleted => [...prevCompleted, {
id: event.id,
type: event.agentType,
description: existing.description || "",
toolCount: event.tools.length,
tokens: event.tokens,
durationMs: event.durationMs,
success: event.success,
}]);
}
return next;
});
break;
case "done":
if (textTimerRef.current) {
clearTimeout(textTimerRef.current);
textTimerRef.current = null;
}
accTextRef.current = event.text;
break;
}
});
await runAgentLoop({
message: userMessage,
images: images?.map(img => ({ base64: img.base64, mediaType: img.mediaType })),
conversationHistory: conversationRef.current,
abortSignal: abort.signal,
emitter,
thinking: thinkingEnabled,
callbacks: {
onText: () => {},
onToolStart: (name, input) => {
if (input) {
const idx = toolCalls.findIndex(t => t.name === name && t.status === "running" && !t.input);
if (idx >= 0) {
toolCalls[idx].input = input;
setActiveTools([...toolCalls]);
}
return;
}
flushText();
toolCalls.push({ name, status: "running" });
setActiveTools([...toolCalls]);
},
onToolResult: (name, success, result, input, durationMs) => {
if (toolOutputTimerRef.current) { clearTimeout(toolOutputTimerRef.current); toolOutputTimerRef.current = null; }
const completedTool: ToolCall = {
name,
status: success ? "success" : "error",
result: typeof result === "string" ? result : JSON.stringify(result),
input,
durationMs,
};
const idx = toolCalls.findIndex((t) => t.name === name && t.status === "running");
if (idx >= 0) toolCalls.splice(idx, 1);
// Track file changes for rewind
if (success && input) {
const FILE_TOOLS = ["write_file", "edit_file", "multi_edit"];
if (FILE_TOOLS.includes(name)) {
try {
const filePath = (input as Record<string, unknown>).file_path as string | undefined;
if (filePath) {
// Find the most recent backup for this file from file-history
const backups = listBackups();
const fileName = filePath.split("/").pop() || "";
const backup = backups.find(b => b.name.endsWith(`-${fileName}`));
const isNewFile = !backup; // No backup means file didn't exist before
const op = name === "write_file" ? "write" as const
: name === "edit_file" ? "edit" as const
: "multi_edit" as const;
rewindManagerRef.current.trackFileChange({
filePath,
backupPath: backup?.path || "",
operation: op,
isNewFile,
});
}
} catch {
// Best effort — don't fail the actual operation
}
}
}
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === "assistant" && !last.text && !last.usage && last.toolCalls?.length) {
const updated = { ...last, toolCalls: [...last.toolCalls, completedTool] };
return [...prev.slice(0, -1), updated];
}
return [...prev, { role: "assistant" as const, text: "", toolCalls: [completedTool] }];
});
setActiveTools([...toolCalls]);
accTextRef.current = "";
if (textTimerRef.current) { clearTimeout(textTimerRef.current); textTimerRef.current = null; }
setStreamingText("");
},
onUsage: (input_tokens, output_tokens, thinking_tokens, model, costUsd, cacheReadTokens, cacheCreationTokens) => {
usage = { input_tokens, output_tokens, thinking_tokens, model, costUsd, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens };
},
onAutoCompact: (before, after, tokensSaved) => {
setMessages((prev) => [...prev, {
role: "assistant" as const,
text: ` Context auto-compacted: ${before} messages -> ${after} messages (~${(tokensSaved / 1000).toFixed(0)}K tokens freed)`,
}]);
},
onDone: (finalMessages) => {
unsub();
emitter.destroy();
if (textTimerRef.current) { clearTimeout(textTimerRef.current); textTimerRef.current = null; }
setCompletedSubagents(prevCompleted => {
if (prevCompleted.length > 0) {
setMessages(prev => [...prev, {
role: "assistant" as const,
text: "",
completedSubagents: prevCompleted,
}]);
}
return [];
});
const finalText = accTextRef.current;
if (finalText.trim() || usage) {
setMessages((prev) => [...prev, {
role: "assistant" as const,
text: finalText,
usage,
}]);
}
// Record rewind checkpoint for this turn
setMessages(prev => {
const fileChanges = rewindManagerRef.current.getCurrentFileChanges();
const summary = finalText.trim() || "(tool-only turn)";
rewindManagerRef.current.addCheckpoint(
turnIndexRef.current,
prev.length,
summary,
fileChanges,
);
rewindManagerRef.current.commitTurn();
turnIndexRef.current++;
return prev;
});
setStreamingText("");
setActiveTools([]);
setSubagentActivity(new Map());
setTeamState(null);
setIsStreaming(false);
abortRef.current = null;
conversationRef.current = finalMessages;
},
onError: (err, partialMessages) => {
unsub();
emitter.destroy();
if (textTimerRef.current) { clearTimeout(textTimerRef.current); textTimerRef.current = null; }
if (err !== "Cancelled") {
setMessages((prev) => [...prev, { role: "assistant", text: `\x1b[31m\u2718 Error: ${err}\x1b[0m` }]);
}
setStreamingText("");
setActiveTools([]);
setSubagentActivity(new Map());
setCompletedSubagents([]);
setTeamState(null);
setIsStreaming(false);
abortRef.current = null;
if (partialMessages && partialMessages.length > 0) {
conversationRef.current = partialMessages;
}
},
},
});
}, [isStreaming, thinkingEnabled]);
return { handleSend };
}