Files
hermes-ui/tests/client/chat-run-reconnect.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

216 lines
7.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const socketState = vi.hoisted(() => ({
sockets: [] as any[],
}))
vi.mock('socket.io-client', () => {
function createSocket() {
const listeners = new Map<string, Set<(...args: any[]) => void>>()
const addListener = (event: string, handler: (...args: any[]) => void) => {
if (!listeners.has(event)) listeners.set(event, new Set())
listeners.get(event)!.add(handler)
}
const removeListener = (event: string, handler: (...args: any[]) => void) => {
const eventListeners = listeners.get(event)
if (!eventListeners) return
for (const candidate of [...eventListeners]) {
if (candidate === handler || (candidate as any).__original === handler) {
eventListeners.delete(candidate)
}
}
}
const socket: any = {
connected: true,
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
addListener(event, handler)
return socket
}),
once: vi.fn((event: string, handler: (...args: any[]) => void) => {
const wrapped = (...args: any[]) => {
removeListener(event, wrapped)
handler(...args)
}
;(wrapped as any).__original = handler
addListener(event, wrapped)
return socket
}),
off: vi.fn((event: string, handler: (...args: any[]) => void) => {
removeListener(event, handler)
return socket
}),
removeListener: vi.fn((event: string, handler: (...args: any[]) => void) => {
removeListener(event, handler)
return socket
}),
removeAllListeners: vi.fn(() => {
listeners.clear()
return socket
}),
emit: vi.fn(),
disconnect: vi.fn(() => {
socket.connected = false
}),
__listenerCount: (event: string) => listeners.get(event)?.size || 0,
__trigger: (event: string, ...args: any[]) => {
if (event === 'connect') socket.connected = true
if (event === 'disconnect') socket.connected = false
for (const handler of [...(listeners.get(event) || [])]) handler(...args)
},
}
return socket
}
return {
io: vi.fn(() => {
const socket = createSocket()
socketState.sockets.push(socket)
return socket
}),
}
})
vi.mock('../../packages/client/src/api/client', () => ({
getApiKey: () => 'test-token',
getBaseUrlValue: () => '',
}))
describe('chat-run socket reconnect handling', () => {
beforeEach(() => {
vi.resetModules()
socketState.sockets = []
})
it('keeps transient mobile disconnects alive and resumes after reconnect', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onEvent = vi.fn()
const onDone = vi.fn()
const onError = vi.fn()
const onReconnectResume = vi.fn()
startRunViaSocket(
{ session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' },
onEvent,
onDone,
onError,
undefined,
{ onReconnectResume },
)
const socket = socketState.sockets[0]
expect(socket.emit).toHaveBeenCalledWith('run', expect.objectContaining({ session_id: 'session-1' }))
socket.__trigger('disconnect', 'ping timeout')
expect(onError).not.toHaveBeenCalled()
socket.__trigger('connect_error', new Error('temporary reconnect failure'))
expect(onError).not.toHaveBeenCalled()
socket.__trigger('connect')
expect(socket.emit).toHaveBeenCalledWith('resume', { session_id: 'session-1', profile: 'default' })
const resumed = { session_id: 'session-1', messages: [], isWorking: true, events: [] }
socket.__trigger('resumed', resumed)
expect(onReconnectResume).toHaveBeenCalledWith(resumed)
socket.__trigger('message.delta', { event: 'message.delta', session_id: 'session-1', delta: 'after reconnect' })
expect(onEvent).toHaveBeenCalledWith({ event: 'message.delta', session_id: 'session-1', delta: 'after reconnect' })
expect(onDone).not.toHaveBeenCalled()
})
it('keeps concurrent resume callbacks scoped to their requested session', async () => {
const { resumeSession } = await import('../../packages/client/src/api/hermes/chat')
const onSessionA = vi.fn()
const onSessionB = vi.fn()
resumeSession('session-a', onSessionA, 'default')
resumeSession('session-b', onSessionB, 'default')
const socket = socketState.sockets[0]
const resumedB = { session_id: 'session-b', messages: [], isWorking: false, events: [] }
socket.__trigger('resumed', resumedB)
expect(onSessionA).not.toHaveBeenCalled()
expect(onSessionB).toHaveBeenCalledWith(resumedB)
expect(socket.__listenerCount('resumed')).toBe(1)
const resumedA = { session_id: 'session-a', messages: [], isWorking: false, events: [] }
socket.__trigger('resumed', resumedA)
expect(onSessionA).toHaveBeenCalledWith(resumedA)
expect(socket.__listenerCount('resumed')).toBe(0)
})
it('keeps fatal disconnects fatal and removes per-run listeners', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onError = vi.fn()
startRunViaSocket(
{ session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' },
vi.fn(),
vi.fn(),
onError,
)
const socket = socketState.sockets[0]
socket.__trigger('disconnect', 'io server disconnect')
expect(onError).toHaveBeenCalledOnce()
expect(onError.mock.calls[0][0].message).toBe('Socket disconnected: io server disconnect')
expect(socket.__listenerCount('connect')).toBe(0)
expect(socket.__listenerCount('disconnect')).toBe(0)
expect(socket.__listenerCount('connect_error')).toBe(0)
})
it('does not attach extra reconnect listeners when the session already has handlers', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const body = { session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' as const }
startRunViaSocket(body, vi.fn(), vi.fn(), vi.fn())
const socket = socketState.sockets[0]
expect(socket.__listenerCount('connect')).toBe(1)
expect(socket.__listenerCount('disconnect')).toBe(1)
startRunViaSocket(body, vi.fn(), vi.fn(), vi.fn())
expect(socket.__listenerCount('connect')).toBe(1)
expect(socket.__listenerCount('disconnect')).toBe(1)
expect(socket.emit).toHaveBeenCalledWith('run', body)
})
it('fans session.command events to run-local and global handlers', async () => {
const { onSessionCommand, startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onEvent = vi.fn()
const onGlobalCommand = vi.fn()
const offGlobalCommand = onSessionCommand(onGlobalCommand)
startRunViaSocket(
{ session_id: 'session-1', input: '/goal status', profile: 'default', source: 'cli' },
onEvent,
vi.fn(),
vi.fn(),
)
const socket = socketState.sockets[0]
const event = {
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'status',
message: 'Goal (active, 0/20 turns): write site',
}
socket.__trigger('session.command', event)
expect(onEvent).toHaveBeenCalledWith(event)
expect(onGlobalCommand).toHaveBeenCalledWith(event)
offGlobalCommand()
socket.__trigger('session.command', { ...event, message: 'next status' })
expect(onGlobalCommand).toHaveBeenCalledTimes(1)
})
})