mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): terminal messages no longer reload the session roster (#128397)
* perf(ui): avoid redundant terminal roster refreshes * fix(ui): preserve filtered roster refreshes * fix(ui): defer filtered roster snapshots * fix(ui): classify roster snapshot eligibility * fix(ui): fence terminal roster membership
This commit is contained in:
committed by
GitHub
parent
6ce272c2a6
commit
91dc6e102a
@@ -4104,7 +4104,7 @@ ui/src/lib/plugin-activation.ts 3
|
||||
ui/src/lib/session-pull-requests.ts 2
|
||||
ui/src/lib/sessions/custom-groups.ts 5
|
||||
ui/src/lib/sessions/grouping.ts 4
|
||||
ui/src/lib/sessions/index.ts 2
|
||||
ui/src/lib/sessions/index.ts 1
|
||||
ui/src/lib/sessions/navigation.ts 1
|
||||
ui/src/lib/sessions/reconcile.ts 10
|
||||
ui/src/lib/sessions/session-key.ts 4
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
chatSessionListResponse,
|
||||
createChatFlowE2eSuite,
|
||||
expectDefined,
|
||||
expectRequestCountStable,
|
||||
installMockGateway,
|
||||
pauseVirtualClock,
|
||||
requireRecord,
|
||||
@@ -205,13 +206,12 @@ suite.define(() => {
|
||||
},
|
||||
messageId: "terminal-sidebar-reply",
|
||||
messageSeq: 2,
|
||||
session: expectDefined(completed.sessions[0], "completed sidebar session fixture"),
|
||||
sessionKey: key,
|
||||
status: "done",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||
.toBeGreaterThan(listCount);
|
||||
await row.getByText("Repair landed cleanly").waitFor();
|
||||
await expectRequestCountStable(gateway, "sessions.list", listCount);
|
||||
expect(await row.textContent()).not.toContain("[[");
|
||||
if (captureUiProofEnabled) {
|
||||
await page.screenshot({
|
||||
|
||||
@@ -245,12 +245,27 @@ describe("event-driven session list refresh", () => {
|
||||
emitEvent({
|
||||
type: "event",
|
||||
event: "session.message",
|
||||
payload: { sessionKey: key, updatedAt: 1, status: "done" },
|
||||
payload: {
|
||||
sessionKey: key,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
session: {
|
||||
key,
|
||||
kind: "direct",
|
||||
updatedAt: 2,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(calls).toEqual({ canonical: 2, main: 2, research: 1 });
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"sessions.list",
|
||||
expect.objectContaining({ agentId: "main", includeUnknown: false }),
|
||||
);
|
||||
expect(calls).toEqual({ canonical: 1, main: 2, research: 1 });
|
||||
expect(sessions.state.result?.sessions[0]).toMatchObject({
|
||||
key,
|
||||
hasActiveRun: false,
|
||||
@@ -269,6 +284,212 @@ describe("event-driven session list refresh", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes the configured-only roster for a terminal snapshot outside its last list", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mainRow = { key: "agent:main:main", kind: "direct" as const, updatedAt: 1 };
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
return sessionsResult(1, [mainRow]);
|
||||
});
|
||||
const { sessions, emitEvent } = createHarness(
|
||||
request as unknown as GatewayBrowserClient["request"],
|
||||
);
|
||||
|
||||
try {
|
||||
await sessions.refresh({ force: true });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"sessions.list",
|
||||
expect.objectContaining({ configuredAgentsOnly: true }),
|
||||
);
|
||||
request.mockClear();
|
||||
|
||||
emitEvent({
|
||||
type: "event",
|
||||
event: "session.message",
|
||||
payload: {
|
||||
agentId: "local",
|
||||
sessionKey: "agent:local:main",
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
session: {
|
||||
key: "agent:local:main",
|
||||
kind: "direct",
|
||||
updatedAt: 2,
|
||||
archived: false,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sessions.state.result?.sessions).toEqual([mainRow]);
|
||||
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
|
||||
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
"sessions.list",
|
||||
expect.objectContaining({ configuredAgentsOnly: true }),
|
||||
);
|
||||
expect(sessions.state.result?.sessions).toEqual([mainRow]);
|
||||
} finally {
|
||||
sessions.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an archived terminal session until the Gateway replaces the active roster", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mainRow = { key: "agent:main:main", kind: "direct" as const, updatedAt: 1 };
|
||||
const fallbackRow = { key: "agent:main:fallback", kind: "direct" as const, updatedAt: 2 };
|
||||
let listCalls = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
listCalls += 1;
|
||||
return sessionsResult(listCalls, listCalls === 1 ? [mainRow] : [fallbackRow]);
|
||||
});
|
||||
const { sessions, emitEvent } = createHarness(
|
||||
request as unknown as GatewayBrowserClient["request"],
|
||||
);
|
||||
|
||||
try {
|
||||
await sessions.refresh({ force: true });
|
||||
request.mockClear();
|
||||
const visibleRosters: SessionsListResult["sessions"][] = [];
|
||||
let previousRoster = sessions.state.result?.sessions;
|
||||
const unsubscribe = sessions.subscribe((next) => {
|
||||
if (next.result?.sessions !== previousRoster) {
|
||||
previousRoster = next.result?.sessions;
|
||||
visibleRosters.push(next.result?.sessions ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
emitEvent({
|
||||
type: "event",
|
||||
event: "session.message",
|
||||
payload: {
|
||||
sessionKey: mainRow.key,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
session: {
|
||||
...mainRow,
|
||||
updatedAt: 3,
|
||||
archived: true,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sessions.state.result?.sessions).toEqual([mainRow]);
|
||||
expect(visibleRosters).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
|
||||
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
"sessions.list",
|
||||
expect.objectContaining({ configuredAgentsOnly: true }),
|
||||
);
|
||||
expect(visibleRosters).toEqual([[fallbackRow]]);
|
||||
expect(sessions.state.result?.sessions).toEqual([fallbackRow]);
|
||||
unsubscribe();
|
||||
} finally {
|
||||
sessions.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
filter: "ownerId",
|
||||
query: { ownerId: "profile-ada" },
|
||||
applyFilter: (sessions: ReturnType<typeof createSessionCapability>) =>
|
||||
sessions.setOwnerFilter("profile-ada"),
|
||||
},
|
||||
{
|
||||
filter: "involvingMe",
|
||||
query: { involvingMe: true },
|
||||
applyFilter: (sessions: ReturnType<typeof createSessionCapability>) =>
|
||||
sessions.setInvolvingMeFilter(true),
|
||||
},
|
||||
{
|
||||
filter: "search",
|
||||
query: { search: "Ada" },
|
||||
applyFilter: (sessions: ReturnType<typeof createSessionCapability>) =>
|
||||
sessions.refresh({ agentId: "main", search: "Ada", force: true }),
|
||||
},
|
||||
])(
|
||||
"refreshes the $filter-filtered primary roster after a terminal session message",
|
||||
async ({ applyFilter, query }) => {
|
||||
vi.useFakeTimers();
|
||||
const key = "agent:main:main";
|
||||
let matchesFilteredRoster = true;
|
||||
const row = {
|
||||
key,
|
||||
kind: "direct" as const,
|
||||
updatedAt: 1,
|
||||
hasActiveRun: true,
|
||||
status: "running" as const,
|
||||
label: "Ada",
|
||||
owner: { actor: { type: "human" as const, id: "profile-ada", label: "Ada" } },
|
||||
};
|
||||
const request = vi.fn(
|
||||
async (
|
||||
method: string,
|
||||
params?: { ownerId?: string; involvingMe?: boolean; search?: string },
|
||||
) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
const filtered = Boolean(params?.ownerId || params?.involvingMe || params?.search);
|
||||
return sessionsResult(
|
||||
matchesFilteredRoster ? 1 : 2,
|
||||
filtered && !matchesFilteredRoster ? [] : [row],
|
||||
);
|
||||
},
|
||||
);
|
||||
const { sessions, emitEvent } = createHarness(
|
||||
request as unknown as GatewayBrowserClient["request"],
|
||||
);
|
||||
|
||||
try {
|
||||
await sessions.refresh({ agentId: "main", force: true });
|
||||
await applyFilter(sessions);
|
||||
expect(sessions.state.result?.sessions.map((session) => session.key)).toEqual([key]);
|
||||
request.mockClear();
|
||||
matchesFilteredRoster = false;
|
||||
|
||||
emitEvent({
|
||||
type: "event",
|
||||
event: "session.message",
|
||||
payload: {
|
||||
sessionKey: key,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
session: {
|
||||
...row,
|
||||
updatedAt: 2,
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
label: "Bob",
|
||||
owner: { actor: { type: "human", id: "profile-bob", label: "Bob" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sessions.state.result?.sessions).toEqual([row]);
|
||||
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("sessions.list", expect.objectContaining(query));
|
||||
expect(sessions.state.result?.sessions).toEqual([]);
|
||||
} finally {
|
||||
sessions.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains every loaded page when a session event replaces the canonical list", async () => {
|
||||
vi.useFakeTimers();
|
||||
const rows = Array.from({ length: 120 }, (_, index) => ({
|
||||
|
||||
@@ -415,22 +415,44 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
resultAgentId: state.agentId,
|
||||
archivedFilter: roster.lastOptions().archivedFilter,
|
||||
});
|
||||
if (eventInfo?.archived !== null) {
|
||||
const payload = event.payload as {
|
||||
agentId?: unknown;
|
||||
reason?: unknown;
|
||||
session?: unknown;
|
||||
} | null;
|
||||
const hasActiveRun = reconciled.hasActiveRun ?? eventInfo?.hasActiveRun;
|
||||
const status = reconciled.status ?? eventInfo?.status;
|
||||
const runEnded =
|
||||
hasActiveRun === false || (status !== null && status !== undefined && status !== "running");
|
||||
const isTerminalMessage = event.event === "session.message" && runEnded;
|
||||
// Only an existing Gateway roster member that remains active can be replaced directly.
|
||||
const primarySnapshotApplied =
|
||||
isTerminalMessage &&
|
||||
reconciled.applied &&
|
||||
eventInfo !== null &&
|
||||
eventInfo.archived !== true &&
|
||||
typeof payload?.session === "object" &&
|
||||
payload.session !== null &&
|
||||
roster.canApplyPrimarySnapshot() &&
|
||||
state.result?.sessions.some((row) =>
|
||||
uiSessionEventMatches(
|
||||
{ ...gateway.snapshot, sessionKey: row.key },
|
||||
eventInfo.key,
|
||||
eventInfo.agentId,
|
||||
),
|
||||
) === true;
|
||||
if ((eventInfo?.archived !== null && !isTerminalMessage) || primarySnapshotApplied) {
|
||||
const result = decorateRows(reconciled.result);
|
||||
if (result !== state.result) {
|
||||
publishReconciledState({ ...state, result });
|
||||
}
|
||||
}
|
||||
const eventReason = (event.payload as { reason?: unknown } | null)?.reason;
|
||||
const payloadAgentId = (event.payload as { agentId?: unknown } | null)?.agentId;
|
||||
const eventReason = payload?.reason;
|
||||
const payloadAgentId = payload?.agentId;
|
||||
if (eventReason === "groups") {
|
||||
groups.invalidate();
|
||||
void groups.load();
|
||||
}
|
||||
const hasActiveRun = reconciled.hasActiveRun ?? eventInfo?.hasActiveRun;
|
||||
const status = reconciled.status ?? eventInfo?.status;
|
||||
const runEnded =
|
||||
hasActiveRun === false || (status !== null && status !== undefined && status !== "running");
|
||||
if (event.event === "session.message" && !runEnded) {
|
||||
return;
|
||||
}
|
||||
@@ -463,12 +485,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
publish({ ...state, deletedSessions: remainingDeletedSessions });
|
||||
}
|
||||
}
|
||||
// Gateway lists own filtering/order; authoritative events invalidate every matching roster.
|
||||
roster.scheduleEvent({
|
||||
agentId:
|
||||
eventInfo?.agentId ??
|
||||
parseAgentSessionKey(eventInfo?.key)?.agentId ??
|
||||
(typeof payloadAgentId === "string" ? payloadAgentId : undefined),
|
||||
primarySnapshotApplied,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -552,8 +552,12 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
|
||||
return refresh({ ...options, force: true });
|
||||
},
|
||||
lastOptions: () => lastListOptions,
|
||||
scheduleEvent(options: { agentId?: string | null } = {}) {
|
||||
eventRefreshCoordinator.schedule();
|
||||
// Gateway-owned membership filters require an authoritative list refresh.
|
||||
canApplyPrimarySnapshot: () => isPrimarySessionListQuery(lastListOptions),
|
||||
scheduleEvent(options: { agentId?: string | null; primarySnapshotApplied?: boolean } = {}) {
|
||||
if (!options.primarySnapshotApplied) {
|
||||
eventRefreshCoordinator.schedule();
|
||||
}
|
||||
const agentId = options.agentId ? normalizeAgentId(options.agentId) : null;
|
||||
for (const entry of managedLists.values()) {
|
||||
const queryAgentId = managedSessionListAgentId(entry);
|
||||
|
||||
Reference in New Issue
Block a user