diff --git a/echo/frontend/src/hooks/useConversationMonitor.test.tsx b/echo/frontend/src/hooks/useConversationMonitor.test.tsx new file mode 100644 index 00000000..fc56eb6d --- /dev/null +++ b/echo/frontend/src/hooks/useConversationMonitor.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useConversationMonitor } from "./useConversationMonitor"; + +const captureMock = vi.hoisted(() => vi.fn()); +vi.mock("posthog-js", () => ({ default: { capture: captureMock } })); + +// Keep React Query idle so only the SSE path drives `isStreaming`. +vi.mock("@/lib/bff", () => ({ + bff: { get: () => new Promise(() => {}) }, +})); + +// A fake EventSource whose error/snapshot we drive by hand. +class FakeEventSource { + static last: FakeEventSource | null = null; + onerror: (() => void) | null = null; + listeners = new Map void>(); + closed = false; + constructor() { + FakeEventSource.last = this; + } + addEventListener(type: string, cb: (event: Event) => void) { + this.listeners.set(type, cb); + } + close() { + this.closed = true; + } + emitSnapshot() { + const event = new MessageEvent("snapshot", { + data: JSON.stringify({ + conversations: [], + live_window_seconds: 60, + summary: {}, + }), + }); + this.listeners.get("snapshot")?.(event); + } + emitError() { + this.onerror?.(); + } +} + +const latestSource = (): FakeEventSource => { + if (!FakeEventSource.last) throw new Error("no EventSource opened"); + return FakeEventSource.last; +}; + +const wrapper = ({ children }: { children: ReactNode }) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +}; + +describe("useConversationMonitor stream degradation", () => { + beforeEach(() => { + vi.useFakeTimers(); + captureMock.mockClear(); + FakeEventSource.last = null; + vi.stubGlobal("EventSource", FakeEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("ignores a brief flap that recovers inside the grace window", () => { + const { result, unmount } = renderHook( + () => useConversationMonitor("p-flap"), + { wrapper }, + ); + const source = latestSource(); + act(() => source.emitSnapshot()); + expect(result.current.isStreaming).toBe(true); + + // Drops, then recovers before the grace window elapses. + act(() => source.emitError()); + act(() => { + vi.advanceTimersByTime(4000); + }); + expect(result.current.isStreaming).toBe(true); + act(() => source.emitSnapshot()); + + expect(captureMock).not.toHaveBeenCalled(); + act(() => unmount()); + }); + + it("reports a sustained outage and its paired recovery", () => { + const { result, unmount } = renderHook( + () => useConversationMonitor("p-outage"), + { wrapper }, + ); + const source = latestSource(); + act(() => source.emitSnapshot()); + + // Stays down past the grace window: now a real degradation. + act(() => source.emitError()); + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current.isStreaming).toBe(false); + expect(captureMock).toHaveBeenCalledWith("monitor_stream_degraded", { + project_id: "p-outage", + }); + + act(() => source.emitSnapshot()); + expect(result.current.isStreaming).toBe(true); + expect(captureMock).toHaveBeenCalledWith( + "monitor_stream_reconnected", + expect.objectContaining({ project_id: "p-outage" }), + ); + act(() => unmount()); + }); +}); diff --git a/echo/frontend/src/hooks/useConversationMonitor.ts b/echo/frontend/src/hooks/useConversationMonitor.ts index e1377655..f3225005 100644 --- a/echo/frontend/src/hooks/useConversationMonitor.ts +++ b/echo/frontend/src/hooks/useConversationMonitor.ts @@ -157,6 +157,13 @@ const EMPTY_SUMMARY: MonitorSummary = { // SSE is the primary channel; React Query is the fallback (first fetch + poll). const FALLBACK_POLL_MS = 5000; const SAFETY_POLL_MS = 30000; +// A dropped SSE connection auto-reconnects within a browser retry cycle (~3s) +// and the fallback poll covers the gap, so a brief flap never reaches the host. +// Wait out this grace window before treating the stream as down: only a stream +// that fails to recover across several reconnect attempts is a real degradation. +// Keeps the "Reconnecting" badge and the monitor_stream_degraded event tied to +// genuine, host-visible outages instead of routine reconnects. +const MONITOR_DEGRADE_GRACE_MS = 10000; type StreamState = { data: MonitorResponse | null; connected: boolean }; @@ -220,16 +227,18 @@ const openSource = (projectId: string, conn: SharedConnection) => { } }); source.onerror = () => { - // Auto-reconnects; mark down meanwhile so consumers fall back to the poll. - conn.state = { connected: false, data: conn.state.data }; - notify(conn); - // Debounce ~3s so a brief reconnect flap doesn't emit a degrade/reconnect pair. + // EventSource auto-reconnects on its own; hold "connected" through the + // grace window so a brief flap doesn't flip the badge or emit a + // degrade/reconnect pair. Only when the stream stays down past the window + // do we mark it down (so consumers fall back to the fast poll) and report. if (!conn.degradeTimer && conn.degradedAt === null) { conn.degradeTimer = setTimeout(() => { conn.degradedAt = Date.now(); + conn.state = { connected: false, data: conn.state.data }; + notify(conn); posthog.capture("monitor_stream_degraded", { project_id: projectId }); conn.degradeTimer = null; - }, 3000); + }, MONITOR_DEGRADE_GRACE_MS); } }; };