From ff0c5643b80c1449dd4fe4cf630f768c66fb5371 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 21 Jul 2026 21:48:59 -0400 Subject: [PATCH] test(desktop): cover compression and queued stop lifecycle Add real desktop E2E coverage for session compression continuation and queue parking after an explicit Stop. Extend the mock server with a blocking scripted turn and submitted-prompt assertions. --- apps/desktop/e2e/mock-server.ts | 36 ++++++ ...session-compression-and-queue-stop.spec.ts | 114 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 apps/desktop/e2e/session-compression-and-queue-stop.spec.ts diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index 92ce26150b78..d591f2c0052c 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -80,11 +80,24 @@ let _sidebarScriptIndex = 0 /** Per-server counter for the cross-session sidebar script. */ let _sidebarCrossIndex = 0 +/** Per-server counter for the queue-stop script. */ +let _queueStopIndex = 0 + +/** User messages received by the mock, for E2E assertions on real submits. */ +const _receivedUserTexts: string[] = [] + /** Reset the script indices (called between tests via restartMockServer). */ function resetScriptIndex(): void { _scriptIndex = 0 _sidebarScriptIndex = 0 _sidebarCrossIndex = 0 + _queueStopIndex = 0 + _receivedUserTexts.length = 0 +} + +/** Return the user prompts the real backend submitted to this mock server. */ +export function receivedUserTexts(): readonly string[] { + return _receivedUserTexts } // ─── Sidebar-states script ───────────────────────────────────────────── @@ -158,6 +171,14 @@ const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = [ }, ] +const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [ + { + text: 'Starting a task that will keep this turn active.', + toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }], + }, + { text: 'The paused task completed.' }, +] + /** * Start the mock server on an ephemeral port. * @@ -226,9 +247,24 @@ export function startMockServer(): Promise<{ port: number; url: string; close: ( const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : [] const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user') const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '' + if (userText) { + _receivedUserTexts.push(userText) + } const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER') const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER') const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS') + const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER') + + if (isQueueStopTrigger) { + const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1] + _queueStopIndex++ + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } if (isSidebarCrossTrigger) { const turn = SIDEBAR_CROSS_SCRIPT[_sidebarCrossIndex] ?? SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1] diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts new file mode 100644 index 000000000000..a1afa8356e0f --- /dev/null +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -0,0 +1,114 @@ +/** + * E2E coverage for two session lifecycle boundaries that unit tests cannot + * prove: compression rotates a live backend session, and Stop must park rather + * than submit queued prompts. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { receivedUserTexts, restartMockServer } from './mock-server' + +async function send(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(text, { delay: 15 }) + await page.keyboard.press('Enter') +} + +async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise { + await page.waitForFunction( + expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, + text, + { timeout }, + ) +} + +test.describe('session compression', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('compresses an existing session and accepts a follow-up turn on its continuation', async () => { + const { page } = fixture + const reply = 'Hello from the mock inference server! The full boot chain is working.' + + // Three completed exchanges leave a compressible middle after the + // compressor's protected head/tail boundaries. + await send(page, 'E2E_COMPRESSION_FIRST') + await waitForTranscript(page, reply) + await send(page, 'E2E_COMPRESSION_SECOND') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1) + await send(page, 'E2E_COMPRESSION_THIRD') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1) + + await send(page, '/compress preserve the three test turns') + await expect + .poll( + () => page.locator('[data-slot="aui_thread-viewport"]').textContent(), + { timeout: 90_000 }, + ) + .toMatch(/Compressed|No changes from compression/) + + // Compression rotates the agent's live session id. A post-compression + // ordinary turn proves the desktop's runtime binding followed that child. + await send(page, 'E2E_COMPRESSION_FOLLOW_UP') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1) + await waitForTranscript(page, reply) + await page.screenshot({ path: 'test-results/session-compression-continuation.png' }) + }) +}) + +test.describe('queued turn stopped explicitly', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('keeps a queued message paused when Stop interrupts the active turn', async () => { + const { page } = fixture + const queued = 'E2E_QUEUE_STOP_QUEUED_MESSAGE' + + await send(page, 'E2E_QUEUE_STOP_TRIGGER') + await waitForTranscript(page, 'Starting a task that will keep this turn active.', 30_000) + + // A second Enter while busy queues instead of submitting the message. + await send(page, queued) + await expect(page.getByText('1 Queued')).toBeVisible() + + // With an empty composer, the same primary control is Stop. Explicit stop + // must park the queue rather than letting the busy→idle drain submit it. + await page.locator('form').getByLabel('Stop').click() + await expect(page.getByText('1 Queued — paused')).toBeVisible({ timeout: 30_000 }) + + // Give the interrupted turn ample time to settle. The real mock receives + // every prompt the backend submits, so this proves the queued turn stayed + // local rather than merely disappearing from the panel. + await page.waitForTimeout(3_000) + expect(receivedUserTexts()).not.toContain(queued) + await page.screenshot({ path: 'test-results/queued-turn-paused-by-stop.png' }) + }) +})