diff --git a/packages/app/e2e/browser/agent-timeline-resume.spec.ts b/packages/app/e2e/browser/agent-timeline-resume.spec.ts index 98dc94ef85..06c373bc00 100644 --- a/packages/app/e2e/browser/agent-timeline-resume.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-resume.spec.ts @@ -11,6 +11,7 @@ import { seedLongMockAgentTimeline, } from "../support/helpers/timeline-pagination"; import { + answerFreshnessCheck, commitTimelineTurnsWhileDisconnected, disconnectViewedTimeline, expectResumeAnsweredWithoutFetch, @@ -21,6 +22,7 @@ import { restoreUnchangedViewedTimeline, restoreViewedTimelineHoldingCatchUp, restoreViewedTimelineWithHeldResponse, + returnWithFreshnessCheckUnanswered, waitForResumeAnswer, reopenAppWatchingForUpdatingToast, watchForUpdatingToast, @@ -63,7 +65,7 @@ test.describe("Agent timeline resume", () => { await test.step("back from the background", async () => { const requests = rememberTimelineRequestCounts(gate); - const expectNoUpdatingToast = await watchForUpdatingToast(page, gate, requests); + const expectNoUpdatingToast = await watchForUpdatingToast(page); await hideTabAndReturn(page); await waitForResumeAnswer(gate, requests); @@ -75,7 +77,7 @@ test.describe("Agent timeline resume", () => { await test.step("back from a dropped connection", async () => { const requests = rememberTimelineRequestCounts(gate); - const expectNoUpdatingToast = await watchForUpdatingToast(page, gate, requests); + const expectNoUpdatingToast = await watchForUpdatingToast(page); await disconnectViewedTimeline(page, gate); await restoreUnchangedViewedTimeline(page, gate); @@ -88,7 +90,7 @@ test.describe("Agent timeline resume", () => { await test.step("back from reopening the app", async () => { const requests = rememberTimelineRequestCounts(gate); - const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page, gate, requests); + const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page); await waitForResumeAnswer(gate, requests); await expectTimelinePromptVisible(page, agent.newestPrompt); @@ -101,6 +103,25 @@ test.describe("Agent timeline resume", () => { } }); + test("a check the daemon has not answered reports the update until it does", async ({ page }) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await seedLongMockAgentTimeline({ turns: 3 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + const requests = rememberTimelineRequestCounts(gate); + + await returnWithFreshnessCheckUnanswered(page, gate); + await answerFreshnessCheck(page, gate); + + expectResumeAnsweredWithoutFetch(gate, requests); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + test("returning to a changed chat reports the update and shows the new messages", async ({ page, }) => { diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 64b13ba92a..c33976879f 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -328,10 +328,8 @@ 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>(); @@ -365,13 +363,8 @@ 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) @@ -488,10 +481,6 @@ 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 }; @@ -862,17 +851,6 @@ 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 3dbbb40b1b..841f9206ef 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -14,11 +14,11 @@ 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. + * `FRESHNESS_CHECK_QUIET_MS` is 500: a check unanswered that long reports Updating messages by + * design, however long the daemon takes. The app starts that wait just before it sends the question + * this helper times, so the bound stops short of 500. */ -const SLOW_ANSWER_MS = 300; +const QUIET_WINDOW_FLOOR_MS = 300; export interface TimelineRequestCounts { agentId?: string; @@ -83,70 +83,88 @@ export async function waitForResumeAnswer( ]); } -/** Records every appearance of the toast, including one that lasts a single commit. */ -export async function watchForUpdatingToast( - page: Page, - gate: DaemonWebSocketGate, - before: TimelineRequestCounts, -): Promise<() => Promise> { - const watch = await page.evaluateHandle(() => { - let shown = false; - const check = () => { - shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null; - }; - const observer = new MutationObserver(check); - observer.observe(document.body, { childList: true, subtree: true }); - check(); - return { - stop() { - observer.disconnect(); - return shown; - }, - }; - }); - return async () => { - await waitForNextPaint(page); - const shown = await watch.evaluate((recorder) => recorder.stop()); - await watch.dispose(); - expectNoToastForPromptAnswer(shown, gate, before); +interface UpdatingToastTiming { + askedAt: number | null; + shownAt: number | null; +} + +declare global { + interface Window { + __updatingToastTiming?: UpdatingToastTiming; + } +} + +/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */ +function recordUpdatingToastTiming(): void { + const noteToast = (timing: UpdatingToastTiming) => { + if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return; + timing.shownAt ??= performance.now(); }; + const existing = window.__updatingToastTiming; + if (existing) { + existing.askedAt = null; + existing.shownAt = null; + noteToast(existing); + return; + } + const timing: UpdatingToastTiming = { askedAt: null, shownAt: null }; + window.__updatingToastTiming = timing; + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function sendRecordingQuestion(data) { + if ( + typeof data === "string" && + data.includes("agent.timeline.set_subscription.request") && + data.includes('"agentIds":["') + ) { + timing.askedAt ??= performance.now(); + } + return send.call(this, data); + }; + new MutationObserver(() => noteToast(timing)).observe(document, { + childList: true, + subtree: true, + }); + noteToast(timing); +} + +/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */ +async function expectToastWaitedForTheDaemon(page: Page): Promise { + await waitForNextPaint(page); + const showedTooSoon = await page.evaluate((floorMs) => { + const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming; + if (shownAt === null) return false; + return askedAt === null || shownAt - askedAt < floorMs; + }, QUIET_WINDOW_FLOOR_MS); + expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false); +} + +/** Records every appearance of the toast, including one that lasts a single commit. */ +export async function watchForUpdatingToast(page: Page): Promise<() => Promise> { + await page.evaluate(recordUpdatingToastTiming); + return () => expectToastWaitedForTheDaemon(page); } /** Reopens the app on this chat, recording the toast from the first paint of the new page. */ -export async function reopenAppWatchingForUpdatingToast( - page: Page, - gate: DaemonWebSocketGate, - before: TimelineRequestCounts, -): Promise<() => Promise> { - await page.addInitScript(() => { - const recorder = window as Window & { updatingToastShown?: boolean }; - new MutationObserver(() => { - recorder.updatingToastShown ||= - document.querySelector('[data-testid="agent-updating-toast"]') !== null; - }).observe(document, { childList: true, subtree: true }); - }); +export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise> { + await page.addInitScript(recordUpdatingToastTiming); await page.reload(); - return async () => { - await waitForNextPaint(page); - const shown = await page.evaluate( - () => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true, - ); - expectNoToastForPromptAnswer(shown, gate, before); - }; + return () => expectToastWaitedForTheDaemon(page); } -function expectNoToastForPromptAnswer( - shown: boolean, +/** Returns to the chat with the daemon's answer held, past the point the toast reports it. */ +export async function returnWithFreshnessCheckUnanswered( + page: Page, 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); +): Promise { + gate.holdNextClientRequest(SUBSCRIPTION_REQUEST); + await hideTabAndReturn(page); + await gate.waitForHeldClientRequest(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible(); +} + +export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise { + gate.releaseHeldClientRequest(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0); } async function waitForNextPaint(page: Page): Promise {