From fe5d591cc339dfd87c4b6cc730ae7b652638f90b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 31 Jul 2026 07:37:21 +0100 Subject: [PATCH] fix: preserve queued session refresh options --- .../lib/sessions/index.event-refresh.test.ts | 143 ++++++++++++++++++ ui/src/lib/sessions/index.ts | 81 +++++++--- 2 files changed, 203 insertions(+), 21 deletions(-) diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index 0a74c3332214..7017529a4d06 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -176,6 +176,149 @@ describe("event-driven session list refresh", () => { } }); + it.each([ + { timing: "before", fireBeforeInitialCompletion: true }, + { timing: "after", fireBeforeInitialCompletion: false }, + ])( + "preserves queued explicit options when the event debounce fires $timing the active request completes", + async ({ fireBeforeInitialCompletion }) => { + vi.useFakeTimers(); + const firstList = deferred(); + const secondList = deferred(); + const secondListStarted = deferred(); + let listCalls = 0; + const request = vi.fn(async (method: string) => { + if (method !== "sessions.list") { + throw new Error(`Unexpected request: ${method}`); + } + listCalls += 1; + if (listCalls === 1) { + return await firstList.promise; + } + if (listCalls === 2) { + secondListStarted.resolve(); + return await secondList.promise; + } + return sessionsResult(listCalls); + }); + const { sessions, emitEvent } = createHarness( + request as unknown as GatewayBrowserClient["request"], + ); + + try { + const initialRefresh = sessions.refresh({ agentId: "main", force: true }); + const explicitRefresh = sessions.refresh({ + agentId: "other", + search: "queued", + archivedFilter: "archived", + limit: 17, + includeDerivedTitles: true, + backgroundHydrate: true, + force: true, + }); + + emitEvent(sessionChangedEvent("agent:main:later-event")); + if (fireBeforeInitialCompletion) { + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + expect(request).toHaveBeenCalledTimes(1); + } + + firstList.resolve(sessionsResult(1)); + await secondListStarted.promise; + if (!fireBeforeInitialCompletion) { + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + } + + expect(request.mock.calls[1]?.[1]).toEqual({ + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + limit: 17, + includeDerivedTitles: true, + archived: true, + agentId: "other", + search: "queued", + }); + expect(sessions.state.loading).toBe(false); + + secondList.resolve(sessionsResult(2)); + await Promise.all([initialRefresh, explicitRefresh]); + expect(request).toHaveBeenCalledTimes(2); + } finally { + firstList.resolve(sessionsResult(1)); + secondList.resolve(sessionsResult(2)); + sessions.dispose(); + vi.useRealTimers(); + } + }, + ); + + it("keeps event invalidation after a queued append refresh", async () => { + vi.useFakeTimers(); + const firstList = deferred(); + const secondList = deferred(); + const secondListStarted = deferred(); + const thirdListStarted = deferred(); + let listCalls = 0; + const request = vi.fn(async (method: string) => { + if (method !== "sessions.list") { + throw new Error(`Unexpected request: ${method}`); + } + listCalls += 1; + if (listCalls === 1) { + return await firstList.promise; + } + if (listCalls === 2) { + secondListStarted.resolve(); + return await secondList.promise; + } + if (listCalls === 3) { + thirdListStarted.resolve(); + } + return sessionsResult(listCalls); + }); + const { sessions, emitEvent } = createHarness( + request as unknown as GatewayBrowserClient["request"], + ); + + try { + const initialRefresh = sessions.refresh({ agentId: "main", limit: 25, force: true }); + const appendRefresh = sessions.refresh({ + agentId: "main", + limit: 25, + offset: 25, + append: true, + force: true, + }); + emitEvent(sessionChangedEvent("agent:main:later-event")); + await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS); + + firstList.resolve(sessionsResult(1)); + await secondListStarted.promise; + expect(request.mock.calls[1]?.[1]).toMatchObject({ + agentId: "main", + limit: 25, + offset: 25, + }); + + secondList.resolve(sessionsResult(2)); + await thirdListStarted.promise; + expect(request.mock.calls[2]?.[1]).toMatchObject({ + agentId: "main", + limit: 25, + }); + expect(request.mock.calls[2]?.[1]).not.toHaveProperty("offset"); + + await Promise.all([initialRefresh, appendRefresh]); + expect(request).toHaveBeenCalledTimes(3); + } finally { + firstList.resolve(sessionsResult(1)); + secondList.resolve(sessionsResult(2)); + sessions.dispose(); + vi.useRealTimers(); + } + }); + it("queues one trailing refresh for an event during an in-flight refresh", async () => { vi.useFakeTimers(); const secondList = deferred(); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 05f239c55f0c..863037b0ea2e 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -727,7 +727,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil sectionOrder: [], }; let inFlight: Promise | null = null; - let queuedRefresh: SessionRefreshOptions | null = null; + let queuedExplicitRefresh: SessionRefreshOptions | null = null; + let eventRefreshQueued = false; let eventRefreshTimer: ReturnType | null = null; let eventRefreshDeadline: number | null = null; let canonicalListRevision = 0; @@ -975,6 +976,33 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil } }; + const clearEventRefreshTimer = () => { + if (eventRefreshTimer !== null) { + globalThis.clearTimeout(eventRefreshTimer); + eventRefreshTimer = null; + } + eventRefreshDeadline = null; + }; + + const takeNextQueuedRefresh = (): SessionRefreshOptions | null => { + const explicitRefresh = queuedExplicitRefresh; + queuedExplicitRefresh = null; + if (explicitRefresh) { + // A replacement that has not started yet observes every earlier event. + // Appends still need a canonical replacement after their requested page. + if (explicitRefresh.append !== true) { + clearEventRefreshTimer(); + eventRefreshQueued = false; + } + return explicitRefresh; + } + if (!eventRefreshQueued) { + return null; + } + eventRefreshQueued = false; + return { ...lastListOptions, force: true }; + }; + const drainRefreshQueue = async (options: SessionRefreshOptions) => { const epoch = connectionEpoch; let next: SessionRefreshOptions | null = options; @@ -983,17 +1011,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil if (disposed || connectionEpoch !== epoch) { return; } - next = queuedRefresh; - queuedRefresh = null; + next = takeNextQueuedRefresh(); } }; - const clearEventRefreshTimer = () => { - if (eventRefreshTimer !== null) { - globalThis.clearTimeout(eventRefreshTimer); - eventRefreshTimer = null; - } - eventRefreshDeadline = null; + const startRefresh = (options: SessionRefreshOptions) => { + const request = drainRefreshQueue(options).finally(() => { + if (inFlight === request) { + inFlight = null; + } + }); + inFlight = request; + return request; }; const refresh = (options: SessionRefreshOptions = {}) => { @@ -1003,7 +1032,10 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil if (inFlight) { // An explicit queued refresh subsumes any older event invalidation. clearEventRefreshTimer(); - queuedRefresh = options; + queuedExplicitRefresh = options; + if (options.append !== true) { + eventRefreshQueued = false; + } return inFlight; } const hasListOverrides = Object.entries(options).some( @@ -1014,13 +1046,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil } // An explicit refresh that will issue a request must run now. clearEventRefreshTimer(); - const request = drainRefreshQueue(options).finally(() => { - if (inFlight === request) { - inFlight = null; - } - }); - inFlight = request; - return request; + return startRefresh(options); + }; + + const refreshFromEvent = () => { + if (gateway.snapshot.phase !== "connected" || !gateway.snapshot.client || disposed) { + return Promise.resolve(); + } + if (inFlight) { + eventRefreshQueued = true; + return inFlight; + } + return startRefresh({ ...lastListOptions, force: true }); }; const flushEventRefresh = () => { @@ -1028,7 +1065,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil return; } clearEventRefreshTimer(); - void refresh({ ...lastListOptions, force: true }); + void refreshFromEvent(); }; const scheduleEventRefresh = () => { @@ -1044,7 +1081,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil eventRefreshTimer = globalThis.setTimeout(() => { eventRefreshTimer = null; eventRefreshDeadline = null; - void refresh({ ...lastListOptions, force: true }); + void refreshFromEvent(); }, delay); }; @@ -1818,7 +1855,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil invalidateGroupsLoad(); swarmActivity.clear(); inFlight = null; - queuedRefresh = null; + queuedExplicitRefresh = null; + eventRefreshQueued = false; rollbackPendingModelPatches(); preparedWorkSessionKeys.clear(); pullRequestSummaries.clear(); @@ -2010,7 +2048,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil invalidateGroupsLoad(); connectionConnected = false; inFlight = null; - queuedRefresh = null; + queuedExplicitRefresh = null; + eventRefreshQueued = false; subscribedClient = null; pendingModelPatches.clear(); preparedWorkSessionKeys.clear();