fix: preserve queued session refresh options

This commit is contained in:
Shakker
2026-07-31 07:37:21 +01:00
parent 52f81a3684
commit fe5d591cc3
2 changed files with 203 additions and 21 deletions

View File

@@ -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<SessionsListResult>();
const secondList = deferred<SessionsListResult>();
const secondListStarted = deferred<void>();
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<SessionsListResult>();
const secondList = deferred<SessionsListResult>();
const secondListStarted = deferred<void>();
const thirdListStarted = deferred<void>();
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<SessionsListResult>();

View File

@@ -727,7 +727,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
sectionOrder: [],
};
let inFlight: Promise<void> | null = null;
let queuedRefresh: SessionRefreshOptions | null = null;
let queuedExplicitRefresh: SessionRefreshOptions | null = null;
let eventRefreshQueued = false;
let eventRefreshTimer: ReturnType<typeof globalThis.setTimeout> | 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();