Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export async function launchDesktop(
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mock: Awaited<ReturnType<typeof startMockServer>>
mockUrl: string
sandbox: Sandbox
cleanup: () => Promise<void>
Expand Down Expand Up @@ -346,6 +347,7 @@ export async function setupMockBackend(): Promise<MockBackendFixture> {
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
Expand Down
19 changes: 17 additions & 2 deletions apps/desktop/e2e/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, and `close()`.
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
*/
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
export function startMockServer(): Promise<{
port: number
url: string
receivedPrompts: string[]
close: () => Promise<void>
}> {
return new Promise((resolve, reject) => {
const receivedPrompts: string[] = []
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.
Expand Down Expand Up @@ -75,6 +81,14 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
// malformed JSON — treat as non-streaming with defaults
}

const lastUserMessage = [...(parsed.messages ?? [])]
.reverse()
.find((message: { role?: unknown }) => message?.role === 'user')

if (typeof lastUserMessage?.content === 'string') {
receivedPrompts.push(lastUserMessage.content)
}

const stream = parsed.stream === true
const model = parsed.model || 'mock-model'

Expand Down Expand Up @@ -187,6 +201,7 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
resolve({
port,
url,
receivedPrompts,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/e2e/submit-drift.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Regression coverage for #69578: harmless route-token churn during a send
* must not make the desktop silently drop the prompt before prompt.submit.
*/

import { test, expect } from './test'

import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'

const PROMPT = 'E2E route token drift must still submit this prompt.'

let fixture: MockBackendFixture | null = null

test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})

test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})

test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
const { page, mock } = fixture!
const composer = page.locator('[contenteditable="true"]').first()

await composer.click()
await composer.type(PROMPT, { delay: 10 })

// The submit pipeline snapshots the route synchronously, then awaits session
// creation. Keep changing only the query string of whichever chat route is
// current. Before #69578, comparing the raw route token treated this as a
// user chat switch and aborted before prompt.submit.
await page.evaluate(() => {
let revision = 0
const interval = window.setInterval(() => {
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
}, 1)

;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
window.clearInterval(interval)
}
})

try {
await page.keyboard.press('Enter')

await expect
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
.toBe(true)

await page.waitForFunction(
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
PROMPT,
{ timeout: 15_000 },
)
await page.waitForFunction(
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
undefined,
{ timeout: 60_000 },
)
} finally {
await page.evaluate(() => {
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
})
}

await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
})
Loading