mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 13:41:39 +00:00
fix: preserve queued session refresh options
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user