From 91dc6e102a2c2326398e10001e6fe90396df5e96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 23:04:23 -0700 Subject: [PATCH] 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 --- config/assertion-safety-baseline.txt | 2 +- ...chat-flow.sidebar-presentation.e2e.test.ts | 6 +- .../lib/sessions/index.event-refresh.test.ts | 227 +++++++++++++++++- ui/src/lib/sessions/index.ts | 38 ++- ui/src/lib/sessions/session-roster-refresh.ts | 8 +- 5 files changed, 264 insertions(+), 17 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index d0af49b020e6..64974e08e493 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts index f5749542911d..1ef2ef7ba9df 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -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({ diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index bc13dbcb269f..2b026001708e 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -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) => + sessions.setOwnerFilter("profile-ada"), + }, + { + filter: "involvingMe", + query: { involvingMe: true }, + applyFilter: (sessions: ReturnType) => + sessions.setInvolvingMeFilter(true), + }, + { + filter: "search", + query: { search: "Ada" }, + applyFilter: (sessions: ReturnType) => + 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) => ({ diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index c7e898907805..1b32ac7b0f28 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -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, }); }); diff --git a/ui/src/lib/sessions/session-roster-refresh.ts b/ui/src/lib/sessions/session-roster-refresh.ts index 205f8e067c8b..1e6523581c4f 100644 --- a/ui/src/lib/sessions/session-roster-refresh.ts +++ b/ui/src/lib/sessions/session-roster-refresh.ts @@ -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);