From 599d962c0a15fe8257df17e7a0d82bbe8def719b Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Tue, 30 Jun 2026 17:20:44 +0200 Subject: [PATCH] fix(ui): recover Lighthouse chat stream state --- .../chat/lighthouse-v2-chat-page.test.tsx | 13 +++-- .../chat/lighthouse-v2-chat-page.tsx | 4 +- .../lighthouse/_lib/event-reducer.test.ts | 6 ++- .../lighthouse/_lib/event-reducer.ts | 13 +++-- ui/app/(prowler)/lighthouse/page.tsx | 53 ++++++++++++------- 5 files changed, 58 insertions(+), 31 deletions(-) diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index 8d3fc1ab9b..9bb733328c 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -442,13 +442,16 @@ describe("LighthouseV2ChatPage", () => { const recordCurrentUrl = () => { notifiedUrls.push(`${window.location.pathname}${window.location.search}`); }; - window.addEventListener( - LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, - recordCurrentUrl, - ); - renderPage(); try { + // Register inside the try so a throw in renderPage() can't leak the + // listener into later tests (the finally only runs if we entered the try). + window.addEventListener( + LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, + recordCurrentUrl, + ); + renderPage(); + // When await user.type( screen.getByRole("textbox", { name: "Message" }), diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx index e1c875885b..db84391034 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx @@ -67,6 +67,7 @@ interface LighthouseV2ChatPageProps { initialActiveTaskId?: string | null; initialStreamUrl?: string; initialPrompt?: string; + initialError?: string; } export function LighthouseV2ChatPage({ @@ -78,6 +79,7 @@ export function LighthouseV2ChatPage({ initialActiveTaskId, initialStreamUrl, initialPrompt, + initialError, }: LighthouseV2ChatPageProps) { const eventSourceRef = useRef(null); const initialPromptSentRef = useRef(false); @@ -96,7 +98,7 @@ export function LighthouseV2ChatPage({ ); const [messages, setMessages] = useState(initialMessages); const [input, setInput] = useState(""); - const [feedback, setFeedback] = useState(null); + const [feedback, setFeedback] = useState(initialError ?? null); const [blockedByConflict, setBlockedByConflict] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [lastSubmittedText, setLastSubmittedText] = useState( diff --git a/ui/app/(prowler)/lighthouse/_lib/event-reducer.test.ts b/ui/app/(prowler)/lighthouse/_lib/event-reducer.test.ts index 6d40f09bc0..4f284a5656 100644 --- a/ui/app/(prowler)/lighthouse/_lib/event-reducer.test.ts +++ b/ui/app/(prowler)/lighthouse/_lib/event-reducer.test.ts @@ -132,7 +132,7 @@ describe("event-reducer", () => { expect(next.activeTaskId).toBeNull(); }); - it("should mark disconnect as recoverable", () => { + it("should clear the task gate on disconnect so retry can recover", () => { // Given const state = createInitialLighthouseV2StreamState("task-1"); @@ -141,6 +141,8 @@ describe("event-reducer", () => { // Then expect(next.status).toBe("disconnected"); - expect(next.activeTaskId).toBe("task-1"); + // activeTaskId must be cleared: leaving it set keeps canSend false and + // makes the Retry button a no-op after a dropped SSE connection. + expect(next.activeTaskId).toBeNull(); }); }); diff --git a/ui/app/(prowler)/lighthouse/_lib/event-reducer.ts b/ui/app/(prowler)/lighthouse/_lib/event-reducer.ts index 02da6b151a..89b48ec838 100644 --- a/ui/app/(prowler)/lighthouse/_lib/event-reducer.ts +++ b/ui/app/(prowler)/lighthouse/_lib/event-reducer.ts @@ -49,6 +49,11 @@ export type LighthouseV2StreamActivityItem = | LighthouseV2StreamTextActivityItem | LighthouseV2StreamToolCallActivityItem; +export interface LighthouseV2StreamError { + code: string; + detail: string; +} + export interface LighthouseV2StreamState { status: LighthouseV2StreamStatus; activeTaskId: string | null; @@ -56,10 +61,7 @@ export interface LighthouseV2StreamState { toolCalls: LighthouseV2ToolCallState[]; activityItems: LighthouseV2StreamActivityItem[]; messageId?: string; - error?: { - code: string; - detail: string; - }; + error?: LighthouseV2StreamError; } export function createInitialLighthouseV2StreamState( @@ -151,9 +153,12 @@ export function reduceLighthouseV2Event( }, }; case LIGHTHOUSE_V2_SSE_EVENT.DISCONNECT: + // Clear the task gate so the UI can recover: keeping activeTaskId set + // leaves canSend false and makes the Retry button a no-op. return { ...state, status: LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED, + activeTaskId: null, }; } } diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index ddf3f5a763..59b28ced43 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -58,34 +58,50 @@ export default async function AIChatbot({ const result = await getLighthouseV2SupportedModels( configuration.providerType, ); - return [ - configuration.providerType, - "data" in result ? result.data : [], - ] as const satisfies readonly [ - LighthouseV2ProviderType, - LighthouseV2SupportedModel[], - ]; + return [configuration.providerType, result] as const; }), ); - const modelsByProvider = Object.fromEntries(modelsEntries) as Record< - LighthouseV2ProviderType, - LighthouseV2SupportedModel[] - >; + const modelsByProvider = Object.fromEntries( + modelsEntries.map(([providerType, result]) => [ + providerType, + "data" in result ? result.data : [], + ]), + ) as Record; + // Surface (rather than silently swallow to []) providers whose models + // failed to load, so an empty model list reads as a real backend failure. + const failedModelProviders = modelsEntries + .filter(([, result]) => !("data" in result)) + .map(([providerType]) => providerType); + const modelsError = + failedModelProviders.length > 0 + ? `Could not load available models for: ${failedModelProviders.join(", ")}. Try again shortly.` + : undefined; + const [initialMessages, activeSession] = activeSessionId ? await Promise.all([ getLighthouseV2Messages(activeSessionId), getLighthouseV2Session(activeSessionId), ]) : [{ data: [] }, undefined]; + // Treat the ?session= id as valid when its messages load (you can't fetch + // messages for a non-existent session, so this is the authoritative + // "session exists" check). A stale/deleted id fails here and is dropped so + // the client starts fresh instead of sending against a dead session. The + // session-metadata read stays best-effort — it only supplies activeTaskId, + // so a flaky metadata read must NOT discard an otherwise-valid session. + const sessionLoaded = Boolean(activeSessionId) && "data" in initialMessages; + const validSessionId = sessionLoaded ? activeSessionId : undefined; + const chatMessages = + sessionLoaded && "data" in initialMessages ? initialMessages.data : []; const initialActiveTaskId = - activeSession && "data" in activeSession + sessionLoaded && activeSession && "data" in activeSession ? (activeSession.data.activeTaskId ?? null) : null; const initialStreamUrl = - activeSessionId && initialActiveTaskId - ? buildLighthouseV2StreamUrl(activeSessionId) + validSessionId && initialActiveTaskId + ? buildLighthouseV2StreamUrl(validSessionId) : undefined; - const chatRouteKey = activeSessionId ?? initialPrompt ?? "new"; + const chatRouteKey = validSessionId ?? initialPrompt ?? "new"; return ( }> @@ -96,13 +112,12 @@ export default async function AIChatbot({ configurations={configurations} modelsByProvider={modelsByProvider} supportedProviders={supportedProviders} - initialSessionId={activeSessionId} - initialMessages={ - "data" in initialMessages ? initialMessages.data : [] - } + initialSessionId={validSessionId} + initialMessages={chatMessages} initialActiveTaskId={initialActiveTaskId} initialStreamUrl={initialStreamUrl} initialPrompt={initialPrompt} + initialError={modelsError} />