mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
* 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.
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
/**
|
|
* E2E test for the interim-assistant-message preservation fix (#65919).
|
|
*
|
|
* Reproduces the bug across all three layers (agent core → tui_gateway →
|
|
* desktop renderer): when the agent emits assistant text alongside a tool
|
|
* call, then completes the turn with a *different* final answer, the
|
|
* interim text must survive in the transcript — not be wiped when
|
|
* message.complete replaces the streaming bubble.
|
|
*
|
|
* The mock server walks through a multi-turn script when it sees the
|
|
* trigger keyword:
|
|
*
|
|
* Turn 1: "Let me start by planning the approach." + todo tool_call
|
|
* Turn 2: "Now checking the details before answering." + todo tool_call
|
|
* Turn 3: (no text) + todo tool_call → NO interim (no visible text)
|
|
* Turn 4: "Found something interesting worth noting." + todo tool_call
|
|
* Turn 5: "All done! Here is the complete summary..." (final, stop)
|
|
*
|
|
* Two describe blocks exercise the config flag both ways:
|
|
*
|
|
* display.interim_assistant_messages: true (default)
|
|
* → ALL interim texts AND the final text must be visible in the
|
|
* transcript.
|
|
*
|
|
* display.interim_assistant_messages: false
|
|
* → only the final text is visible (no message.interim events emitted,
|
|
* so all streamed interim text is replaced at message.complete).
|
|
*
|
|
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
|
*/
|
|
|
|
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
import {
|
|
type MockBackendFixture,
|
|
setupMockBackend,
|
|
waitForAppReady,
|
|
} from './fixtures'
|
|
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
/** Unique trigger keyword the mock server detects to switch to the script. */
|
|
const TRIGGER = 'E2E_INTERIM_TRIGGER'
|
|
|
|
/**
|
|
* Send a message and wait for BOTH the user's message and the agent's
|
|
* final response to appear in the transcript. Returns when the final text
|
|
* is visible, which means message.complete has fired and the transcript
|
|
* has settled.
|
|
*/
|
|
async function sendInterimMessage(page: Page): Promise<void> {
|
|
const composer = page.locator('[contenteditable="true"]').first()
|
|
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
|
await composer.click()
|
|
await composer.type(TRIGGER, { delay: 20 })
|
|
await page.keyboard.press('Enter')
|
|
|
|
// Wait for the user's trigger message to appear.
|
|
await page.waitForFunction(
|
|
() => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
|
|
undefined,
|
|
{ timeout: 15_000 },
|
|
)
|
|
|
|
// Wait for the agent's FINAL response (last turn). This means
|
|
// message.complete has fired and the transcript is settled.
|
|
await page.waitForFunction(
|
|
(finalText) => (document.body.textContent ?? '').includes(finalText),
|
|
INTERIM_TEXTS.finalText,
|
|
{ timeout: 90_000 },
|
|
)
|
|
|
|
// Give the renderer a moment to settle any final state updates
|
|
// (hydration, session refresh) before asserting.
|
|
await page.waitForTimeout(2000)
|
|
}
|
|
|
|
/**
|
|
* Count how many times `text` appears as distinct text in the chat transcript
|
|
* (excluding the session sidebar, whose session-preview label shows the
|
|
* first streamed text as a title).
|
|
*
|
|
* The desktop app renders the transcript inside a
|
|
* `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
|
|
* The session sidebar's preview labels live outside that container, so
|
|
* scoping the DOM walk to the viewport cleanly excludes them.
|
|
*/
|
|
async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
|
|
return page.evaluate(
|
|
(search) => {
|
|
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
|
if (!viewport) {
|
|
return 0
|
|
}
|
|
|
|
let count = 0
|
|
const walker = document.createTreeWalker(
|
|
viewport,
|
|
NodeFilter.SHOW_ELEMENT,
|
|
{
|
|
acceptNode: (node) => {
|
|
const el = node as HTMLElement
|
|
const directText = el.textContent ?? ''
|
|
if (!directText.includes(search)) {
|
|
return NodeFilter.FILTER_SKIP
|
|
}
|
|
// Only count leaf-ish elements to avoid double-counting.
|
|
const hasChildWithText = Array.from(el.children).some(
|
|
(child) => (child.textContent ?? '').includes(search),
|
|
)
|
|
if (hasChildWithText) {
|
|
return NodeFilter.FILTER_SKIP
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT
|
|
},
|
|
},
|
|
)
|
|
while (walker.nextNode()) {
|
|
count++
|
|
}
|
|
return count
|
|
},
|
|
text,
|
|
)
|
|
}
|
|
|
|
// ─── Flag ON: interim_assistant_messages = true (default) ─────────────
|
|
|
|
test.describe('interim assistant messages — flag ON (default)', () => {
|
|
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('all interim texts survive alongside the final response', async () => {
|
|
const page = fixture.page
|
|
await sendInterimMessage(page)
|
|
|
|
// Every interim text (turns with visible text + tool calls) must be
|
|
// present in the transcript as its own sealed message — NOT wiped by
|
|
// message.complete.
|
|
for (const interimText of INTERIM_TEXTS.interims) {
|
|
await expect
|
|
.poll(
|
|
() => countTranscriptMessagesContaining(page, interimText),
|
|
{ timeout: 15_000, message: `interim text "${interimText}" should be visible` },
|
|
)
|
|
.toBeGreaterThanOrEqual(1)
|
|
}
|
|
|
|
// The final text must also be visible.
|
|
await expect
|
|
.poll(
|
|
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
|
{ timeout: 15_000, message: 'final text should be visible' },
|
|
)
|
|
.toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|
|
|
|
// ─── Flag OFF: interim_assistant_messages = false ────────────────────
|
|
|
|
test.describe('interim assistant messages — flag OFF', () => {
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
let fixture: MockBackendFixture
|
|
|
|
test.beforeAll(async () => {
|
|
restartMockServer()
|
|
fixture = await setupMockBackend({
|
|
extraDisplayConfig: ' interim_assistant_messages: false',
|
|
})
|
|
await waitForAppReady(fixture, 120_000)
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await fixture?.cleanup()
|
|
})
|
|
|
|
test('only the final response is visible; all interim texts are wiped', async () => {
|
|
const page = fixture.page
|
|
await sendInterimMessage(page)
|
|
|
|
// The final text must be visible.
|
|
await expect
|
|
.poll(
|
|
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
|
{ timeout: 15_000, message: 'final text should be visible' },
|
|
)
|
|
.toBeGreaterThanOrEqual(1)
|
|
|
|
// NONE of the interim texts should be visible — with the flag off,
|
|
// the tui_gateway never installs interim_assistant_callback, so no
|
|
// message.interim events are emitted. All streamed interim text is
|
|
// accumulated into the streaming bubble and replaced by
|
|
// message.complete.
|
|
for (const interimText of INTERIM_TEXTS.interims) {
|
|
const count = await countTranscriptMessagesContaining(page, interimText)
|
|
expect(
|
|
count,
|
|
`interim text "${interimText}" should NOT be visible when flag is off`,
|
|
).toBe(0)
|
|
}
|
|
})
|
|
})
|