Spaces:
Sleeping
Sleeping
| // 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<typeof useTaskStore.getState>, | |
| ) { | |
| 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<string, unknown>) ?? {}, | |
| }); | |
| } | |
| break; | |
| } | |
| case "step_done": { | |
| // S396-NARR: parse rich step data from backend (action, status, title, explanation) | |
| const stepRaw = (ev.step ?? ev) as Record<string, unknown>; | |
| 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<string, unknown>).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<string, unknown>) ?? {} }); | |
| 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<string, unknown>).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<string, unknown>).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<string,unknown>; | |
| 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<string,unknown>).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<string,unknown>; | |
| 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<string, ((id: string, p: unknown) => 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<string,unknown>; | |
| 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<string,unknown>).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<string, unknown>).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<string, unknown>; | |
| 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; | |
| } | |
| } | |
| } | |