diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index a198ff8989c..7b14b0ea8f4 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -23,8 +23,10 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c export interface MockServerOptions { /** Pause the matching stream after its first token for session-switch E2E coverage. */ holdFirstStreamForPrompt?: string -/** Pause the first completion whose request JSON contains this text. */ + /** Pause the first completion whose request JSON contains this text. */ holdFirstCompletionContaining?: string +/** Pause the first completion after another hold whose request JSON contains this text. */ +holdFirstCompletionContainingAfterHeldCompletion?: string /** Absolute sandbox path written by the verify-on-stop scripted tool call. */ verificationWritePath?: string } @@ -285,12 +287,20 @@ export function startMockServer(options: MockServerOptions = {}): Promise void) | null = null let releaseHeldStream: (() => void) | null = null let heldCompletionCount = 0 - const heldStreamStarted = new Promise(resolveHeld => { - resolveHeldStreamStarted = resolveHeld - }) - const heldStreamReleased = new Promise(resolveRelease => { - releaseHeldStream = resolveRelease - }) + let heldCompletionAfterHeldCompletion = false + let heldStreamStarted: Promise + let heldStreamReleased: Promise + + const resetHeldStreamGate = (): void => { + heldStreamStarted = new Promise(resolveHeld => { + resolveHeldStreamStarted = resolveHeld + }) + heldStreamReleased = new Promise(resolveRelease => { + releaseHeldStream = resolveRelease + }) + } + + resetHeldStreamGate() const server = http.createServer((req, res) => { // CORS headers — the Electron renderer doesn't need them, but they // don't hurt and make the server usable from a browser context too. @@ -355,6 +365,15 @@ export function startMockServer(options: MockServerOptions = {}): Promise { + streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion || holdThisCompletionAfterHeldCompletion ? () => { if (holdThisCompletion) { heldCompletionCount++ } @@ -470,8 +490,10 @@ export function startMockServer(options: MockServerOptions = {}): Promise nonStreamingTextResponse(res, model, MOCK_REPLY)) } else { @@ -510,7 +532,10 @@ export function startMockServer(options: MockServerOptions = {}): Promise heldStreamStarted, waitForHeldCompletion: () => heldStreamStarted, - releaseHeldStream: () => releaseHeldStream?.(), + releaseHeldStream: () => { + releaseHeldStream?.() + resetHeldStreamGate() + }, heldCompletionCount: () => heldCompletionCount, close: () => new Promise((resolveClose, rejectClose) => { diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index f15b8d0befc..b427a52374e 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -144,3 +144,66 @@ auxiliary: expect(fixture.mock.heldCompletionCount()).toBe(1) }) }) + +test.describe('session compression handoff queueing', () => { + let fixture: MockBackendFixture + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + modelContextLength: 64_000, + extraConfig: `compression: + threshold_tokens: 22000 + protect_first_n: 0 + protect_last_n: 1 +auxiliary: + compression: + provider: custom + model: mock-model`, + mockServer: { + holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.', + holdFirstCompletionContainingAfterHeldCompletion: 'E2E_POST_COMPACTION_RUNNING', + }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + }) + + test('queues an Enter-submitted prompt while the post-compression turn is still running', async ({}, testInfo) => { + const { mock, page } = fixture + const compressionTrigger = 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500) + const running = 'E2E_POST_COMPACTION_RUNNING' + const queued = 'E2E_QUEUED_AFTER_COMPACTION' + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + + // Cross the threshold, complete compaction, then start a separate normal + // turn. This mirrors the user-visible sequence rather than holding the + // turn that caused the compaction itself. + await pasteAndSend(page, 'E2E_POST_COMPACTION_HISTORY_ONE '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_POST_COMPACTION_HISTORY_TWO '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, compressionTrigger) + await mock.waitForHeldCompletion() + expect(mock.heldCompletionCount()).toBe(1) + await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible() + + mock.releaseHeldStream() + await expect(page.getByText(MOCK_REPLY, { exact: true })).toHaveCount(4) + + await pasteAndSend(page, running) + await mock.waitForHeldStream() + + await expect(primary).toHaveAttribute('aria-label', 'Queue message') + await pasteAndSend(page, queued) + + await expect(page.getByText('1 Queued')).toBeVisible() + expect(mock.receivedPrompts).not.toContain(queued) + await page.screenshot({ path: testInfo.outputPath('queued-after-compression-handoff.png') }) + + mock.releaseHeldStream() + await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === queued), { timeout: 30_000 }).toHaveLength(1) + }) +})