mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
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.
This commit is contained in:
parent
e89d1f544c
commit
ff0c5643b8
2 changed files with 150 additions and 0 deletions
|
|
@ -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]
|
||||
|
|
|
|||
114
apps/desktop/e2e/session-compression-and-queue-stop.spec.ts
Normal file
114
apps/desktop/e2e/session-compression-and-queue-stop.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue