mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): refresh session windows after events (#103134)
* fix(ui): refresh session windows after events * chore: defer session fix release note
This commit is contained in:
committed by
GitHub
parent
e251bc4c2f
commit
af3a76f320
@@ -76,19 +76,35 @@ describeControlUiE2e("Control UI session-list event scope", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await gateway.deferNext("sessions.list");
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
session: {
|
||||
key: "agent:local:hidden",
|
||||
kind: "direct",
|
||||
label: hiddenLabel,
|
||||
updatedAt: 2,
|
||||
},
|
||||
sessionKey: "agent:local:hidden",
|
||||
reason: "create",
|
||||
key: "agent:local:hidden",
|
||||
kind: "direct",
|
||||
label: hiddenLabel,
|
||||
updatedAt: 2,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||
.toBeGreaterThan(requestsBeforeEvent.length);
|
||||
expect(await currentPage.getByText(hiddenLabel, { exact: true }).count()).toBe(0);
|
||||
await gateway.resolveDeferred("sessions.list", {
|
||||
count: 1,
|
||||
defaults: { contextTokens: null, model: null, modelProvider: null },
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:visible",
|
||||
kind: "direct",
|
||||
label: visibleLabel,
|
||||
updatedAt: 3,
|
||||
},
|
||||
],
|
||||
ts: 3,
|
||||
});
|
||||
await visibleOverviewRow.waitFor();
|
||||
expect(await currentPage.getByText(hiddenLabel, { exact: true }).count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,13 +70,13 @@ function sessionChangedEvent(key: string): GatewayEventFrame {
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: {
|
||||
session: {
|
||||
key,
|
||||
kind: "direct",
|
||||
updatedAt: 2,
|
||||
sessionId: "hidden-session",
|
||||
label: "Hidden",
|
||||
},
|
||||
sessionKey: key,
|
||||
reason: "create",
|
||||
key,
|
||||
kind: "direct",
|
||||
updatedAt: 2,
|
||||
sessionId: "hidden-session",
|
||||
label: "Hidden",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -408,11 +408,14 @@ describe("createSessionCapability", () => {
|
||||
it("refreshes instead of inserting hidden sessions after configured-only lists", async () => {
|
||||
const visibleKey = "agent:main:main";
|
||||
const hiddenKey = "agent:local:hidden";
|
||||
const refreshed = deferred<SessionsListResult>();
|
||||
let listCalls = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
return sessionsResult(
|
||||
listCalls += 1;
|
||||
const result = sessionsResult(
|
||||
[
|
||||
{
|
||||
key: visibleKey,
|
||||
@@ -423,6 +426,7 @@ describe("createSessionCapability", () => {
|
||||
],
|
||||
1,
|
||||
);
|
||||
return listCalls === 1 ? result : await refreshed.promise;
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { gateway, emitEvent } = createGatewayHarness(client);
|
||||
@@ -431,20 +435,62 @@ describe("createSessionCapability", () => {
|
||||
await sessions.refresh({ force: true });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"sessions.list",
|
||||
expect.objectContaining({ configuredAgentsOnly: true }),
|
||||
expect.objectContaining({ configuredAgentsOnly: true, limit: 50 }),
|
||||
);
|
||||
const publishedKeys: string[][] = [];
|
||||
sessions.subscribe((next) => {
|
||||
publishedKeys.push(next.result?.sessions.map((row) => row.key) ?? []);
|
||||
});
|
||||
|
||||
emitEvent(sessionChangedEvent(hiddenKey));
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
expect(sessions.state.result?.sessions.map((row) => row.key)).toEqual([visibleKey]);
|
||||
expect(publishedKeys.some((keys) => keys.includes(hiddenKey))).toBe(false);
|
||||
refreshed.resolve(sessionsResult([{ key: visibleKey, kind: "direct", updatedAt: 1 }], 2));
|
||||
await vi.waitFor(() => expect(sessions.state.loading).toBe(false));
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("reconciles broad events when configured-agent filtering is explicitly disabled", async () => {
|
||||
it("publishes remote deletion before refreshing the canonical list", async () => {
|
||||
const visibleKey = "agent:main:main";
|
||||
const refreshed = deferred<SessionsListResult>();
|
||||
let listCalls = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
listCalls += 1;
|
||||
const result = sessionsResult([{ key: visibleKey, kind: "direct", updatedAt: 1 }], 1);
|
||||
return listCalls === 1 ? result : await refreshed.promise;
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { gateway, emitEvent } = createGatewayHarness(client);
|
||||
const sessions = createSessionCapability(gateway);
|
||||
|
||||
await sessions.refresh({ force: true });
|
||||
const deletedSnapshots: string[][] = [];
|
||||
sessions.subscribe((next) => {
|
||||
deletedSnapshots.push(next.deletedSessions.map((target) => target.key));
|
||||
});
|
||||
|
||||
emitEvent({
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: { sessionKey: visibleKey, reason: "delete" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
expect(deletedSnapshots.some((keys) => keys.includes(visibleKey))).toBe(true);
|
||||
refreshed.resolve(sessionsResult([], 2));
|
||||
await vi.waitFor(() => expect(sessions.state.loading).toBe(false));
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("refreshes broad lists when the client omits the server-side window limit", async () => {
|
||||
const visibleKey = "agent:main:main";
|
||||
const hiddenKey = "agent:local:hidden";
|
||||
const request = vi.fn(async (method: string) => {
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
@@ -454,20 +500,21 @@ describe("createSessionCapability", () => {
|
||||
const { gateway, emitEvent } = createGatewayHarness(client);
|
||||
const sessions = createSessionCapability(gateway);
|
||||
|
||||
await sessions.refresh({ configuredAgentsOnly: false, force: true });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"sessions.list",
|
||||
await sessions.refresh({ configuredAgentsOnly: false, force: true, limit: 0 });
|
||||
const requestParams = request.mock.calls[0]?.[1];
|
||||
expect(requestParams).toEqual(
|
||||
expect.objectContaining({
|
||||
configuredAgentsOnly: false,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
}),
|
||||
);
|
||||
expect(requestParams).not.toHaveProperty("limit");
|
||||
|
||||
emitEvent(sessionChangedEvent(hiddenKey));
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(sessions.state.result?.sessions.map((row) => row.key)).toContain(hiddenKey);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
expect(sessions.state.result?.sessions.map((row) => row.key)).not.toContain(hiddenKey);
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -230,10 +230,7 @@ export type {
|
||||
SessionScopeHostWithKey,
|
||||
} from "./navigation.ts";
|
||||
|
||||
type EffectiveSessionListOptions = SessionListOptions &
|
||||
Required<Pick<SessionListOptions, "includeGlobal" | "includeUnknown" | "configuredAgentsOnly">>;
|
||||
|
||||
const SESSION_LIST_DEFAULTS = {
|
||||
const SESSION_LIST_PARAMS = {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
configuredAgentsOnly: true,
|
||||
@@ -251,29 +248,24 @@ function buildSessionRequestParams(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEffectiveSessionListOptions(
|
||||
options: SessionListOptions = {},
|
||||
): EffectiveSessionListOptions {
|
||||
return {
|
||||
...options,
|
||||
includeGlobal: options.includeGlobal ?? SESSION_LIST_DEFAULTS.includeGlobal,
|
||||
includeUnknown: options.includeUnknown ?? SESSION_LIST_DEFAULTS.includeUnknown,
|
||||
configuredAgentsOnly:
|
||||
options.configuredAgentsOnly ?? SESSION_LIST_DEFAULTS.configuredAgentsOnly,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionListParams(options: EffectiveSessionListOptions): Record<string, unknown> {
|
||||
function buildSessionListParams(options: SessionListOptions = {}): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {
|
||||
includeGlobal: options.includeGlobal,
|
||||
includeUnknown: options.includeUnknown,
|
||||
configuredAgentsOnly: options.configuredAgentsOnly,
|
||||
...SESSION_LIST_PARAMS,
|
||||
};
|
||||
if (options.limit === undefined) {
|
||||
params.limit = 50;
|
||||
} else if (options.limit > 0) {
|
||||
params.limit = Math.floor(options.limit);
|
||||
}
|
||||
if (options.includeGlobal !== undefined) {
|
||||
params.includeGlobal = options.includeGlobal;
|
||||
}
|
||||
if (options.includeUnknown !== undefined) {
|
||||
params.includeUnknown = options.includeUnknown;
|
||||
}
|
||||
if (options.configuredAgentsOnly !== undefined) {
|
||||
params.configuredAgentsOnly = options.configuredAgentsOnly;
|
||||
}
|
||||
if (options.showArchived === true) {
|
||||
params.archived = true;
|
||||
}
|
||||
@@ -302,7 +294,7 @@ function buildSessionListParams(options: EffectiveSessionListOptions): Record<st
|
||||
|
||||
async function requestSessionList(
|
||||
client: SessionRequestClient,
|
||||
options: EffectiveSessionListOptions,
|
||||
options: SessionListOptions = {},
|
||||
): Promise<SessionsListResult | null> {
|
||||
const result = await client.request<SessionsListResult | undefined>(
|
||||
"sessions.list",
|
||||
@@ -495,18 +487,6 @@ function isSessionStateEvent(event: GatewayEventFrame): boolean {
|
||||
return event.event === "sessions.changed" || event.event === "session.message";
|
||||
}
|
||||
|
||||
function canReconcileSessionEvent(options: SessionListOptions): boolean {
|
||||
return (
|
||||
options.activeMinutes === undefined &&
|
||||
options.search === undefined &&
|
||||
options.offset === undefined &&
|
||||
options.limit === undefined &&
|
||||
options.includeGlobal !== false &&
|
||||
options.includeUnknown !== false &&
|
||||
options.configuredAgentsOnly !== true
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcileSessionRunTerminal(
|
||||
result: SessionsListResult | null,
|
||||
terminal: SessionRunTerminal,
|
||||
@@ -615,10 +595,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
const result = await requestSessionList(
|
||||
scope.client,
|
||||
resolveEffectiveSessionListOptions(options),
|
||||
);
|
||||
const result = await requestSessionList(scope.client, options);
|
||||
return isCurrentConnection(scope) ? (result ?? null) : null;
|
||||
};
|
||||
|
||||
@@ -666,10 +643,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
const { append = false, force: _force, backgroundHydrate = false, ...listOptions } = options;
|
||||
const requestOptions = resolveEffectiveSessionListOptions(listOptions);
|
||||
// Event reconciliation must retain the boolean filters sent to sessions.list.
|
||||
// Otherwise broad events can insert rows that the Gateway deliberately excluded.
|
||||
const { append = false, force: _force, backgroundHydrate = false, ...requestOptions } = options;
|
||||
lastListOptions = requestOptions;
|
||||
if (!backgroundHydrate) {
|
||||
publish({ ...state, loading: true, error: null, deletedSessions: [] });
|
||||
@@ -1178,35 +1152,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
if (event.event === "session.message" && !runEnded) {
|
||||
return;
|
||||
}
|
||||
if (!canReconcileSessionEvent(lastListOptions)) {
|
||||
void refresh({ ...lastListOptions, force: true });
|
||||
return;
|
||||
}
|
||||
const priorRow =
|
||||
reconciled.row ??
|
||||
(eventInfo
|
||||
? state.result?.sessions.find((row) => areUiSessionKeysEquivalent(row.key, eventInfo.key))
|
||||
: undefined);
|
||||
const activeRunClearNeedsRefresh = runEnded && priorRow?.hasActiveRun === true;
|
||||
if (activeRunClearNeedsRefresh) {
|
||||
// Terminal lifecycle events can omit hasActiveRun. Re-list when the
|
||||
// stale-row guard preserves an active row after the run has ended.
|
||||
void refresh({ ...lastListOptions, force: true });
|
||||
return;
|
||||
}
|
||||
if (reconciled.applied) {
|
||||
if (reconciled.result !== state.result || reconciled.deletedKey) {
|
||||
publish({
|
||||
...state,
|
||||
result: reconciled.result,
|
||||
error: null,
|
||||
deletedSessions: reconciled.deletedKey
|
||||
? [{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined }]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
return;
|
||||
if (reconciled.deletedKey) {
|
||||
// Preserve remote-deletion navigation before the canonical refresh
|
||||
// clears transient event state.
|
||||
publish({
|
||||
...state,
|
||||
deletedSessions: [
|
||||
{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined },
|
||||
],
|
||||
});
|
||||
}
|
||||
// Gateway lists are filtered and windowed. Events cannot preserve server
|
||||
// membership or ordering, so the coalesced refresh remains canonical.
|
||||
void refresh({ ...lastListOptions, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user