diff --git a/apps/desktop/e2e/queue-turn-boundary.spec.ts b/apps/desktop/e2e/queue-turn-boundary.spec.ts new file mode 100644 index 000000000000..834a5fe949e4 --- /dev/null +++ b/apps/desktop/e2e/queue-turn-boundary.spec.ts @@ -0,0 +1,69 @@ +/** + * A queued prompt must remain local until the current inference turn settles. + * + * Hold the first streamed reply open after its first token. This gives the + * composer a live, busy turn while the user queues a follow-up, then lets us + * assert against the mock provider's real request log before and after the + * held turn completes. + */ + +import { expect, test, type Page } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { MOCK_REPLY } from './mock-server' + +const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE' +const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED' + +async function send(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +test.describe('queued prompt turn boundary', () => { + let fixture: MockBackendFixture | null = null + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT } + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('submits a queued prompt only after the active turn completes', async () => { + const { mock, page } = fixture! + const composer = page.locator('[contenteditable="true"]').first() + const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]') + + await send(page, ACTIVE_PROMPT) + await mock.waitForHeldStream() + + await composer.click() + await composer.type(QUEUED_PROMPT, { delay: 5 }) + await queue.click() + await expect(page.getByText('1 Queued')).toBeVisible() + + // The mock keeps the active SSE stream open, so a queued prompt has no + // completed-turn boundary that could legitimately drain it. Wait past the + // queue retry interval and assert the provider saw only the active turn. + await page.waitForTimeout(1_000) + expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0) + await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT) + + mock.releaseHeldStream() + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 } + ) + await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1) + }) +}) \ No newline at end of file