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:
majdyz
2026-04-22 07:12:18 +07:00
parent 7ef10b26c0
commit 37de838652
3 changed files with 115 additions and 6 deletions

View File

@@ -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();
});
});

View File

@@ -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.
*

View File

@@ -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(() => {