Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions echo/frontend/src/hooks/useConversationMonitor.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, (event: Event) => 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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};

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());
});
});
19 changes: 14 additions & 5 deletions echo/frontend/src/hooks/useConversationMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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);
}
};
};
Expand Down
Loading