// agentSSEDispatch.ts — P3-5: dispatch SSE events estratto da agentSSE.ts // handleEvent: switch su SSEEvent.event → aggiorna taskStore + emette EventRuntime. // _maybeNotify: push notification se pagina in background. import { useTaskStore, type TaskActionType, type PhaseBreakdown } from "@/store/taskStore"; import { eventRuntime } from "@/core/events"; import { onStepDone, onTaskEnd } from "@/lib/checkpointManager"; import { streamManager } from "@/lib/streamManager"; import { handleBuildFailure } from "@/lib/buildFailureHandler"; import { inferActionType, inferActionLabel, inferActionDetail } from "./sseActionInferer"; import type { SSEEvent } from "./agentSSETypes"; import { _taskParams, _taskStarted, _connectAt, _thinkingTimers, _taskTimers, _streamingBuffer, _bufferTimestamps, } from "./agentSSEState"; import { tgNotify } from "./agentTelegram"; import { toast } from "sonner"; // ABORT-TOAST // [S67] Mostra notifica push se la pagina è nascosta e il permesso è concesso function _maybeNotify(taskId: string): void { try { if (!document.hidden) return; if (!("Notification" in window) || Notification.permission !== "granted") return; navigator.serviceWorker.ready.then(reg => { reg.showNotification("Agente AI — Task completato ✓", { body: `Task ${taskId.slice(0, 8)}… completato con successo`, icon: "/favicon.svg", badge: "/favicon.svg", tag: taskId, }); }).catch(() => {}); } catch { /* best-effort */ } } // ─── Dispatch eventi → taskStore ────────────────────────────────────────────── export function handleEvent( ev: SSEEvent, taskId: string, store: ReturnType, ) { switch (ev.event) { case "task_start": store.updateStatus(taskId, "RUNNING"); store.addLog(taskId, "info", `Task avviato: ${String(ev.goal ?? "")}`); tgNotify.taskStart(String(ev.goal ?? ""), taskId); store.addAction(taskId, { type: "plan", label: "Planning task execution", done: false }); // S758-P4.1: se nessun step_start arriva entro 3s, mostra "thinking" chip { const _thinkT = setTimeout(() => { _thinkingTimers.delete(taskId); const _cur = useTaskStore.getState().tasks.find(t => t.id === taskId); if (_cur?.status === "RUNNING" && (_cur.currentStep ?? 0) === 0) { store.addAction(taskId, { type: "thinking", label: "L’agente sta analizzando…", done: false }); store.addLog(taskId, "info", "⏳ In attesa della prima risposta…"); } }, 3_000); _thinkingTimers.set(taskId, _thinkT); } break; case "step_start": { // S758-P4.1: clear thinking timer — first real step received { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } const stepObj = ev.step as { name?: string; tool?: string } | undefined; const name = stepObj?.name ?? String(ev.tool ?? "Step"); const type = inferActionType(name, { ...ev, tool: stepObj?.tool ?? ev.tool }); const label = inferActionLabel(name, ev); const detail = inferActionDetail(ev); store.addLog(taskId, "info", `▶ ${name}`); store.addAction(taskId, { type, label, detail, done: false, toolName: name }); // S397: toolName for narrative // [Sessione 17] Emit agent_step_start const stepNum = useTaskStore.getState().tasks.find(t => t.id === taskId)?.currentStep ?? 0; eventRuntime.emit({ kind: "agent_step_start", taskId, step: stepNum, action: name }); if (ev.tool) { eventRuntime.emit({ kind: "tool_call", taskId, tool: String(ev.tool), args: (ev.args as Record) ?? {}, }); } break; } case "step_done": { // S396-NARR: parse rich step data from backend (action, status, title, explanation) const stepRaw = (ev.step ?? ev) as Record; const bAction = String(stepRaw.action ?? stepRaw.name ?? "step"); // S403: SSE Visibility Guard — filtra eventi interni che non devono essere mostrati // all'utente. Il backend assegna il campo `visibility` ad ogni step_done event. // "internal" = mai visibile (plan, llm, fallback, reflective_debug, fast_path, smolagents) // "debug" = solo devMode (ignorato per ora — trattato come internal) // "progress" = visibile come StepCard (tool reali, auto-fix, meteo, ricerca…) // Assenza del campo → backwards compat: lascia passare (eventi da versioni precedenti) const bVisibility = String(stepRaw.visibility ?? "progress"); if (bVisibility === "internal" || bVisibility === "debug") { // Solo log — nessuna StepCard, nessun agent_step_start/complete store.addLog(taskId, "info", `[internal] ${bAction}`); break; } const bStatus = String(stepRaw.status ?? "done"); const bTitle = String(stepRaw.title ?? bAction.replace(/_/g, " ").replace(/\b\w/g, (ch: string) => ch.toUpperCase())); const bExpl = String(stepRaw.explanation ?? ""); const bOutput = String(stepRaw.output ?? ""); const bDurMs = (stepRaw.duration_ms ?? ev.duration_ms) as number | undefined; // P16-B4: segnala truncation SSE al task store + log warning const bTruncated = Boolean((stepRaw as Record).truncated); if (bTruncated) { store.addLog(taskId, "warn", "⚠️ Risposta LLM troncata (max_tokens raggiunto) — contesto ridotto al prossimo step."); } const aType = inferActionType(bAction, { ...ev, tool: bAction }); if (bStatus === "running") { // S396: Tool STARTING — aggiungi come action in corso store.addLog(taskId, "info", `▶ ${bTitle}`); store.addAction(taskId, { type: aType, toolName: bAction, label: bTitle, detail: bExpl.slice(0, 80) || undefined, explanation: bExpl || undefined, done: false, }); } else { // S396: Tool DONE/warning — completa pending o aggiungi completata store.addLog(taskId, "info", `✓ ${bTitle}`); const task = useTaskStore.getState().tasks.find(t => t.id === taskId); if (task) { const pending = [...task.actions].reverse().find(a => !a.done); if (pending) { store.completeAction(taskId, pending.id, bDurMs); } else { // nessuna pending — aggiungi già completata (path piano/fallback) store.addAction(taskId, { type: aType, toolName: bAction, label: bTitle, detail: (bExpl || bOutput).slice(0, 80) || undefined, explanation: bExpl || undefined, done: true, durationMs: bDurMs, }); } } store.advanceStep(taskId); const taskNow = useTaskStore.getState().tasks.find(t => t.id === taskId); // [Sessione 17] Emit agent_step_complete eventRuntime.emit({ kind: "agent_step_complete", taskId, step: taskNow?.currentStep ?? 0, action: bAction, success: bStatus !== "error", }); // [Sessione 18] Checkpoint ogni N step if (taskNow) { onStepDone( taskId, taskNow.goal, taskNow.currentStep, taskNow.logs.map(l => l.message), taskNow.plan ?? [], taskNow.artifacts.map(a => a.id), taskNow.retryCount, ); } } break; } case "action": { const type = (ev.type ?? "tool_call") as TaskActionType; const label = String(ev.label ?? ev.tool ?? "Action"); const detail = (ev.detail ?? ev.path ?? ev.url) as string | undefined; store.addAction(taskId, { type, label, detail, done: false }); break; } case "tool_use": { // S758-P4.1: pre-execution chip — fires BEFORE the tool runs { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } const _tuTool = String(ev.tool ?? ev.name ?? "Tool"); store.addAction(taskId, { type: inferActionType(_tuTool, ev), label: inferActionLabel(_tuTool, ev), detail: inferActionDetail(ev), done: false, toolName: _tuTool }); store.addLog(taskId, "info", `⚡ ${_tuTool}`); if (ev.tool) eventRuntime.emit({ kind: "tool_call", taskId, tool: String(ev.tool), args: (ev.args as Record) ?? {} }); break; } case "task_thinking": { // S758-P4.1: explicit thinking event from backend { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } const _thMsg = String(ev.message ?? "L’agente sta elaborando…"); store.addAction(taskId, { type: "thinking", label: _thMsg, done: false }); store.addLog(taskId, "info", `🧠 ${_thMsg}`); break; } case "step_error": { const errStep = String(ev.error ?? ""); store.addLog(taskId, "error", `✗ Step errore: ${errStep}`); eventRuntime.emit({ kind: "agent_step_error", taskId, step: useTaskStore.getState().tasks.find(t => t.id === taskId)?.currentStep ?? 0, action: "step", message: errStep, }); break; } case "task_done": { { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } store.updateStatus(taskId, "SUCCESS"); const artifacts: string[] = []; if (ev.result) { store.addArtifact(taskId, { id: crypto.randomUUID(), type: "data", name: "Risultato", content: String(ev.result).slice(0, 10_000), }); artifacts.push("result"); } // [Sessione 17] Emit task_complete eventRuntime.emit({ kind: "task_complete", taskId, durationMs: Date.now() - (_connectAt.get(taskId) ?? Date.now()), artifacts, }); // [Sessione 18] Rimuove checkpoint // GAP-2-FIX + P16-F2: piano completato → nascondi StreamingPlanBubble // P16-F2: ritardo 1500ms per permettere all'utente di vedere il piano completato if (typeof window !== "undefined") { setTimeout(() => window.dispatchEvent(new CustomEvent("agente-ai:plan-clear", { detail: {} })), 1500); } onTaskEnd(taskId); // Sprint 5 ITEM 14: phase_breakdown → taskStore if (ev.phase_breakdown && typeof ev.phase_breakdown === "object") { store.setPhaseBreakdown(taskId, ev.phase_breakdown as PhaseBreakdown); } // FIX(audit): cancella stream dalla registry — evita idle timer zombie streamManager.cancel(taskId); // Telegram taskDone — PRIMA del cleanup per leggere _taskParams e _connectAt // TG-ENRICH: passa stepsCount per notifica più ricca (quanti step ha eseguito) { const _tgTask = useTaskStore.getState().tasks.find(t => t.id === taskId); tgNotify.taskDone( taskId, String(_taskParams.get(taskId)?.goal ?? ev.goal ?? ""), Date.now() - (_connectAt.get(taskId) ?? Date.now()), ev.result ? String(ev.result).slice(0, 400) : undefined, undefined, // filesModified: non tracciato per action type _tgTask?.currentStep ?? undefined, // stepsCount: numero step completati ); } // RT-5: cleanup Map per evitare leak e possibile rianimazione fantasma _taskParams.delete(taskId); _connectAt.delete(taskId); _taskStarted.delete(taskId); // S345: cleanup — prossimo task può usare stesso ID _streamingBuffer.delete(taskId); // S420: cleanup buffer streaming _bufferTimestamps.delete(taskId); // S422: cleanup TTL timestamp // S346-A: clear timeout timer — task completato normalmente const _tt = _taskTimers.get(taskId); if (_tt) { clearTimeout(_tt); _taskTimers.delete(taskId); } // [S67] Notifica push se la pagina è in background _maybeNotify(taskId); break; } case "task_error": { // N3: ProblemsPanel realtime — segnala errore a diagAsync (fire-and-forget, 0 overhead) try { import("@/lib/vfsDb").then(({ diagAsync: _d }) => { const _em = String(ev.error ?? "Errore sconosciuto"); _d.add({ type: "error", context: `task:${String(taskId).slice(0,8)}`, message: _em }); }).catch(() => {}); } catch { /* non-blocking */ } { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } const errMsg = String(ev.error ?? "Errore sconosciuto"); const task = useTaskStore.getState().tasks.find(t => t.id === taskId); if (task && task.retryCount < task.maxRetries) { const backoffMs = Math.min(2000 * (2 ** task.retryCount), 30_000); store.addLog( taskId, "warn", `Errore — retry ${task.retryCount + 1}/${task.maxRetries} in ${backoffMs / 1000}s`, ); // S762-BUG2: buffer stale accumula contenuto del tentativo fallito; // al task_done del retry, join produce testo duplicato → pulisci subito _streamingBuffer.delete(taskId); _bufferTimestamps.delete(taskId); setTimeout(() => { store.retryTask(taskId); }, backoffMs); } else { store.updateStatus(taskId, "ERROR", errMsg); // F3-S201: buildFailureHandler — auto-analisi e istruzioni recovery per build error const _isBuildErr = /TSd{4}|build fail|esbuild|typecheck|compile/i.test(errMsg); if (_isBuildErr) { try { const _bf = handleBuildFailure(errMsg, { retryCount: task?.retryCount ?? 0, taskType: "build" }); store.addLog(taskId, "warn", `🔧 Build fix: ${_bf.action} — ${_bf.instructions.slice(0, 120)}`); } catch { /* non-blocking */ } } // FIX(audit): cancella stream dalla registry quando non c'è più retry streamManager.cancel(taskId); // Telegram — notifica errore definitivo PRIMA del cleanup dei Map tgNotify.taskError(taskId, String(_taskParams.get(taskId)?.goal ?? ""), errMsg); // RT-5: cleanup Map — evita leak e possibile rianimazione da agent:promote-queued _taskParams.delete(taskId); _connectAt.delete(taskId); _taskStarted.delete(taskId); // S345: consenti fresh start su errore definitivo _streamingBuffer.delete(taskId); // S422: cleanup buffer streaming su errore definitivo _bufferTimestamps.delete(taskId); // S422: cleanup TTL timestamp su errore definitivo // S346-A: clear timeout timer — task terminato con errore const _te = _taskTimers.get(taskId); if (_te) { clearTimeout(_te); _taskTimers.delete(taskId); } } eventRuntime.emit({ kind: "task_error", taskId, message: errMsg, retryCount: task?.retryCount ?? 0, }); break; } case "text_chunk": { // S420: accumula token in buffer locale e dispatcha evento DOM per UI real-time const _tok = String(ev.token ?? ""); if (_tok) { const _prev = (_streamingBuffer.get(taskId) ?? "") + _tok; _streamingBuffer.set(taskId, _prev); _bufferTimestamps.set(taskId, Date.now()); // S422: aggiorna TTL if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agent:stream-chunk", { detail: { taskId, text: _prev, token: _tok }, })); } } break; } case "vfs_sync_complete": { // P34: VFS Atomic Swap — tutti i file del task scritti, il frontend può committare lo staging const _sc_files = (ev as Record).files as string[] | undefined; if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agent:vfs-sync-complete", { detail: { taskId, files: _sc_files ?? [] }, })); } break; } case "vfs_update": { // S723-GAP3.2: vfs_update era silenzioso — workspace non si aggiornava mai // SYNC-1: forward content nel DOM event per vfsRemoteSync — zero fetch aggiuntivo const _vf = String(ev.file ?? ""); const _vo = String(ev.op ?? "write"); const _vc = (ev as Record).content as string | undefined; // SYNC-1: file content store.addLog(taskId, "info", `${_vo === "delete" ? "\uD83D\uDDD1\uFE0F" : "\uD83D\uDCDD"} ${_vo}: ${_vf.split("/").pop() ?? _vf}`); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agent:vfs-update", { detail: { file: _vf, op: _vo, taskId, content: _vc } })); } break; } case "persona_classified": { // P17-F5: server ha classificato la persona (auto-detection) — aggiorna badge UI const _pc = ev as Record; const _pcPersona = String(_pc.persona ?? ''); const _pcConf = Number(_pc.confidence ?? 0); const _pcAuto = Boolean(_pc.auto ?? false); if (_pcPersona && typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('agent:persona-classified', { detail: { taskId, persona: _pcPersona, confidence: _pcConf, auto: _pcAuto }, })); store.addLog(taskId, 'info', `🎯 Persona: ${_pcPersona}${_pcAuto ? ' (auto)' : ''} ${Math.round(_pcConf * 100)}%`); } break; } case "thought": { // SYNC-2: thought dal backend (goal esplicito dopo planning) → DOM event per ThinkingBlock const _thText = String((ev as Record).text ?? ""); if (_thText) { store.addLog(taskId, "info", `💭 ${_thText.slice(0, 80)}`); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agent:thought", { detail: { taskId, text: _thText } })); } } break; } case "plan_update": { // SYNC-2: subtask list dal backend → aggiorna plan in taskStore + DOM event per live checkbox const _pu = ev as Record; const _subtasks = (_pu.subtasks as Array<{ id: string|number; description: string; tool?: string; status: string }> | undefined) ?? []; if (_subtasks.length > 0) { store.addLog(taskId, "info", `📋 Piano: ${_subtasks.length} step`); (store as unknown as Record void) | undefined>)["updateTask"]?.(taskId, { plan: _subtasks.map(s => String(s.description || s.id)).filter(Boolean) }); if (typeof window !== "undefined") { // P16-F2: StreamingPlanBubble ascolta direttamente agent:plan-update (bridge rimosso) window.dispatchEvent(new CustomEvent("agent:plan-update", { detail: { taskId, subtasks: _subtasks, goal: String(_pu.goal ?? "") } })); } } break; } case "subtask_done": { // SYNC-2: singolo subtask completato → DOM event per live plan checkbox update const _sd = ev as Record; if (typeof window !== "undefined") { // P16-F2: StreamingPlanBubble ascolta direttamente agent:subtask-done (bridge rimosso) window.dispatchEvent(new CustomEvent("agent:subtask-done", { detail: { taskId, id: _sd.id, status: String(_sd.status ?? "done") } })); } break; } case "task_interrupted": { // SYNC-2: backend riavviato durante esecuzione → status ERROR + toast + DOM event { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } store.updateStatus(taskId, "ERROR", "Backend riavviato durante l'esecuzione"); store.addLog(taskId, "warn", "⚡ Backend riavviato — task interrotto"); streamManager.cancel(taskId); const _tc2 = _taskTimers.get(taskId); if (_tc2) { clearTimeout(_tc2); _taskTimers.delete(taskId); } if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agent:task-interrupted", { detail: { taskId, reason: String((ev as Record).reason ?? "backend_restarted") } })); } eventRuntime.emit({ kind: "task_error", taskId, message: "Backend riavviato durante l'esecuzione", retryCount: 0 }); // GAP-2-FIX: backend crash → nascondi StreamingPlanBubble if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agente-ai:plan-clear", { detail: {} })); } break; } case "task_cancelled": { const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } } store.updateStatus(taskId, "CANCELLED"); // GAP-2-FIX: piano annullato → nascondi StreamingPlanBubble if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agente-ai:plan-clear", { detail: {} })); } onTaskEnd(taskId); // FIX(audit): cancella stream dalla registry — evita idle timer zombie streamManager.cancel(taskId); // S346-A: clear timeout timer su task_cancelled { const _tc = _taskTimers.get(taskId); if (_tc) { clearTimeout(_tc); _taskTimers.delete(taskId); } } tgNotify.taskCancelled(taskId, String(_taskParams.get(taskId)?.goal ?? "")); break; case "connector_needed": { // P39-UX: Tocco Finale Manus — agente richiede OAuth per continuare const _provider = String(ev.provider ?? ''); const _label = String(ev.label ?? ev.provider ?? ''); const _msg = String(ev.message ?? ''); store.addLog(taskId, "warn", `🔗 Connettore richiesto: ${_label || _provider}`); // Emit custom DOM event — ascoltato da ConnectorNeededBanner try { window.dispatchEvent(new CustomEvent("agent:connector-needed", { detail: { provider: _provider, label: _label, message: _msg, taskId }, })); } catch { /* SSR safe */ } break; } case "task_aborted": { // ABORT-DISPATCH: task fermato dal pulsante "✕ Stop" via POST /api/agent/abort. // Auto-retry: salva params PRIMA del cleanup → riavvio automatico dopo 1200ms. const _tt = _thinkingTimers.get(taskId); if (_tt) { clearTimeout(_tt); _thinkingTimers.delete(taskId); } // Salva prima del cleanup — _taskParams viene cancellato sotto const _abGoal = String(_taskParams.get(taskId)?.goal ?? ""); const _abCtx = _taskParams.get(taskId)?.context ?? []; const _abSteps = _taskParams.get(taskId)?.maxSteps ?? 8; store.updateStatus(taskId, "CANCELLED"); // MX16+MX18-ABORT: abort_reason enum — "user_stop" | "timeout" | "system" | "escalation" | "error_limit" const _abortReason = String((ev as Record).abort_reason ?? "user_stop"); const _isUserStop = _abortReason === "user_stop"; const _isTimeout = _abortReason === "timeout"; // MX18-ABORT const _isEscalation = _abortReason === "escalation"; // MX18-ABORT const _isErrorLimit = _abortReason === "error_limit"; // MX18-ABORT // UX: messaggio e toast differenziati per tipo abort — MX18-ABORT const _abortLogMsg = _isUserStop ? "⛔ Task fermato dall'utente" : _isTimeout ? "⏱ Task scaduto (timeout di sistema)" : _isEscalation ? "⚡ Provider AI esauriti — interruzione escalation" : _isErrorLimit ? "⚠️ Limite errori raggiunto — task interrotto" : "⛔ Task interrotto dal sistema"; store.addLog(taskId, "warn", _abortLogMsg); if (_isUserStop) { toast.success("✓ Task fermato", { duration: 2000, id: "task-aborted-toast" }); } else if (_isTimeout) { toast.error("⏱ Task scaduto — riprova", { duration: 4000, id: "task-aborted-toast" }); } else if (_isEscalation) { toast.error("⚡ Provider AI esauriti — riprovo con strategia alternativa…", { duration: 5000, id: "task-aborted-toast" }); } else { toast.info("Task interrotto — riavvio automatico in corso…", { duration: 4000, id: "task-aborted-toast" }); } onTaskEnd(taskId); streamManager.cancel(taskId); const _tc = _taskTimers.get(taskId); if (_tc) { clearTimeout(_tc); _taskTimers.delete(taskId); } // Full Map cleanup — libera MAX_CONCURRENT_SSE per il prossimo task _taskParams.delete(taskId); _connectAt.delete(taskId); _taskStarted.delete(taskId); _streamingBuffer.delete(taskId); _bufferTimestamps.delete(taskId); // MX16+MX18-ABORT: task_error solo su abort di sistema, non su stop utente né timeout (già gestito) if (!_isUserStop && !_isTimeout) { eventRuntime.emit({ kind: "task_error", taskId, message: "Task interrotto dal sistema", retryCount: useTaskStore.getState().tasks.find(t => t.id === taskId)?.retryCount ?? 0, }); } // MX16+MX18-ABORT: auto-retry su system/escalation/error_limit, NON su user_stop né timeout const _shouldAutoRetry = !_isUserStop && !_isTimeout; // MX18-ABORT: timeout non retry if (_abGoal && _shouldAutoRetry && typeof window !== "undefined") { setTimeout(() => window.dispatchEvent(new CustomEvent("agente-ai:auto-retry", { detail: { goal: _abGoal, context: _abCtx, maxSteps: _abSteps }, })), 1200); } break; } case "partial_output": { // S-PARTIAL: backend delegate timeout → output parziale recuperato. // Mostra chip "⚠ output parziale — riprendo" nella chat. // Il chip scompare automaticamente al prossimo step_done (normale progressione). const _po = ev as Record; const _poSteps = Number(_po.steps_done ?? 0); const _poFiles = (_po.partial_files as string[] | undefined) ?? []; const _poOut = String(_po.partial_output ?? _po.output ?? "").slice(0, 200); const _poLabel = [ "⚠ output parziale — riprendo", _poSteps > 0 ? `(${_poSteps} step completati)` : "", _poFiles.length > 0 ? `· ${_poFiles.length} file parziali` : "", ].filter(Boolean).join(" "); store.addAction(taskId, { type: "tool_call", label: _poLabel, detail: _poOut || undefined, done: false, // pending: scomparirà al prossimo completeAction toolName: "partial_output", }); store.addLog(taskId, "warn", `⚠ output parziale (${_poSteps} step, ${_poFiles.length} file) — il backend sta recuperando`); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("agent:partial-output", { detail: { taskId, stepsDone: _poSteps, partialFiles: _poFiles, partialOutput: _poOut }, })); } break; } } }