mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
test(frontend/copilot): cover reconnect debounce window
Extract the inline debounce logic in `useCopilotStream.handleReconnect` into a pure `shouldDebounceReconnect(lastResumeAt, now, windowMs)` helper and cover it with 10 vitest cases (first-reconnect pass-through, inside window coalesce, boundary, beyond window, custom window, burst simulation). The hook wiring shrinks to two lines and the decision surface is 100% covered by unit tests — useful for codecov/patch on the frontend diff.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
formatNotificationTitle,
|
||||
getSendSuppressionReason,
|
||||
parseSessionIDs,
|
||||
shouldDebounceReconnect,
|
||||
shouldSuppressDuplicateSend,
|
||||
} from "./helpers";
|
||||
|
||||
@@ -466,3 +467,88 @@ describe("deduplicateMessages", () => {
|
||||
expect(result).toHaveLength(2); // duplicate step-start messages are deduped
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDebounceReconnect", () => {
|
||||
const WINDOW_MS = 1_500;
|
||||
|
||||
it("returns null for the first reconnect (lastResumeAt === 0)", () => {
|
||||
expect(shouldDebounceReconnect(0, 10_000, WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a negative lastResumeAt sentinel", () => {
|
||||
// Defensive: a negative value is still treated as "no reconnect yet".
|
||||
expect(shouldDebounceReconnect(-1, 10_000, WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the remaining delay when now is inside the window", () => {
|
||||
// 500ms since the last resume — the caller must wait another 1000ms
|
||||
// before the storm cap reopens.
|
||||
const remaining = shouldDebounceReconnect(1_000, 1_500, WINDOW_MS);
|
||||
expect(remaining).toBe(1_000);
|
||||
});
|
||||
|
||||
it("coalesces a reconnect that arrives immediately after the previous resume", () => {
|
||||
// now === lastResumeAt → sinceLastResume === 0, so the full window remains.
|
||||
const remaining = shouldDebounceReconnect(5_000, 5_000, WINDOW_MS);
|
||||
expect(remaining).toBe(WINDOW_MS);
|
||||
});
|
||||
|
||||
it("returns null when exactly on the window boundary", () => {
|
||||
// sinceLastResume === windowMs is NOT inside the window — the next
|
||||
// reconnect should fire immediately.
|
||||
expect(shouldDebounceReconnect(1_000, 2_500, WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the window has elapsed", () => {
|
||||
expect(shouldDebounceReconnect(1_000, 5_000, WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a small remaining delay at the far edge of the window", () => {
|
||||
// 1ms before the window closes → 1ms left.
|
||||
const remaining = shouldDebounceReconnect(1_000, 2_499, WINDOW_MS);
|
||||
expect(remaining).toBe(1);
|
||||
});
|
||||
|
||||
it("collapses a burst of reconnects into one debounced scheduling", () => {
|
||||
// Simulates the browser tab-throttle storm: three reconnect calls fire
|
||||
// within a single second after the last resume. Only the first slot
|
||||
// would actually run; subsequent calls must always be coalesced.
|
||||
const lastResumeAt = 10_000;
|
||||
const firstCallRemaining = shouldDebounceReconnect(
|
||||
lastResumeAt,
|
||||
10_100,
|
||||
WINDOW_MS,
|
||||
);
|
||||
const secondCallRemaining = shouldDebounceReconnect(
|
||||
lastResumeAt,
|
||||
10_200,
|
||||
WINDOW_MS,
|
||||
);
|
||||
const thirdCallRemaining = shouldDebounceReconnect(
|
||||
lastResumeAt,
|
||||
10_300,
|
||||
WINDOW_MS,
|
||||
);
|
||||
expect(firstCallRemaining).toBe(1_400);
|
||||
expect(secondCallRemaining).toBe(1_300);
|
||||
expect(thirdCallRemaining).toBe(1_200);
|
||||
});
|
||||
|
||||
it("allows a reconnect to fire immediately once the window has passed", () => {
|
||||
// After the window expires, a retry that came in earlier can now fire
|
||||
// rather than stalling the loop. Guards against the regression that
|
||||
// motivated the coalesce-instead-of-drop fix.
|
||||
const lastResumeAt = 10_000;
|
||||
expect(
|
||||
shouldDebounceReconnect(lastResumeAt, 10_500, WINDOW_MS),
|
||||
).not.toBeNull();
|
||||
expect(shouldDebounceReconnect(lastResumeAt, 11_500, WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("honours a custom windowMs value", () => {
|
||||
// Shouldn't hard-code 1500 anywhere: the helper is generic over the
|
||||
// window.
|
||||
expect(shouldDebounceReconnect(1_000, 1_500, 2_000)).toBe(1_500);
|
||||
expect(shouldDebounceReconnect(1_000, 3_500, 2_000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,28 @@ export function disconnectSessionStream(sessionId: string): void {
|
||||
deleteV2DisconnectSessionStream(sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a reconnect request must be coalesced onto the debounce
|
||||
* window boundary, rather than firing immediately.
|
||||
*
|
||||
* Returns the remaining milliseconds until the window closes (so the caller
|
||||
* can schedule a `setTimeout` for that delay) when the previous resume
|
||||
* happened inside the window, or `null` to let the reconnect proceed now.
|
||||
*
|
||||
* `lastResumeAt === 0` signals "no reconnect has fired yet in this session"
|
||||
* — the first reconnect always passes through regardless of `now`.
|
||||
*/
|
||||
export function shouldDebounceReconnect(
|
||||
lastResumeAt: number,
|
||||
now: number,
|
||||
windowMs: number,
|
||||
): number | null {
|
||||
if (lastResumeAt <= 0) return null;
|
||||
const sinceLastResume = now - lastResumeAt;
|
||||
if (sinceLastResume >= windowMs) return null;
|
||||
return windowMs - sinceLastResume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate messages by ID and by consecutive content fingerprint.
|
||||
*
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveInProgressTools,
|
||||
getSendSuppressionReason,
|
||||
disconnectSessionStream,
|
||||
shouldDebounceReconnect,
|
||||
} from "./helpers";
|
||||
import type { CopilotLlmModel, CopilotMode } from "./store";
|
||||
import { useHydrateOnStreamEnd } from "./useHydrateOnStreamEnd";
|
||||
@@ -155,12 +156,12 @@ export function useCopilotStream({
|
||||
// quickly — e.g. a 502 on GET /stream that trips onError inside 500 ms
|
||||
// while the 1500 ms window is still open. Scheduling the retry for
|
||||
// the remaining window preserves both the storm cap and the retry.
|
||||
const sinceLastResume = Date.now() - lastReconnectResumeAtRef.current;
|
||||
if (
|
||||
lastReconnectResumeAtRef.current > 0 &&
|
||||
sinceLastResume < RECONNECT_DEBOUNCE_MS
|
||||
) {
|
||||
const remainingDelay = RECONNECT_DEBOUNCE_MS - sinceLastResume;
|
||||
const remainingDelay = shouldDebounceReconnect(
|
||||
lastReconnectResumeAtRef.current,
|
||||
Date.now(),
|
||||
RECONNECT_DEBOUNCE_MS,
|
||||
);
|
||||
if (remainingDelay !== null) {
|
||||
isReconnectScheduledRef.current = true;
|
||||
setIsReconnectScheduled(true);
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user