mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
* fix(desktop): hide persisted agent-only history scaffolding Filter verification-stop nudges and context-compaction handoffs at the stored-history mapper boundary. Preserve a real reply when a compaction handoff shares its stored message. * test(desktop): build persisted E2E sessions through the real agent Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock provider, wait for real completion events, and persist normal session history through AIAgent and SessionDB. Migrate resume and hidden-history coverage, including real compression and live verify-on-stop scaffolding, then remove the unused direct SessionDB import scripts. * fix(desktop): use the provisioned Python for real-session E2Es Run the stdio gateway through uv's synced project environment outside the Nix dev shell, while retaining the fully provisioned Nix Python when the shell advertises HERMES_PYTHON_SRC_ROOT. * fix(nix): expose the provisioned Python environment to uv Mark the Nix-built Python environment active in the dev shell so the shared E2E session builder can always run through `uv run --active --no-sync`. * fix(timeline): persist typed display events * fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history Three review findings from PR #69771: 1. Provider payload leak: display_kind and display_metadata were forwarded to the provider API as unknown message fields. Strict OpenAI-compatible backends can reject the next request after a model switch or resumed typed event. Strip both from the per-request api_msg copy in conversation_loop alongside the existing api_content pop. 2. Rewrite/import data loss: _insert_message_rows preserved display_kind but silently dropped display_metadata. After replace_messages, archive_and_compact, or session import, async-delegation completion events lost their task counts and fell back to generic display text. Add display_metadata to the INSERT columns and bind tuple. 3. CLI /resume stale recap: startup --resume A set _resume_display_history from A's lineage. A subsequent in-session /resume B loaded B only into conversation_history via get_messages_as_conversation, leaving the stale A display projection. _display_resumed_history preferentially read the stale attribute, showing A's recap for B. Switch /resume to get_resume_conversations and update _resume_display_history alongside conversation_history. Tests: 890 Python (5 files), 35 desktop TS — all green. * feat(tui): render typed display events as ◈ markers in the Ink TUI The TUI was not handling display_kind at all — model switch markers and async delegation completions rendered as opaque user messages with the full [System: ...] text, and hidden compaction handoffs were visible. Wire display_kind through the full TUI chain: - _history_to_messages (tui_gateway/server.py) forwards display_kind and display_metadata to the gateway transcript payload. - GatewayTranscriptMessage (gatewayTypes.ts) gains both fields. - Msg.kind (types.ts) gains 'event' value. - toTranscriptMessages (domain/messages.ts) maps: - hidden → skip entirely - model_switch → event "model changed" - async_delegation_complete → event "N background agents finished" (or "background agent work finished" without metadata) - messageGroup (blockLayout.ts) routes event to its own group, with SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins. - messageLine.tsx renders event-kind as a dim ◈ marker with no gutter, matching the CLI's ◈ event rendering. - 4 new TUI tests for hidden/model_switch/async_delegation mapping. TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings). TUI tests: 9 passed (1 pre-existing failure on main, unrelated).
239 lines
8.6 KiB
TypeScript
239 lines
8.6 KiB
TypeScript
import * as path from 'node:path'
|
|
|
|
import { type TestInfo } from '@playwright/test'
|
|
|
|
import { expect, test, type ElectronApplication, type Page } from './test'
|
|
|
|
import {
|
|
buildAppEnv,
|
|
createSandbox,
|
|
launchDesktop,
|
|
type Sandbox,
|
|
waitForAppReady,
|
|
writeEnvFile,
|
|
writeMockProviderConfig,
|
|
} from './fixtures'
|
|
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
|
|
import { RealSessionBuilder } from './real-session-builder'
|
|
|
|
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
|
const SESSION_TITLE = 'E2E large persisted session'
|
|
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
|
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
|
const HISTORY_TURNS = Array.from(
|
|
{ length: 27 },
|
|
(_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
|
|
)
|
|
|
|
interface SeededFixture {
|
|
app: ElectronApplication
|
|
mock: MockServer
|
|
mockUrl: string
|
|
page: Page
|
|
sandbox: Sandbox
|
|
cleanup: () => Promise<void>
|
|
}
|
|
|
|
interface PaintState {
|
|
bursts: number
|
|
timeline: Array<{ mutations: number; time: number }>
|
|
}
|
|
|
|
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
|
|
const mock = await startMockServer(mockServer)
|
|
const sandbox = createSandbox('large-session')
|
|
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
|
writeEnvFile(sandbox.hermesHome)
|
|
|
|
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
|
try {
|
|
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
|
|
} finally {
|
|
await builder.close()
|
|
}
|
|
|
|
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
|
|
|
return {
|
|
app,
|
|
mock,
|
|
mockUrl: mock.url,
|
|
page,
|
|
sandbox,
|
|
cleanup: async () => {
|
|
await app.close().catch(() => undefined)
|
|
await mock.close()
|
|
sandbox.cleanup()
|
|
},
|
|
}
|
|
}
|
|
|
|
function sessionRow(page: Page) {
|
|
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
|
|
}
|
|
|
|
async function openSeededSession(page: Page): Promise<void> {
|
|
const row = sessionRow(page)
|
|
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
|
await row.click()
|
|
await page.waitForFunction(
|
|
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
|
EXPECTED_TEXT,
|
|
{ timeout: 30_000 },
|
|
)
|
|
}
|
|
|
|
async function openNewSession(page: Page): Promise<void> {
|
|
const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
|
|
await button.waitFor({ state: 'visible', timeout: 10_000 })
|
|
await button.click()
|
|
await page.waitForFunction(
|
|
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
|
EXPECTED_TEXT,
|
|
{ timeout: 15_000 },
|
|
)
|
|
}
|
|
|
|
async function submitPrompt(page: Page, prompt: string): Promise<void> {
|
|
const composer = page.locator('[contenteditable="true"]').first()
|
|
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
|
await composer.click()
|
|
await composer.type(prompt, { delay: 2 })
|
|
await page.keyboard.press('Enter')
|
|
await page.waitForFunction(
|
|
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
|
prompt,
|
|
{ timeout: 15_000 },
|
|
)
|
|
}
|
|
|
|
async function startPaintObserver(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
|
const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
|
|
;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
|
|
if (!viewport) return
|
|
|
|
let additions = 0
|
|
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
new MutationObserver(records => {
|
|
additions += records.reduce(
|
|
(count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
|
|
0,
|
|
)
|
|
if (additions === 0) return
|
|
if (flushTimer) clearTimeout(flushTimer)
|
|
flushTimer = setTimeout(() => {
|
|
state.bursts += 1
|
|
state.timeline.push({ mutations: additions, time: Date.now() })
|
|
additions = 0
|
|
}, 30)
|
|
}).observe(viewport, { childList: true, subtree: true })
|
|
})
|
|
}
|
|
|
|
async function paintState(page: Page): Promise<PaintState> {
|
|
const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
|
|
expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
|
|
return state!
|
|
}
|
|
|
|
async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
|
|
return page.evaluate(text => {
|
|
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
|
if (!viewport) return 0
|
|
|
|
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
|
|
let count = 0
|
|
while (walker.nextNode()) {
|
|
if (walker.currentNode.textContent?.includes(text)) {
|
|
count += 1
|
|
}
|
|
}
|
|
return count
|
|
}, expected)
|
|
}
|
|
|
|
async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
|
|
await fixture.page.reload()
|
|
await waitForAppReady(fixture, 120_000)
|
|
await openNewSession(fixture.page)
|
|
}
|
|
|
|
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
|
|
await openSeededSession(page)
|
|
await page.waitForTimeout(1_000)
|
|
await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
|
|
|
|
const paints = await paintState(page)
|
|
expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
|
|
// A warm session first restores its retained view, then reconciles it with the
|
|
// authoritative transcript. That is bounded at two builds; a third paint was
|
|
// the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
|
|
expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
|
|
}
|
|
|
|
test.describe('large session resume', () => {
|
|
let fixture: SeededFixture | null = null
|
|
|
|
test.afterEach(async () => {
|
|
await fixture?.cleanup()
|
|
fixture = null
|
|
})
|
|
|
|
test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
|
fixture = await setupSeededDesktop()
|
|
await waitForAppReady(fixture, 120_000)
|
|
|
|
await startPaintObserver(fixture.page)
|
|
await assertUnchangedResume(fixture.page, testInfo)
|
|
})
|
|
|
|
test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
|
// Known RED: a rapid warm resume rebuilds the transcript three times
|
|
// (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
|
|
// regression visible without making unrelated desktop work fail CI.
|
|
test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
|
|
|
|
fixture = await setupSeededDesktop()
|
|
await waitForAppReady(fixture, 120_000)
|
|
|
|
await openSeededSession(fixture.page)
|
|
await openNewSession(fixture.page)
|
|
await startPaintObserver(fixture.page)
|
|
await assertUnchangedResume(fixture.page, testInfo)
|
|
})
|
|
|
|
for (const resumeKind of ['fast', 'cold'] as const) {
|
|
test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
|
|
fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
|
|
await waitForAppReady(fixture, 120_000)
|
|
|
|
await openSeededSession(fixture.page)
|
|
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
|
|
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
|
await fixture.mock.waitForHeldStream()
|
|
await openNewSession(fixture.page)
|
|
|
|
if (resumeKind === 'cold') {
|
|
await reloadIntoColdRenderer(fixture)
|
|
}
|
|
|
|
await openSeededSession(fixture.page)
|
|
fixture.mock.releaseHeldStream()
|
|
await fixture.page.waitForFunction(
|
|
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
|
MOCK_REPLY,
|
|
{ timeout: 60_000 },
|
|
)
|
|
await fixture.page.waitForTimeout(300)
|
|
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
|
|
|
|
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
|
|
expect(
|
|
await textNodeOccurrences(fixture.page, MOCK_REPLY),
|
|
'the completed assistant reply should add exactly one transcript row',
|
|
).toBe(initialMockReplyCount + 1)
|
|
})
|
|
}
|
|
})
|