fix(ui): prevent stale sidebar attention refreshes (#111061)

This commit is contained in:
Alix-007
2026-07-19 14:37:20 +08:00
committed by GitHub
parent 5a81e9fa81
commit aaacee8166
2 changed files with 142 additions and 2 deletions

View File

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

View File

@@ -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;