hermes-agent/ui-tui/src/__tests__/useSessionLifecycle.test.ts
2026-06-26 01:05:26 -07:00

145 lines
3.8 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import {
hydrateLiveSessionInflight,
liveSessionInflightMessages,
scheduleResumeScrollToBottom,
writeActiveSessionFile
} from '../app/useSessionLifecycle.js'
describe('writeActiveSessionFile', () => {
let dir = ''
afterEach(() => {
if (dir) {
rmSync(dir, { force: true, recursive: true })
dir = ''
}
})
it('writes the actual resumed session id for the shell exit summary', () => {
dir = mkdtempSync(join(tmpdir(), 'hermes-tui-active-'))
const path = join(dir, 'active.json')
writeActiveSessionFile('actual_session', path)
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ session_id: 'actual_session' })
})
})
describe('live session activation in-flight state', () => {
beforeEach(() => {
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ streaming: true })
})
it('keeps the in-flight user prompt in history and hydrates partial assistant text', () => {
const inflight = { assistant: 'partial answer', streaming: true, user: 'write a long answer' }
expect(liveSessionInflightMessages(inflight)).toEqual([{ role: 'user', text: 'write a long answer' }])
hydrateLiveSessionInflight(inflight)
expect(turnController.bufRef).toBe('partial answer')
expect(getTurnState().streaming).toBe('partial answer')
})
it('ignores empty in-flight payloads', () => {
expect(liveSessionInflightMessages({ assistant: '', streaming: false, user: ' ' })).toEqual([])
hydrateLiveSessionInflight({ assistant: '', streaming: false, user: '' })
expect(turnController.bufRef).toBe('')
expect(getTurnState().streaming).toBe('')
})
})
describe('resume scroll settle', () => {
afterEach(() => {
vi.useRealTimers()
})
it('re-snaps while sticky and stops when the user scrolls away', () => {
vi.useFakeTimers()
let sticky = true
let lastManualScrollAt = 0
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
getLastManualScrollAt: () => lastManualScrollAt,
isSticky: () => sticky,
scrollToBottom
}
} as any,
[0, 80, 240]
)
vi.advanceTimersByTime(0)
expect(scrollToBottom).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(80)
expect(scrollToBottom).toHaveBeenCalledTimes(2)
sticky = false
lastManualScrollAt = Date.now() + 1
vi.advanceTimersByTime(160)
expect(scrollToBottom).toHaveBeenCalledTimes(2)
cancel()
})
it('cancels pending resume snaps', () => {
vi.useFakeTimers()
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
getLastManualScrollAt: () => 0,
isSticky: () => true,
scrollToBottom
}
} as any,
[20]
)
cancel()
vi.advanceTimersByTime(20)
expect(scrollToBottom).not.toHaveBeenCalled()
})
it('keeps the immediate resume snap even before sticky state settles', () => {
vi.useFakeTimers()
let sticky = false
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
getLastManualScrollAt: () => 0,
isSticky: () => sticky,
scrollToBottom
}
} as any,
[0, 80]
)
vi.advanceTimersByTime(0)
expect(scrollToBottom).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(80)
expect(scrollToBottom).toHaveBeenCalledTimes(1)
sticky = true
cancel()
})
})