From 6032dec3ba0d4cbff3f996738900d8de1df76321 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 09:27:01 +0100 Subject: [PATCH] fix(ui): make mock sessions archive flow usable (#110556) * fix(ui): complete mock sessions archive flow * fix(ui): scope archive filtering to stateful fixtures --- scripts/control-ui-mock-dev.ts | 21 +++- .../control-ui-e2e.mock-gateway.test.ts | 112 ++++++++++++++++++ ui/src/test-helpers/control-ui-e2e.ts | 86 ++++++++------ 3 files changed, 185 insertions(+), 34 deletions(-) diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 253e61bb8d31..7d7424a99757 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1013,6 +1013,23 @@ async function createChatPickerScenario(): Promise labelPrefix: "Long running session", }), ]; + const archivedSessions = [ + sessionRow("agent:main:archived-launch-notes", "Archived launch notes", baseTime - 86_400_000, { + archived: true, + totalTokens: 42_000, + }), + sessionRow( + "agent:main:discord:channel:archived-lounge", + "#archived-lounge", + baseTime - 172_800_000, + { + archived: true, + channel: "discord", + kind: "group", + totalTokens: 18_000, + }, + ), + ]; const telegramSessions = buildSessionRows({ baseTime: baseTime - 30_000, count: TOTAL_TELEGRAM_SESSIONS, @@ -1046,6 +1063,7 @@ async function createChatPickerScenario(): Promise // (raw persists, hash advances) because config.get ships a raw fixture. "config.get": configMocks.get, "config.schema": configMocks.schema, + "sessions.patch": { ok: true }, "sessions.diff": buildSessionDiffMock(), "plugins.list": buildPluginCatalogMock(), "channels.status": buildChannelsStatusMock(baseTime), @@ -1355,11 +1373,12 @@ async function createChatPickerScenario(): Promise ...searchPrefixes("claude-sonnet-4-6"), ...searchPrefixes("anthropic"), ]), - ...buildSessionListCases(sessions), + ...buildSessionListCases([...sessions, ...archivedSessions]), ], }, }, models: modelProviders.models, + sessionArchiveFiltering: true, sessionKey: "agent:main:main", }; } diff --git a/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts b/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts index ed253dc32548..bb9d7a0aa577 100644 --- a/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts +++ b/ui/src/test-helpers/control-ui-e2e.mock-gateway.test.ts @@ -149,3 +149,115 @@ describe("mock gateway stateful config", () => { socket.close(); }); }); + +describe("mock gateway stateful sessions", () => { + it("keeps archive filtering opt-in for static session fixtures", async () => { + const script = createControlUiMockGatewayInitScript({ + methodResponses: { + "sessions.list": { + count: 1, + defaults: {}, + path: "", + sessions: [{ key: "agent:main:research", archived: false }], + ts: 0, + }, + "sessions.patch": { ok: true }, + }, + }); + window.sessionStorage.clear(); + // oxlint-disable-next-line typescript/no-implied-eval -- Executes the generated init script standalone, proving it captures no module closures. + new Function(script)(); + + const socket = new WebSocket("ws://mock-gateway"); + const frames: ResponseFrame[] = []; + socket.addEventListener("message", (event) => { + frames.push(JSON.parse(String((event as MessageEvent).data)) as ResponseFrame); + }); + await flushMockTimers(); + + socket.send( + JSON.stringify({ + type: "req", + id: "patch-1", + method: "sessions.patch", + params: { key: "agent:main:research", archived: true }, + }), + ); + await flushMockTimers(); + socket.send(JSON.stringify({ type: "req", id: "list-1", method: "sessions.list", params: {} })); + await flushMockTimers(); + + expect(frames.find((frame) => frame.id === "list-1")?.payload).toMatchObject({ + count: 1, + sessions: [{ key: "agent:main:research", archived: false }], + }); + socket.close(); + }); + + it("moves archive patches between active and archived session lists", async () => { + const script = createControlUiMockGatewayInitScript({ + methodResponses: { + "sessions.list": { + count: 2, + defaults: {}, + path: "", + sessions: [ + { key: "agent:main:research", archived: false }, + { key: "agent:main:launch-notes", archived: true }, + ], + ts: 0, + }, + "sessions.patch": { ok: true }, + }, + sessionArchiveFiltering: true, + }); + window.sessionStorage.clear(); + // oxlint-disable-next-line typescript/no-implied-eval -- Executes the generated init script standalone, proving it captures no module closures. + new Function(script)(); + + const socket = new WebSocket("ws://mock-gateway"); + const frames: ResponseFrame[] = []; + socket.addEventListener("message", (event) => { + frames.push(JSON.parse(String((event as MessageEvent).data)) as ResponseFrame); + }); + await flushMockTimers(); + + const request = async (id: string, method: string, params: unknown) => { + socket.send(JSON.stringify({ type: "req", id, method, params })); + await flushMockTimers(); + const response = frames.find((frame) => frame.type === "res" && frame.id === id); + if (!response) { + throw new Error(`No mock response for ${method}`); + } + return response.payload as Record; + }; + const keys = (payload: Record) => + (payload.sessions as Array<{ key: string }>).map((row) => row.key); + + expect(keys(await request("list-1", "sessions.list", {}))).toEqual(["agent:main:research"]); + expect(keys(await request("list-2", "sessions.list", { archived: true }))).toEqual([ + "agent:main:launch-notes", + ]); + expect( + await request("patch-3", "sessions.patch", { + key: "agent:main:research", + archived: true, + }), + ).toEqual({ ok: true }); + expect(keys(await request("list-4", "sessions.list", {}))).toEqual([]); + expect(keys(await request("list-5", "sessions.list", { archived: true }))).toEqual([ + "agent:main:research", + "agent:main:launch-notes", + ]); + + await request("patch-6", "sessions.patch", { + key: "agent:main:launch-notes", + archived: false, + }); + expect(keys(await request("list-7", "sessions.list", {}))).toEqual(["agent:main:launch-notes"]); + expect(keys(await request("list-8", "sessions.list", { archived: true }))).toEqual([ + "agent:main:research", + ]); + socket.close(); + }); +}); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 012beb673ae0..417b4d970f1d 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -67,6 +67,8 @@ export type ControlUiMockGatewayScenario = { inFlightRun?: { runId: string; text?: string; plan?: unknown } | null; /** Session run state served alongside history (hasActiveRun/activeRunIds). */ sessionInfo?: Record | null; + /** Partition sessions.list fixtures by archived state after applying patches. */ + sessionArchiveFiltering?: boolean; models?: Array<{ id: string; name: string; @@ -260,6 +262,7 @@ function normalizeScenario( inFlightRun: scenario.inFlightRun ?? null, models: scenario.models ?? [{ id: "gpt-5.5", name: "gpt-5.5", provider: "openai" }], sessionInfo: scenario.sessionInfo ?? null, + sessionArchiveFiltering: scenario.sessionArchiveFiltering ?? false, sessionKey, sessionGroups: scenario.sessionGroups ?? [], terminalEnabled: scenario.terminalEnabled ?? false, @@ -578,36 +581,48 @@ function installControlUiMockGateway(input: { patch[key] = params[key]; } } + if (scenario.sessionArchiveFiltering && hasOwn(params, "archived")) { + patch.archived = params.archived; + } sessionPatches.set(params.key, patch); } - function applySessionPatches(response: unknown): unknown { + function applySessionPatches(response: unknown, params: unknown): unknown { if (!isRecord(response) || !Array.isArray(response.sessions)) { return response; } + const showArchived = isRecord(params) && params.archived === true; + const sessions = response.sessions.map((row) => { + if (!isRecord(row) || typeof row.key !== "string") { + return row; + } + const patch = sessionPatches.get(row.key); + const next = patch ? { ...row, ...patch } : { ...row }; + // Replay group renames/deletes over static fixtures: the real gateway + // rewrites member categories server-side before the next sessions.list. + let category = typeof next.category === "string" ? next.category : undefined; + for (const rename of groupsState.renames) { + if (category === rename.from) { + category = rename.to ?? undefined; + } + } + if (category === undefined) { + delete next.category; + } else { + next.category = category; + } + return next; + }); + if (!scenario.sessionArchiveFiltering) { + return { ...response, sessions }; + } + const filteredSessions = sessions.filter( + (row) => isRecord(row) && (row.archived === true) === showArchived, + ); return { ...response, - sessions: response.sessions.map((row) => { - if (!isRecord(row) || typeof row.key !== "string") { - return row; - } - const patch = sessionPatches.get(row.key); - const next = patch ? { ...row, ...patch } : { ...row }; - // Replay group renames/deletes over static fixtures: the real gateway - // rewrites member categories server-side before the next sessions.list. - let category = typeof next.category === "string" ? next.category : undefined; - for (const rename of groupsState.renames) { - if (category === rename.from) { - category = rename.to ?? undefined; - } - } - if (category === undefined) { - delete next.category; - } else { - next.category = category; - } - return next; - }), + count: filteredSessions.length, + sessions: filteredSessions, }; } @@ -700,7 +715,9 @@ function installControlUiMockGateway(input: { } const configured = configuredResponse(method, params); if (configured.found) { - return method === "sessions.list" ? applySessionPatches(configured.value) : configured.value; + return method === "sessions.list" + ? applySessionPatches(configured.value, params) + : configured.value; } switch (method) { case "connect": @@ -841,17 +858,20 @@ function installControlUiMockGateway(input: { case "models.list": return { models: scenario.models }; case "sessions.list": - return applySessionPatches({ - count: 1, - defaults: { - contextTokens: null, - model: "gpt-5.5", - modelProvider: "openai", + return applySessionPatches( + { + count: 1, + defaults: { + contextTokens: null, + model: "gpt-5.5", + modelProvider: "openai", + }, + path: "", + sessions: [sessionRow()], + ts: Date.now(), }, - path: "", - sessions: [sessionRow()], - ts: Date.now(), - }); + params, + ); case "sessions.groups.list": return groupsPayload(); case "sessions.groups.put": {