From 33c8164015b0dec0a7317b47f18612061b59bca5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 16:41:31 +0100 Subject: [PATCH] test(app): allow the Updating messages toast when the daemon answers slowly A freshness check unanswered after 500ms reports Updating messages by design, but the resume spec asserted no toast regardless of how long the daemon took. On a loaded runner a cold reload's answer took 512ms, so the toast was correct and the spec failed. Assert no toast only when the answer came back promptly, using answer times the gate records. --- .../e2e/browser/agent-timeline-resume.spec.ts | 6 ++-- .../support/helpers/daemon-websocket-gate.ts | 24 ++++++++++++- .../e2e/support/helpers/timeline-resume.ts | 36 ++++++++++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/app/e2e/browser/agent-timeline-resume.spec.ts b/packages/app/e2e/browser/agent-timeline-resume.spec.ts index 8d164e954a..98dc94ef85 100644 --- a/packages/app/e2e/browser/agent-timeline-resume.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-resume.spec.ts @@ -63,7 +63,7 @@ test.describe("Agent timeline resume", () => { await test.step("back from the background", async () => { const requests = rememberTimelineRequestCounts(gate); - const expectNoUpdatingToast = await watchForUpdatingToast(page); + const expectNoUpdatingToast = await watchForUpdatingToast(page, gate, requests); await hideTabAndReturn(page); await waitForResumeAnswer(gate, requests); @@ -75,7 +75,7 @@ test.describe("Agent timeline resume", () => { await test.step("back from a dropped connection", async () => { const requests = rememberTimelineRequestCounts(gate); - const expectNoUpdatingToast = await watchForUpdatingToast(page); + const expectNoUpdatingToast = await watchForUpdatingToast(page, gate, requests); await disconnectViewedTimeline(page, gate); await restoreUnchangedViewedTimeline(page, gate); @@ -88,7 +88,7 @@ test.describe("Agent timeline resume", () => { await test.step("back from reopening the app", async () => { const requests = rememberTimelineRequestCounts(gate); - const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page); + const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page, gate, requests); await waitForResumeAnswer(gate, requests); await expectTimelinePromptVisible(page, agent.newestPrompt); diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index c33976879f..64b13ba92a 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -328,8 +328,10 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n }; const clientRequestCounts = new Map(); const clientRequests = new Map(); + const clientRequestTimes = new Map(); const timelineRequestCounts = new Map(); const serverMessageCounts = new Map(); + const serverMessageTimes = new Map(); const agentStreamEventCounts = new Map(); const agentStreamItemCounts = new Map(); const serverMessageWaiters = new Set<() => void>(); @@ -363,8 +365,13 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n const messageType = typeof message?.type === "string" ? message.type : null; const itemType = readAgentStreamItemType(message); const eventType = readAgentStreamEventType(message); - if (messageType) + if (messageType) { serverMessageCounts.set(messageType, (serverMessageCounts.get(messageType) ?? 0) + 1); + serverMessageTimes.set(messageType, [ + ...(serverMessageTimes.get(messageType) ?? []), + Date.now(), + ]); + } if (itemType) agentStreamItemCounts.set(itemType, (agentStreamItemCounts.get(itemType) ?? 0) + 1); if (eventType) @@ -481,6 +488,10 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n const requests = clientRequests.get(request.type) ?? []; requests.push(request); clientRequests.set(request.type, requests); + clientRequestTimes.set(request.type, [ + ...(clientRequestTimes.get(request.type) ?? []), + Date.now(), + ]); } if (request?.type === heldClientRequestType) { heldClientRequest = { server, message }; @@ -851,6 +862,17 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n getClientRequestCount(type: string): number { return clientRequestCounts.get(type) ?? 0; }, + /** The longest the daemon took to answer a request of this type since `fromIndex`, in ms. */ + getSlowestAnswerMs(requestType: string, responseType: string, fromIndex = 0): number { + const answers = serverMessageTimes.get(responseType) ?? []; + const latencies = (clientRequestTimes.get(requestType) ?? []) + .slice(fromIndex) + .flatMap((askedAt) => { + const answeredAt = answers.find((at) => at >= askedAt); + return answeredAt === undefined ? [] : [answeredAt - askedAt]; + }); + return Math.max(0, ...latencies); + }, getClientRequests(type: string): ReadonlyArray { return [...(clientRequests.get(type) ?? [])]; }, diff --git a/packages/app/e2e/support/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts index d0ffd4156e..3dbbb40b1b 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -13,6 +13,12 @@ const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request"; const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response"; const TIMELINE_REQUEST = "fetch_agent_timeline_request"; const TIMELINE_RESPONSE = "fetch_agent_timeline_response"; +/** + * A check unanswered after `FRESHNESS_CHECK_QUIET_MS` (500) reports Updating messages by design, and + * a loaded runner can take that long. The gate sees the answer before the app does, so this stops + * short of 500. + */ +const SLOW_ANSWER_MS = 300; export interface TimelineRequestCounts { agentId?: string; @@ -78,7 +84,11 @@ export async function waitForResumeAnswer( } /** Records every appearance of the toast, including one that lasts a single commit. */ -export async function watchForUpdatingToast(page: Page): Promise<() => Promise> { +export async function watchForUpdatingToast( + page: Page, + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): Promise<() => Promise> { const watch = await page.evaluateHandle(() => { let shown = false; const check = () => { @@ -98,12 +108,16 @@ export async function watchForUpdatingToast(page: Page): Promise<() => Promise recorder.stop()); await watch.dispose(); - expect(shown, "the Updating messages toast appeared").toBe(false); + expectNoToastForPromptAnswer(shown, gate, before); }; } /** Reopens the app on this chat, recording the toast from the first paint of the new page. */ -export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise> { +export async function reopenAppWatchingForUpdatingToast( + page: Page, + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): Promise<() => Promise> { await page.addInitScript(() => { const recorder = window as Window & { updatingToastShown?: boolean }; new MutationObserver(() => { @@ -117,10 +131,24 @@ export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() const shown = await page.evaluate( () => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true, ); - expect(shown, "the Updating messages toast appeared").toBe(false); + expectNoToastForPromptAnswer(shown, gate, before); }; } +function expectNoToastForPromptAnswer( + shown: boolean, + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): void { + const slowest = gate.getSlowestAnswerMs( + SUBSCRIPTION_REQUEST, + SUBSCRIPTION_RESPONSE, + before.subscriptions, + ); + if (slowest >= SLOW_ANSWER_MS) return; + expect(shown, "the Updating messages toast appeared").toBe(false); +} + async function waitForNextPaint(page: Page): Promise { await page.evaluate( () =>