mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
perf(ui): stop long-lived request and session caches from growing forever (#127818)
* perf(ui): bound long-lived request and session state * fix(gateway-client): prevent request id collisions * fix(ui): preserve ordering across agent caches * fix(ui): preserve session order after pruning * fix(ui): retain cached session order during loading * fix(ui): evict removed agent session caches * test(ui): cover canonical session cache pruning
This commit is contained in:
committed by
GitHub
parent
6ed8954c8a
commit
d75164c46d
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GatewayPendingRequests } from "./pending-request.js";
|
||||
|
||||
describe("GatewayPendingRequests", () => {
|
||||
it("does not retain settled IDs for the socket generation", async () => {
|
||||
let requestId = 0;
|
||||
const requests = new GatewayPendingRequests({
|
||||
createRequestId: () => `request-${requestId++}`,
|
||||
nowMs: () => 0,
|
||||
});
|
||||
const sender = {
|
||||
send: () => {
|
||||
throw new Error("synthetic send failure");
|
||||
},
|
||||
};
|
||||
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
await requests.request(sender, "bounded", {}, { timeoutMs: null }).catch(() => undefined);
|
||||
}
|
||||
|
||||
const retained = (requests as unknown as { retiredIds?: ReadonlySet<string> }).retiredIds;
|
||||
expect(retained?.size ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -46,8 +46,7 @@ type GatewayPendingRequestsOptions = {
|
||||
/** Owns request deadlines, correlation, settlement, and generation-scoped IDs. */
|
||||
export class GatewayPendingRequests {
|
||||
private readonly pending = new Map<string, GatewayPendingRequest>();
|
||||
private readonly retiredIds = new Set<string>();
|
||||
private collisionSuffix = 0;
|
||||
private requestSequence = 0;
|
||||
|
||||
constructor(private readonly opts: GatewayPendingRequestsOptions) {}
|
||||
|
||||
@@ -101,7 +100,6 @@ export class GatewayPendingRequests {
|
||||
return false;
|
||||
}
|
||||
this.pending.delete(id);
|
||||
this.retiredIds.add(id);
|
||||
cleanup();
|
||||
this.finishTiming(id, pending, false, errorCode);
|
||||
return true;
|
||||
@@ -186,23 +184,14 @@ export class GatewayPendingRequests {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
// IDs are tombstoned only for one socket generation. Retired socket frames
|
||||
// are fenced by GatewayProtocolClient before a replacement generation runs.
|
||||
this.retiredIds.clear();
|
||||
this.collisionSuffix = 0;
|
||||
// Request sequences belong to one socket generation. Retired socket frames
|
||||
// are fenced by GatewayProtocolClient before the sequence restarts.
|
||||
this.requestSequence = 0;
|
||||
}
|
||||
|
||||
private allocateRequestId(): string {
|
||||
const id = this.opts.createRequestId();
|
||||
if (!this.pending.has(id) && !this.retiredIds.has(id)) {
|
||||
return id;
|
||||
}
|
||||
let uniqueId: string;
|
||||
do {
|
||||
this.collisionSuffix += 1;
|
||||
uniqueId = `${id}:${this.collisionSuffix}`;
|
||||
} while (this.pending.has(uniqueId) || this.retiredIds.has(uniqueId));
|
||||
return uniqueId;
|
||||
this.requestSequence += 1;
|
||||
return `${this.requestSequence}:${this.opts.createRequestId()}`;
|
||||
}
|
||||
|
||||
private finishTiming(
|
||||
|
||||
@@ -210,20 +210,45 @@ describe("GatewayProtocolClient requests", () => {
|
||||
await expect(aborted).rejects.toThrow("gateway request aborted for aborted");
|
||||
|
||||
const replacement = client.request("replacement", {}, { timeoutMs: null });
|
||||
expect(latestFrame(connection)).toMatchObject({ id: "same-id:1", method: "replacement" });
|
||||
respond(connection, "same-id", { stale: true });
|
||||
expect(latestFrame(connection)).toMatchObject({ id: "2:same-id", method: "replacement" });
|
||||
respond(connection, "1:same-id", { stale: true });
|
||||
expect(client.hasPendingRequests).toBe(true);
|
||||
respond(connection, "same-id:1", { current: true });
|
||||
respond(connection, "2:same-id", { current: true });
|
||||
await expect(replacement).resolves.toEqual({ current: true });
|
||||
|
||||
await expect(client.request("send.failure", {}, { timeoutMs: null })).rejects.toThrow(
|
||||
"synthetic send failure",
|
||||
);
|
||||
expect(latestFrame(connection)).toMatchObject({ id: "same-id:2", method: "send.failure" });
|
||||
expect(latestFrame(connection)).toMatchObject({ id: "3:same-id", method: "send.failure" });
|
||||
expect(client.hasPendingRequests).toBe(false);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("keeps concurrent requests distinct when generated IDs contain sequence suffixes", async () => {
|
||||
const generatedIds = ["same-id:1", "same-id"];
|
||||
const { client, connections } = createRequestHarness({
|
||||
createRequestId: () => generatedIds.shift() ?? "same-id",
|
||||
});
|
||||
const connection = connections[0];
|
||||
if (!connection) {
|
||||
throw new Error("expected request connection");
|
||||
}
|
||||
|
||||
const first = client.request("first", {}, { timeoutMs: null });
|
||||
const second = client.request("second", {}, { timeoutMs: null });
|
||||
const [firstFrame, secondFrame] = connection.frames;
|
||||
if (!firstFrame || !secondFrame) {
|
||||
throw new Error("expected concurrent request frames");
|
||||
}
|
||||
expect(firstFrame.id).not.toBe(secondFrame.id);
|
||||
|
||||
respond(connection, firstFrame.id, { request: "first" });
|
||||
respond(connection, secondFrame.id, { request: "second" });
|
||||
await expect(first).resolves.toEqual({ request: "first" });
|
||||
await expect(second).resolves.toEqual({ request: "second" });
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("ignores late accepted and final replies after a timeout collision", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onAccepted = vi.fn();
|
||||
@@ -242,14 +267,14 @@ describe("GatewayProtocolClient requests", () => {
|
||||
{},
|
||||
{ timeoutMs: null, expectFinal: true, onAccepted },
|
||||
);
|
||||
expect(latestFrame(connection)).toMatchObject({ id: "same-id:1", method: "agent" });
|
||||
respond(connection, "same-id", { status: "accepted", runId: "old" });
|
||||
respond(connection, "same-id", { status: "ok", runId: "old" });
|
||||
expect(latestFrame(connection)).toMatchObject({ id: "2:same-id", method: "agent" });
|
||||
respond(connection, "1:same-id", { status: "accepted", runId: "old" });
|
||||
respond(connection, "1:same-id", { status: "ok", runId: "old" });
|
||||
expect(onAccepted).not.toHaveBeenCalled();
|
||||
expect(client.hasPendingRequests).toBe(true);
|
||||
|
||||
respond(connection, "same-id:1", { status: "accepted", runId: "new" });
|
||||
respond(connection, "same-id:1", { status: "ok", runId: "new" });
|
||||
respond(connection, "2:same-id", { status: "accepted", runId: "new" });
|
||||
respond(connection, "2:same-id", { status: "ok", runId: "new" });
|
||||
await expect(replacement).resolves.toEqual({ status: "ok", runId: "new" });
|
||||
expect(onAccepted).toHaveBeenCalledExactlyOnceWith({ status: "accepted", runId: "new" });
|
||||
client.stop();
|
||||
@@ -463,7 +488,7 @@ describe("GatewayProtocolClient requests", () => {
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("clears generation tombstones when the socket flushes", async () => {
|
||||
it("restarts the request sequence when the socket flushes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, connections } = createRequestHarness({ createRequestId: () => "same-id" });
|
||||
const firstConnection = connections[0];
|
||||
@@ -482,8 +507,8 @@ describe("GatewayProtocolClient requests", () => {
|
||||
throw new Error("expected replacement request connection");
|
||||
}
|
||||
const replacement = client.request("second", {}, { timeoutMs: null });
|
||||
expect(latestFrame(secondConnection)).toMatchObject({ id: "same-id", method: "second" });
|
||||
respond(secondConnection, "same-id", { ok: true });
|
||||
expect(latestFrame(secondConnection)).toMatchObject({ id: "1:same-id", method: "second" });
|
||||
respond(secondConnection, "1:same-id", { ok: true });
|
||||
await expect(replacement).resolves.toEqual({ ok: true });
|
||||
client.stop();
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ type ConnectFrame = {
|
||||
};
|
||||
};
|
||||
|
||||
const REQUEST_FRAME_ID = "00000000-0000-4000-8000-000000000000";
|
||||
const REQUEST_FRAME_ID = "2:00000000-0000-4000-8000-000000000000";
|
||||
|
||||
function requestFrameBytes(method: string, params?: unknown): number {
|
||||
const frame =
|
||||
@@ -1679,7 +1679,7 @@ describe("GatewayBrowserClient", () => {
|
||||
|
||||
const { connectFrame } = await startConnect(client);
|
||||
|
||||
expect(connectFrame.id).toBe("req-insecure");
|
||||
expect(connectFrame.id).toBe("1:req-insecure");
|
||||
expect(connectFrame.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth).toEqual({
|
||||
token: "shared-auth-token",
|
||||
@@ -1699,7 +1699,7 @@ describe("GatewayBrowserClient", () => {
|
||||
|
||||
const { connectFrame } = await startConnect(client);
|
||||
|
||||
expect(connectFrame.id).toBe("req-insecure");
|
||||
expect(connectFrame.id).toBe("1:req-insecure");
|
||||
expect(connectFrame.method).toBe("connect");
|
||||
expect(connectFrame.params?.auth).toEqual({
|
||||
token: undefined,
|
||||
|
||||
+18
-10
@@ -296,7 +296,24 @@ export class GatewayBrowserClient {
|
||||
|
||||
constructor(private opts: GatewayBrowserClientOptions) {
|
||||
this.client = new GatewayProtocolClient<ConnectPlan>({
|
||||
createSocket: (handlers) => createBrowserGatewaySocket(this.opts.url, handlers),
|
||||
createSocket: (handlers) => {
|
||||
this.maxPayloadBytes = undefined;
|
||||
const socket = createBrowserGatewaySocket(this.opts.url, handlers);
|
||||
return {
|
||||
...socket,
|
||||
send: (data) => {
|
||||
if (
|
||||
this.maxPayloadBytes !== undefined &&
|
||||
new TextEncoder().encode(data).byteLength > this.maxPayloadBytes
|
||||
) {
|
||||
throw new Error(
|
||||
"Request exceeds the Gateway payload limit. Shorten the message or remove one or more attachments and retry.",
|
||||
);
|
||||
}
|
||||
socket.send(data);
|
||||
},
|
||||
};
|
||||
},
|
||||
createRequestId: generateUUID,
|
||||
createRequestError: (error) =>
|
||||
new GatewayRequestError({
|
||||
@@ -654,15 +671,6 @@ export class GatewayBrowserClient {
|
||||
params?: unknown,
|
||||
options?: GatewayProtocolRequestOptions,
|
||||
): Promise<T> {
|
||||
// The UUID request envelope adds 75 bytes with params, 61 when params is omitted.
|
||||
const requestBytes =
|
||||
new TextEncoder().encode(JSON.stringify([method, params])).byteLength +
|
||||
(params === undefined ? 61 : 75);
|
||||
if (this.maxPayloadBytes !== undefined && requestBytes > this.maxPayloadBytes) {
|
||||
throw new Error(
|
||||
"Request exceeds the Gateway payload limit. Shorten the message or remove one or more attachments and retry.",
|
||||
);
|
||||
}
|
||||
return await this.client.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionsListResult } from "../api/types.ts";
|
||||
import { compareSidebarSessionRowsByMode } from "./app-sidebar-session-navigation-logic.ts";
|
||||
import { publishSidebarSessionList } from "./session-data-controller-events.ts";
|
||||
|
||||
describe("publishSidebarSessionList", () => {
|
||||
const createOwner = () => ({
|
||||
context: undefined,
|
||||
sessionCreatedOrder: new Map<string, number>(),
|
||||
sessionResultsByAgent: {} as Record<string, SessionsListResult>,
|
||||
sessionsResult: null as SessionsListResult | null,
|
||||
sessionsAgentId: null as string | null,
|
||||
sessionsLoading: false,
|
||||
sessionMutationError: null,
|
||||
expandedAgentId: () => "main",
|
||||
requestSessionDataUpdate: () => undefined,
|
||||
});
|
||||
|
||||
const publish = (owner: ReturnType<typeof createOwner>, agentId: string | null, keys: string[]) =>
|
||||
publishSidebarSessionList(owner, {
|
||||
result: {
|
||||
sessions: keys.map((key, index) => ({ key, kind: "direct", updatedAt: index })),
|
||||
count: keys.length,
|
||||
} as SessionsListResult,
|
||||
agentId,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
it("keeps observed creation order only for rows in the current accumulated result", () => {
|
||||
const owner = createOwner();
|
||||
|
||||
publish(owner, "main", ["first", "second"]);
|
||||
publish(owner, "main", ["second", "third"]);
|
||||
|
||||
expect([...owner.sessionCreatedOrder.keys()]).toEqual(["second", "third"]);
|
||||
});
|
||||
|
||||
it("keeps observed order after pruning and adding a session", () => {
|
||||
const owner = createOwner();
|
||||
|
||||
publish(owner, "main", ["removed", "z-retained"]);
|
||||
publish(owner, "main", ["z-retained"]);
|
||||
publish(owner, "main", ["z-retained", "a-added"]);
|
||||
|
||||
const ordered = owner.sessionResultsByAgent.main?.sessions.toSorted((a, b) =>
|
||||
compareSidebarSessionRowsByMode({
|
||||
a,
|
||||
b,
|
||||
sortMode: "created",
|
||||
owners: undefined,
|
||||
createdOrder: owner.sessionCreatedOrder,
|
||||
}),
|
||||
);
|
||||
expect(ordered?.map((row) => row.key)).toEqual(["z-retained", "a-added"]);
|
||||
});
|
||||
|
||||
it("keeps creation order for every retained agent result", () => {
|
||||
const owner = createOwner();
|
||||
|
||||
publish(owner, "alpha", ["alpha-first", "alpha-second"]);
|
||||
publish(owner, "beta", ["beta-first"]);
|
||||
publish(owner, "alpha", ["alpha-first", "alpha-second"]);
|
||||
|
||||
expect([...owner.sessionCreatedOrder.keys()]).toEqual([
|
||||
"alpha-first",
|
||||
"alpha-second",
|
||||
"beta-first",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps cached agent order while an uncached agent has no result", () => {
|
||||
const owner = createOwner();
|
||||
|
||||
publish(owner, "alpha", ["alpha-first", "alpha-second"]);
|
||||
publishSidebarSessionList(owner, {
|
||||
result: null,
|
||||
agentId: "beta",
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect([...owner.sessionCreatedOrder.keys()]).toEqual(["alpha-first", "alpha-second"]);
|
||||
});
|
||||
|
||||
it("preserves promoted order for an unscoped canonical result", () => {
|
||||
const owner = createOwner();
|
||||
owner.sessionCreatedOrder.set("first", 1);
|
||||
owner.sessionCreatedOrder.set("second", 0);
|
||||
|
||||
publish(owner, null, ["first", "second"]);
|
||||
|
||||
expect([...owner.sessionCreatedOrder]).toEqual([
|
||||
["first", 1],
|
||||
["second", 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../app/context.ts";
|
||||
import { readPresenceEntries, type PresencePayload } from "../app/user-profile.ts";
|
||||
import type { AgentCapability } from "../lib/agents/index.ts";
|
||||
import type { SessionCapability, SessionListSnapshot } from "../lib/sessions/index.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import {
|
||||
@@ -20,6 +21,63 @@ type SidebarSessionListOwner = {
|
||||
requestSessionDataUpdate(): void;
|
||||
};
|
||||
|
||||
function pruneSidebarSessionOrder(
|
||||
owner: SidebarSessionListOwner,
|
||||
retainedResults: readonly NonNullable<SessionListSnapshot["result"]>[],
|
||||
): void {
|
||||
const visibleKeys = new Set(
|
||||
retainedResults.flatMap((result) => result.sessions.map((row) => row.key).filter(Boolean)),
|
||||
);
|
||||
for (const key of owner.sessionCreatedOrder.keys()) {
|
||||
if (!visibleKeys.has(key)) {
|
||||
owner.sessionCreatedOrder.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneSidebarAgentSessionCaches(
|
||||
owner: SidebarSessionListOwner,
|
||||
agentIds: readonly string[],
|
||||
): void {
|
||||
const retainedAgentIds = new Set(agentIds.map(normalizeAgentId));
|
||||
for (const agentId of Object.keys(owner.sessionResultsByAgent)) {
|
||||
if (!retainedAgentIds.has(agentId)) {
|
||||
delete owner.sessionResultsByAgent[agentId];
|
||||
}
|
||||
}
|
||||
if (owner.sessionsAgentId && !retainedAgentIds.has(normalizeAgentId(owner.sessionsAgentId))) {
|
||||
owner.sessionsResult = null;
|
||||
owner.sessionsAgentId = null;
|
||||
}
|
||||
const retainedResults = Object.values(owner.sessionResultsByAgent);
|
||||
if (owner.sessionsResult) {
|
||||
retainedResults.push(owner.sessionsResult);
|
||||
}
|
||||
pruneSidebarSessionOrder(owner, retainedResults);
|
||||
}
|
||||
|
||||
export function subscribeSidebarAgentSessionCaches(
|
||||
agents: AgentCapability,
|
||||
owner: SidebarSessionListOwner,
|
||||
notify: () => void,
|
||||
): () => void {
|
||||
const synchronize = () => {
|
||||
const roster = agents.state.agentsList;
|
||||
// A null roster is transient during reconnect; only a concrete list can evict agent caches.
|
||||
if (roster) {
|
||||
pruneSidebarAgentSessionCaches(
|
||||
owner,
|
||||
roster.agents.map((agent) => agent.id),
|
||||
);
|
||||
}
|
||||
};
|
||||
synchronize();
|
||||
return agents.subscribe(() => {
|
||||
synchronize();
|
||||
notify();
|
||||
});
|
||||
}
|
||||
|
||||
function filteredSidebarSessionQuery(agentId: string, archivedFilter: SidebarSessionStatusFilter) {
|
||||
return {
|
||||
agentId,
|
||||
@@ -36,14 +94,23 @@ export function publishSidebarSessionList(
|
||||
): void {
|
||||
owner.sessionsResult = snapshot.result;
|
||||
owner.sessionsAgentId = snapshot.agentId;
|
||||
for (const row of snapshot.result?.sessions ?? []) {
|
||||
if (row.key && !owner.sessionCreatedOrder.has(row.key)) {
|
||||
owner.sessionCreatedOrder.set(row.key, owner.sessionCreatedOrder.size);
|
||||
}
|
||||
}
|
||||
const sessions = snapshot.result?.sessions ?? [];
|
||||
if (snapshot.result && snapshot.agentId) {
|
||||
owner.sessionResultsByAgent[normalizeAgentId(snapshot.agentId)] = snapshot.result;
|
||||
}
|
||||
const retainedResults = snapshot.result
|
||||
? [snapshot.result, ...Object.values(owner.sessionResultsByAgent)]
|
||||
: Object.values(owner.sessionResultsByAgent);
|
||||
pruneSidebarSessionOrder(owner, retainedResults);
|
||||
let nextCreatedOrder = 0;
|
||||
for (const order of owner.sessionCreatedOrder.values()) {
|
||||
nextCreatedOrder = Math.max(nextCreatedOrder, order + 1);
|
||||
}
|
||||
for (const row of sessions) {
|
||||
if (row.key && !owner.sessionCreatedOrder.has(row.key)) {
|
||||
owner.sessionCreatedOrder.set(row.key, nextCreatedOrder++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeFilteredSidebarSessions(
|
||||
|
||||
@@ -25,6 +25,13 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo
|
||||
kind: "direct" as const,
|
||||
updatedAt: index + 1,
|
||||
}));
|
||||
const resultForKeys = (keys: string[]) => ({
|
||||
ts: 1,
|
||||
path: "",
|
||||
count: keys.length,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: keys.map((key) => ({ key, kind: "direct" as const })),
|
||||
});
|
||||
const list = vi.fn(async (options?: Parameters<SessionCapability["list"]>[0]) => {
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 60;
|
||||
@@ -86,19 +93,24 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo
|
||||
const sessions = createSessionCapability(gateway);
|
||||
let selectedAgentId = "main";
|
||||
let selectedStatusFilter = statusFilter;
|
||||
const agentsState = {
|
||||
connected: true,
|
||||
client,
|
||||
agentsList: {
|
||||
defaultId: "main",
|
||||
agents: [{ id: "main" }, { id: "research" }],
|
||||
} as ApplicationContext["agents"]["state"]["agentsList"],
|
||||
};
|
||||
const agentListeners = new Set<(state: ApplicationContext["agents"]["state"]) => void>();
|
||||
const context = {
|
||||
gateway,
|
||||
sessions,
|
||||
agents: {
|
||||
state: {
|
||||
connected: true,
|
||||
client,
|
||||
agentsList: {
|
||||
defaultId: "main",
|
||||
agents: [{ id: "main" }, { id: "research" }],
|
||||
},
|
||||
state: agentsState,
|
||||
subscribe(listener: (state: ApplicationContext["agents"]["state"]) => void) {
|
||||
agentListeners.add(listener);
|
||||
return () => agentListeners.delete(listener);
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
},
|
||||
agentSelection: {
|
||||
get state() {
|
||||
@@ -127,6 +139,7 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo
|
||||
return {
|
||||
controller,
|
||||
list,
|
||||
resultForKeys,
|
||||
selectAgent: (agentId: string) => {
|
||||
selectedAgentId = agentId;
|
||||
controller.synchronizeSessionScope();
|
||||
@@ -150,10 +163,63 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo
|
||||
listener(event);
|
||||
}
|
||||
},
|
||||
publishAgentRoster: (agentIds: string[] | null) => {
|
||||
agentsState.agentsList = agentIds
|
||||
? {
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "global",
|
||||
agents: agentIds.map((id) => ({ id })),
|
||||
}
|
||||
: null;
|
||||
for (const listener of agentListeners) {
|
||||
listener(agentsState as ApplicationContext["agents"]["state"]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("filtered sidebar session event refresh", () => {
|
||||
it("evicts cached sessions when an agent leaves the authoritative roster", () => {
|
||||
const { controller, publishAgentRoster, resultForKeys } =
|
||||
createFilteredSessionController("all");
|
||||
controller.hostConnected();
|
||||
controller.sessionResultsByAgent = {
|
||||
main: resultForKeys(["agent:main:kept"]),
|
||||
research: resultForKeys(["agent:research:removed"]),
|
||||
};
|
||||
controller.sessionsResult = controller.sessionResultsByAgent.research ?? null;
|
||||
controller.sessionsAgentId = "research";
|
||||
controller.sessionCreatedOrder = new Map([
|
||||
["agent:main:kept", 0],
|
||||
["agent:research:removed", 1],
|
||||
]);
|
||||
|
||||
publishAgentRoster(null);
|
||||
expect(Object.keys(controller.sessionResultsByAgent)).toEqual(["main", "research"]);
|
||||
|
||||
publishAgentRoster(["main"]);
|
||||
expect(Object.keys(controller.sessionResultsByAgent)).toEqual(["main"]);
|
||||
expect(controller.sessionsResult).toBeNull();
|
||||
expect(controller.sessionsAgentId).toBeNull();
|
||||
expect([...controller.sessionCreatedOrder.keys()]).toEqual(["agent:main:kept"]);
|
||||
controller.hostDisconnected();
|
||||
});
|
||||
|
||||
it("retains the current canonical result outside the per-agent cache", () => {
|
||||
const { controller, publishAgentRoster, resultForKeys } =
|
||||
createFilteredSessionController("all");
|
||||
controller.hostConnected();
|
||||
controller.sessionsResult = resultForKeys(["agent:main:current"]);
|
||||
controller.sessionsAgentId = "main";
|
||||
controller.sessionCreatedOrder = new Map([["agent:main:current", 0]]);
|
||||
|
||||
publishAgentRoster(["main"]);
|
||||
|
||||
expect([...controller.sessionCreatedOrder.keys()]).toEqual(["agent:main:current"]);
|
||||
controller.hostDisconnected();
|
||||
});
|
||||
|
||||
it.each(["archived", "all"] as const)(
|
||||
"refreshes the %s list once for duplicate remote session events",
|
||||
async (statusFilter) => {
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import {
|
||||
publishSidebarSessionList,
|
||||
refreshSidebarSessionList,
|
||||
subscribeSidebarAgentSessionCaches,
|
||||
subscribeFilteredSidebarSessions,
|
||||
subscribeSessionDataGatewayEvents,
|
||||
} from "./session-data-controller-events.ts";
|
||||
@@ -141,7 +142,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
)
|
||||
.watch(
|
||||
() => this.context?.agents,
|
||||
(agents, notify) => agents.subscribe(notify),
|
||||
(agents, notify) => subscribeSidebarAgentSessionCaches(agents, this, notify),
|
||||
)
|
||||
.watch(
|
||||
() => this.context?.agentSelection,
|
||||
@@ -201,10 +202,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
this.gatewayConnected = false;
|
||||
this.retireSessionCatalogData();
|
||||
this.scroll.dispose();
|
||||
if (this.activeSessionLineageRetryTimer) {
|
||||
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
|
||||
this.activeSessionLineageRetryTimer = null;
|
||||
}
|
||||
this.clearActiveSessionLineageRetry();
|
||||
this.subscriptions.hostDisconnected();
|
||||
}
|
||||
|
||||
@@ -365,6 +363,13 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
return this.scroll.state;
|
||||
}
|
||||
|
||||
private clearActiveSessionLineageRetry(): void {
|
||||
if (this.activeSessionLineageRetryTimer) {
|
||||
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
|
||||
this.activeSessionLineageRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateSessionsScrollState(element: HTMLElement): void {
|
||||
this.scroll.update(element);
|
||||
}
|
||||
@@ -387,10 +392,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
}
|
||||
this.activeSessionLineageLoaded = false;
|
||||
this.activeSessionLineageRequest = null;
|
||||
if (this.activeSessionLineageRetryTimer) {
|
||||
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
|
||||
this.activeSessionLineageRetryTimer = null;
|
||||
}
|
||||
this.clearActiveSessionLineageRetry();
|
||||
}
|
||||
|
||||
private readonly updateSessions = (sessions: SessionCapability) => {
|
||||
@@ -624,10 +626,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog
|
||||
this.activeSessionLineageRequest = null;
|
||||
this.activeSessionLineageRoot = null;
|
||||
this.activeSessionLineageSelectedRow = null;
|
||||
if (this.activeSessionLineageRetryTimer) {
|
||||
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
|
||||
this.activeSessionLineageRetryTimer = null;
|
||||
}
|
||||
this.clearActiveSessionLineageRetry();
|
||||
this.notify();
|
||||
}
|
||||
const { gateway, sessions } = this.context ?? {};
|
||||
|
||||
@@ -383,31 +383,31 @@ describe("session connection hydration", () => {
|
||||
|
||||
try {
|
||||
connect();
|
||||
expect(sent).toEqual([{ id: "request", method: "sessions.subscribe" }]);
|
||||
expect(sent).toEqual([{ id: "1:request", method: "sessions.subscribe" }]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS);
|
||||
expect(sent).toContainEqual({ id: "request:1", method: "sessions.list" });
|
||||
respond("request:1", initialResult);
|
||||
expect(sent).toContainEqual({ id: "2:request", method: "sessions.list" });
|
||||
respond("2:request", initialResult);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sessions.state.result).toEqual(initialResult);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(sent).toContainEqual({ id: "request:2", method: "sessions.subscribe" });
|
||||
expect(sent).toContainEqual({ id: "3:request", method: "sessions.subscribe" });
|
||||
|
||||
respond("request", { status: "accepted" });
|
||||
respond("request", { subscribed: true });
|
||||
respond("1:request", { status: "accepted" });
|
||||
respond("1:request", { subscribed: true });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sessions.state.error).not.toBeNull();
|
||||
expect(sent.filter(({ method }) => method === "sessions.list")).toHaveLength(1);
|
||||
|
||||
respond("request:2", { subscribed: true });
|
||||
respond("3:request", { subscribed: true });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent).toContainEqual({ id: "request:3", method: "sessions.list" });
|
||||
respond("request:3", recoveredResult);
|
||||
expect(sent).toContainEqual({ id: "4:request", method: "sessions.list" });
|
||||
respond("4:request", recoveredResult);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
respond("request", { status: "accepted" });
|
||||
respond("request", { subscribed: true });
|
||||
respond("1:request", { status: "accepted" });
|
||||
respond("1:request", { subscribed: true });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(sessions.state.error).toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user