diff --git a/ui/src/e2e/session-list-filter.e2e.test.ts b/ui/src/e2e/session-list-filter.e2e.test.ts index 70558b533354..378c387e49b7 100644 --- a/ui/src/e2e/session-list-filter.e2e.test.ts +++ b/ui/src/e2e/session-list-filter.e2e.test.ts @@ -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); }); }); diff --git a/ui/src/lib/sessions/index.test.ts b/ui/src/lib/sessions/index.test.ts index 7b334a66267f..060926b76aa7 100644 --- a/ui/src/lib/sessions/index.test.ts +++ b/ui/src/lib/sessions/index.test.ts @@ -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(); + 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(); + 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(); }); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 0b0da86b5802..63c070cb920e 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -230,10 +230,7 @@ export type { SessionScopeHostWithKey, } from "./navigation.ts"; -type EffectiveSessionListOptions = SessionListOptions & - Required>; - -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 { +function buildSessionListParams(options: SessionListOptions = {}): Record { const params: Record = { - 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 { const result = await client.request( "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 }); } });