mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 23:21:17 +00:00
fix(ui): prevent stale sidebar attention refreshes (#111061)
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { CronJob, ModelAuthStatusResult } from "../api/types.ts";
|
||||
import type { ApplicationContext, ApplicationGateway } from "../app/context.ts";
|
||||
import type { ExecApprovalRequest } from "../app/exec-approval.ts";
|
||||
import { createApplicationContextProvider } from "../test-helpers/application-context.ts";
|
||||
import { createStorageMock as createTestStorageMock } from "../test-helpers/storage.ts";
|
||||
import { waitForFast } from "../test-helpers/wait-for.ts";
|
||||
import {
|
||||
addDismissal,
|
||||
dismissalStoreKey,
|
||||
@@ -9,6 +15,37 @@ import {
|
||||
type SidebarAttentionKind,
|
||||
} from "./sidebar-attention-dismissals.ts";
|
||||
import { buildSidebarAttentionItems } from "./sidebar-attention-items.ts";
|
||||
import "./sidebar-attention.ts";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function cronJob(id: string): CronJob {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "test" },
|
||||
state: { lastRunStatus: "error" },
|
||||
};
|
||||
}
|
||||
|
||||
type SidebarAttentionElement = HTMLElement & {
|
||||
updateComplete: Promise<boolean>;
|
||||
cronJobs: CronJob[];
|
||||
modelAuthStatus: ModelAuthStatusResult | null;
|
||||
loadedAtMs: number;
|
||||
};
|
||||
|
||||
function approval(id: string): ExecApprovalRequest {
|
||||
return {
|
||||
@@ -57,6 +94,98 @@ describe("pending approval attention", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar attention refresh ownership", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the latest refresh when an older load on the same client finishes last", async () => {
|
||||
const firstCron = deferred<unknown>();
|
||||
const firstAuth = deferred<unknown>();
|
||||
const secondCron = deferred<unknown>();
|
||||
const secondAuth = deferred<unknown>();
|
||||
const responses = {
|
||||
"cron.list": [firstCron, secondCron],
|
||||
"models.authStatus": [firstAuth, secondAuth],
|
||||
};
|
||||
const request = vi.fn((method: keyof typeof responses) => {
|
||||
const response = responses[method].shift();
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
return response.promise;
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
reconnecting: false,
|
||||
hello: null,
|
||||
assistantAgentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
const gateway = {
|
||||
snapshot,
|
||||
connection: {
|
||||
gatewayUrl: "ws://gateway.test",
|
||||
token: "",
|
||||
bootstrapToken: "",
|
||||
password: "",
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
} as unknown as ApplicationGateway;
|
||||
const overlays = {
|
||||
snapshot: { approvalQueue: [] },
|
||||
subscribe: () => () => undefined,
|
||||
} as unknown as ApplicationContext["overlays"];
|
||||
const storage = createTestStorageMock();
|
||||
vi.stubGlobal("localStorage", storage);
|
||||
localStorage.setItem(
|
||||
dismissalStoreKey(gateway.connection.gatewayUrl),
|
||||
JSON.stringify({ cronFailed: "current" }),
|
||||
);
|
||||
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
||||
let now = 120_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
|
||||
const provider = createApplicationContextProvider({ gateway, overlays } as ApplicationContext);
|
||||
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(4));
|
||||
|
||||
const currentAuth = { ts: 2, providers: [] } as ModelAuthStatusResult;
|
||||
now = 200_000;
|
||||
secondCron.resolve({ jobs: [cronJob("current")] });
|
||||
secondAuth.resolve(currentAuth);
|
||||
await waitForFast(() => expect(element.loadedAtMs).toBe(200_000));
|
||||
expect(element.cronJobs.map((job) => job.id)).toEqual(["current"]);
|
||||
expect(element.modelAuthStatus).toBe(currentAuth);
|
||||
expect(localStorage.getItem(dismissalStoreKey(gateway.connection.gatewayUrl))).not.toBeNull();
|
||||
|
||||
now = 300_000;
|
||||
firstCron.resolve({ jobs: [cronJob("stale")] });
|
||||
firstAuth.resolve({ ts: 1, providers: [] });
|
||||
await Promise.all([firstCron.promise, firstAuth.promise]);
|
||||
await new Promise<void>((resolve) => {
|
||||
globalThis.setTimeout(resolve, 0);
|
||||
});
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.cronJobs.map((job) => job.id)).toEqual(["current"]);
|
||||
expect(element.modelAuthStatus).toBe(currentAuth);
|
||||
expect(element.loadedAtMs).toBe(200_000);
|
||||
expect(localStorage.getItem(dismissalStoreKey(gateway.connection.gatewayUrl))).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneDismissals", () => {
|
||||
const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature });
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) onOpenApprovals?: () => void;
|
||||
|
||||
private loadedClient: GatewayBrowserClient | null = null;
|
||||
private loadGeneration = 0;
|
||||
private loadedAtMs = 0;
|
||||
private dismissedScope: string | null = null;
|
||||
private idleRefreshTimer: ReturnType<typeof globalThis.setInterval> | null = null;
|
||||
@@ -101,6 +102,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
this.idleRefreshTimer = null;
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
this.loadGeneration += 1;
|
||||
this.loadedClient = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -113,6 +115,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
this.dismissed = loadDismissals(gatewayUrl);
|
||||
}
|
||||
if (!snapshot.connected || !snapshot.client) {
|
||||
this.loadGeneration += 1;
|
||||
this.loadedClient = null;
|
||||
this.cronJobs = [];
|
||||
this.modelAuthStatus = null;
|
||||
@@ -122,12 +125,20 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
return;
|
||||
}
|
||||
this.loadedClient = snapshot.client;
|
||||
void this.load(gateway, snapshot.client);
|
||||
// Stale refreshes reuse the same client, so identity alone cannot retire
|
||||
// an older completion once the replacement load starts.
|
||||
const generation = ++this.loadGeneration;
|
||||
void this.load(gateway, snapshot.client, generation);
|
||||
}
|
||||
|
||||
private async load(gateway: ApplicationContext["gateway"], client: GatewayBrowserClient) {
|
||||
private async load(
|
||||
gateway: ApplicationContext["gateway"],
|
||||
client: GatewayBrowserClient,
|
||||
generation: number,
|
||||
) {
|
||||
const isCurrent = () =>
|
||||
this.isConnected &&
|
||||
this.loadGeneration === generation &&
|
||||
this.loadedClient === client &&
|
||||
gateway.snapshot.client === client &&
|
||||
gateway.snapshot.connected;
|
||||
|
||||
Reference in New Issue
Block a user