"use client"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { CornerDownLeftIcon, OctagonAlert } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { useHitlPhase } from "@/hooks/use-hitl-phase"; import type { HitlDecision, InterruptResult } from "@/lib/hitl"; import { isInterruptResult, useHitlDecision } from "@/lib/hitl"; /** * Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The * backend signals these by setting ``context.permission === "doom_loop"`` * on the ``permission_ask`` interrupt. * * The card replaces the generic "approve/reject" framing with a * "continue/stop" affordance that better matches the user's mental * model: the agent is stuck repeating itself, not asking permission * for a destructive action. */ function DoomLoopCard({ toolName, args, interruptData, onDecision, }: { toolName: string; args: Record; interruptData: InterruptResult; onDecision: (decision: HitlDecision) => void; }) { const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const context = (interruptData.context ?? {}) as Record; const threshold = typeof context.threshold === "number" ? context.threshold : 3; const stuckTool = (typeof context.tool === "string" && context.tool) || toolName; const recentSignatures = Array.isArray(context.recent_signatures) ? (context.recent_signatures as string[]) : []; const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); const argPreview = useMemo(() => { if (!args || Object.keys(args).length === 0) return null; try { const json = JSON.stringify(args, null, 2); return json.length > 600 ? `${json.slice(0, 600)}…` : json; } catch { return null; } }, [args]); const handleContinue = useCallback(() => { if (phase !== "pending") return; setProcessing(); onDecision({ type: "approve" }); }, [phase, setProcessing, onDecision]); const handleStop = useCallback(() => { if (phase !== "pending") return; setRejected(); onDecision({ type: "reject", message: "Doom loop: user requested stop." }); }, [phase, setRejected, onDecision]); useEffect(() => { const handler = (e: KeyboardEvent) => { if (phase !== "pending") return; if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); handleStop(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [phase, handleStop]); const isResolved = phase === "complete" || phase === "rejected"; return ( {phase === "rejected" ? "Stopped" : phase === "processing" ? "Continuing…" : phase === "complete" ? "Continued" : "I might be stuck"} {!isResolved && ( doom-loop )} {phase === "processing" ? ( ) : phase === "rejected" ? (

I stopped retrying {displayName} as you asked.

) : phase === "complete" ? (

Continuing to call {displayName} as you asked.

) : (

I called {displayName} {threshold} times in a row with similar arguments. Should I keep going or stop and rethink?

)} {argPreview && phase === "pending" && ( <>

Last arguments

								{argPreview}
							
)} {recentSignatures.length > 0 && phase === "pending" && (
Show repeated signatures ({recentSignatures.length})
    {recentSignatures.map((sig) => (
  • {sig}
  • ))}
)} {phase === "pending" && (
)}
); } export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({ toolName, args, result, }) => { const { dispatch } = useHitlDecision(); if (!result || !isInterruptResult(result)) return null; return ( } interruptData={result} onDecision={(decision) => dispatch([decision])} /> ); }; export function isDoomLoopInterrupt(result: unknown): boolean { if (!isInterruptResult(result)) return false; const ctx = (result.context ?? {}) as Record; return ctx.permission === "doom_loop"; }