hermes-agent/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts
ethernet 68abf0b33d
test(desktop): add E2E coverage for session lifecycle (#69580)
* test(desktop): e2e test for interim assistant message preservation (#65919)

Adds a Playwright E2E test that reproduces the fix from PR #65919 across
all three layers (agent core → tui_gateway → desktop renderer). The mock
inference server is upgraded with a multi-turn scripted response that
exercises several interleaved patterns:

  1. text + tool_call  → should produce an interim message
  2. text + tool_call  → another interim message
  3. no text + tool_call → NO interim (no visible text alongside tools)
  4. text + tool_call  → another interim message
  5. final answer (stop) → message.complete, different from all interims

Two describe blocks exercise display.interim_assistant_messages both on
(default) and off:
  - ON:  all interim texts + the final answer visible in the transcript
  - OFF: only the final answer visible, all interim texts wiped

Also fixes a footgun: test:e2e now runs `npm run build` as a pretest
hook so the renderer dist/ is always fresh. Previously, running
`npx playwright test` locally would silently load a stale dist/ that
predated renderer fixes — the python backend ran from source (had the
fix) but the renderer was frozen in an old bundle. CI already built
fresh, so the explicit build step there is removed to avoid duplication.

* test(desktop): e2e sidebar states — background dot, subagent, cross-session

Add sidebar-states.spec.ts with three E2E tests exercising the desktop
sidebar's session dot states driven by real gateway events:

1. Background process dot appears during a terminal(background=true)
   call and disappears after auto-dismiss; subagent (delegate_task)
   runs concurrently; final answer is visible in the transcript.

2. Background dot remains visible while a subagent runs concurrently
   (longer sleep 5 background process so the dot is catchable).

3. Cross-session dot transition: start a turn with a background process,
   wait for the turn to complete, open a new session, then verify the
   original session's dot transitions from 'background running' to
   'finished — unread' when the background process exits.

The mock server gains SIDEBAR_SCRIPT and SIDEBAR_CROSS_SCRIPT trigger
keywords that return tool_calls for terminal(background=true) and
delegate_task — the agent executes these for real (real background
process, real subagent), so the tests assert against genuine gateway
events rather than mocked UI state.

Verified: 3 passed (1.2m) under cage headless wlroots.

* test(desktop): e2e tests for tile-unread bug (tab passes, split fails)

Two scenarios for the tile-unread bug where a session that finishes
while visible on-screen gets the green 'finished unread' dot even
though the user is looking right at it.

The unread check in handleTransition (session-states.ts:174) only
compares against $selectedStoredSessionId and ignores $sessionTiles,
so a session visible in a tile gets marked unread even though it's
on screen.

1. TAB (hidden, PASSES): ⌃-click opens the session as a stacked tab
   that is NOT visible on screen. The unread dot IS correct here —
   the user isn't looking at it.

2. SPLIT (visible, FAILS): drag the session row to the workspace's
   right edge to create a side-by-side split tile. Both sessions are
   visible on screen. The unread dot is WRONG — the session is visible
   in the split tile, so it should not be marked 'unread'. This test
   is RED until the fix lands.

Also adds explicit page.screenshot() calls at key assertion points in
sidebar-states.spec.ts so the trace viewer has full-res captures of the
sidebar dot states during the test.

* 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.

* test(desktop): cover busy composer submit routing

Replace the invalid queued-stop E2E scenario: plain text redirects a busy
turn rather than entering the queue. Add focused submit-routing coverage for
plain text, slash commands, attachments, explicit Stop, and idle submission.
2026-07-22 22:56:13 +00:00

83 lines
3.3 KiB
TypeScript

/**
* E2E coverage for session compression, which rotates a live backend session.
*/
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)
// Commit the command before typing its argument. This waits for the async
// completion request on cold CI workers, then uses the composer's own
// keyboard accept path to replace the `/compress` trigger with a command
// chip. Clicking a later completion after typing the argument can insert a
// second command token (for example `//compress ...`) as plain text.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('/compress', { delay: 15 })
await page.getByText('/compress').first().waitFor({ state: 'visible' })
await page.keyboard.press('Enter')
await composer.type(' preserve the three test turns', { delay: 15 })
await page.keyboard.press('Enter')
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' })
})
})