From 2479081415abe3360e7d29f08e0e26ca8817c459 Mon Sep 17 00:00:00 2001 From: ddonaldson130 Date: Wed, 8 Jul 2026 12:47:13 -0400 Subject: [PATCH] test(FUX-039): add cross-instance toast dedupe regression test Co-authored-by: Fusion --- .../__tests__/RuntimeFallbackBadge.test.tsx | 39 +++++++++++ .../app/hooks/useRuntimeFallbackStatus.ts | 68 ++++++++++++++++--- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx index 349cc74b3a..1db788b5bc 100644 --- a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx +++ b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; import { RuntimeFallbackBadge } from "../RuntimeFallbackBadge"; import { ToastProvider, useToast } from "../../hooks/useToast"; +import { __resetRuntimeFallbackToastDedupeStoreForTests } from "../../hooks/useRuntimeFallbackStatus"; import type { TaskRuntimeFallbackResponse } from "../../api/legacy"; const legacyMocks = vi.hoisted(() => ({ @@ -88,6 +89,10 @@ describe("RuntimeFallbackBadge", () => { beforeEach(() => { vi.useFakeTimers(); legacyMocks.fetchTaskRuntimeFallback.mockReset(); + // The toast dedupe store is module-level/shared by design (that is the + // fix under test) — reset it between test cases so one test's "already + // toasted" state does not leak into the next. + __resetRuntimeFallbackToastDedupeStoreForTests(); }); afterEach(() => { @@ -238,12 +243,46 @@ describe("RuntimeFallbackBadge", () => { expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled(); expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument(); }); + + it("fires exactly one toast when the same task/event is observed by two simultaneously-mounted badge instances (e.g. ActiveAgentsPanel + AgentsView rendering the same task concurrently)", async () => { + legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint); + + render( + + + + + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Both instances polled and both observed the same new eventId on their + // first poll, but the shared module-level dedupe store means only one of + // them should have won the "claim" and fired a toast. + expect(screen.getAllByTestId("toast-entry")).toHaveLength(1); + expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes"); + + // Both badges still render independently (dedupe only affects the toast, + // not the per-instance badge display). + expect(screen.getAllByTestId("runtime-fallback-badge")).toHaveLength(2); + + // Further polls with the same event on both instances must not add a + // second toast either. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(screen.getAllByTestId("toast-entry")).toHaveLength(1); + }); }); describe("RuntimeFallbackBadge — mobile breakpoint", () => { beforeEach(() => { vi.useFakeTimers(); legacyMocks.fetchTaskRuntimeFallback.mockReset(); + __resetRuntimeFallbackToastDedupeStoreForTests(); }); afterEach(() => { diff --git a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts index 9a876f4978..cf65dff81e 100644 --- a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts +++ b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts @@ -14,11 +14,65 @@ * This hook only polls while `enabled` is true (callers should pass * `isInViewport` so off-screen cards do not generate background traffic). */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy"; const POLL_INTERVAL_MS = 30_000; +// Toast dedupe must be shared across ALL hook instances in the process, not +// scoped per-instance: the same task/event can be observed simultaneously by +// multiple mounted badges (e.g. ActiveAgentsPanel + AgentsView board/list + +// TaskCard all rendering the same in-progress task at once), each running +// its own useRuntimeFallbackStatus() call. A per-instance ref only dedupes +// within one component instance's own poll history, so the same eventId +// would independently look "newly observed" to every instance and fire one +// toast each. Module-level state is shared across every call site because +// there is exactly one copy of this module per process/bundle. +// +// Keyed by `${taskId}:${eventId}` (not eventId alone) so ids are unambiguous +// even if two different tasks' audit logs ever produced colliding event ids. +// Bounded via a simple FIFO eviction (insertion order === Map iteration +// order) so a long-lived dashboard session touching many tasks over many +// hours cannot grow this unboundedly; runtime-fallback events are rare +// (at most one per agent session), so a few hundred entries comfortably +// covers realistic session lengths without needing TTL bookkeeping. +const MAX_TOASTED_EVENTS = 500; +const toastedEventKeys = new Map(); + +function toastKey(taskId: string, eventId: string): string { + return `${taskId}:${eventId}`; +} + +/** + * Returns true and records the key the first time it is seen; returns false + * on every subsequent call for the same key, regardless of which hook + * instance/component asks. This is the single shared gate all simultaneously + * mounted badge instances for the same task funnel through. + */ +function claimToastOnce(taskId: string, eventId: string): boolean { + const key = toastKey(taskId, eventId); + if (toastedEventKeys.has(key)) { + return false; + } + toastedEventKeys.set(key, true); + if (toastedEventKeys.size > MAX_TOASTED_EVENTS) { + const oldestKey = toastedEventKeys.keys().next().value; + if (oldestKey !== undefined) { + toastedEventKeys.delete(oldestKey); + } + } + return true; +} + +/** + * Test-only escape hatch: clears the shared module-level dedupe store between + * test cases so one test's "already toasted" state cannot leak into the + * next. Not used by production code paths. + */ +export function __resetRuntimeFallbackToastDedupeStoreForTests(): void { + toastedEventKeys.clear(); +} + export interface RuntimeFallbackStatus { /** True only when the latest resolution has wasConfigured=false and a non-empty runtimeHint. */ showBadge: boolean; @@ -55,10 +109,6 @@ export function useRuntimeFallbackStatus( projectId?: string, ): RuntimeFallbackStatus { const [status, setStatus] = useState(IDLE_STATUS); - // Dedupe key for toasts: last audit event ID we already toasted for. Persists across - // polls/re-renders for the lifetime of the component so the toast fires exactly once - // per newly-observed fallback session, not on every poll. - const lastToastedEventIdRef = useRef(null); useEffect(() => { if (!enabled || !taskId) { @@ -83,10 +133,10 @@ export function useRuntimeFallbackStatus( return; } - const isNewlyObserved = data.eventId !== null && data.eventId !== lastToastedEventIdRef.current; - if (isNewlyObserved && data.eventId) { - lastToastedEventIdRef.current = data.eventId; - } + // Dedupe against the shared module-level store (not a per-instance ref) + // so a fallback event toasts exactly once across every simultaneously + // mounted badge instance for this task, not once per instance. + const isNewlyObserved = data.eventId !== null && taskId !== undefined && claimToastOnce(taskId, data.eventId); setStatus({ showBadge: true,