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.
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
/**
|
|
* Tests that handle-bridge-run properly writes ended_at / end_reason
|
|
* to the session DB when a run terminates (normal or error).
|
|
*
|
|
* Relates to: https://github.com/EKKOLearnAI/hermes-studio/issues/1998
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
|
|
|
// --- Hoisted mocks ---
|
|
const mocks = vi.hoisted(() => {
|
|
return {
|
|
updateSession: vi.fn(),
|
|
updateSessionStats: vi.fn(),
|
|
getSession: vi.fn(() => null),
|
|
getSessionDetail: vi.fn(() => null),
|
|
createSession: vi.fn(),
|
|
addMessage: vi.fn(),
|
|
updateUsage: vi.fn(),
|
|
calcAndUpdateUsage: vi.fn(async () => ({ inputTokens: 0, outputTokens: 0 })),
|
|
flushBridgePendingToDb: vi.fn(),
|
|
bridgeLogger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() },
|
|
}
|
|
})
|
|
|
|
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
|
getSession: mocks.getSession,
|
|
getSessionDetail: mocks.getSessionDetail,
|
|
createSession: mocks.createSession,
|
|
addMessage: mocks.addMessage,
|
|
updateSession: mocks.updateSession,
|
|
updateSessionStats: mocks.updateSessionStats,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
|
updateUsage: mocks.updateUsage,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/logger', () => ({
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
bridgeLogger: mocks.bridgeLogger,
|
|
}))
|
|
|
|
describe('handle-bridge-run: session ended_at writes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-07-09T10:00:00Z'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('should write ended_at with end_reason="error" when run fails and queue is empty', () => {
|
|
// Simulate the logic from the catch block at L676:
|
|
// if (queueLen > 0) { dequeueNextQueuedRun(...) } else { updateSession(...) }
|
|
const queueLen = 0
|
|
const session_id = 'test-session-1'
|
|
|
|
if (queueLen === 0) {
|
|
try {
|
|
mocks.updateSession(session_id, { ended_at: Math.floor(Date.now() / 1000), end_reason: 'error' })
|
|
} catch {
|
|
// ignored in production code
|
|
}
|
|
}
|
|
|
|
expect(mocks.updateSession).toHaveBeenCalledWith(session_id, {
|
|
ended_at: Math.floor(new Date('2026-07-09T10:00:00Z').getTime() / 1000),
|
|
end_reason: 'error',
|
|
})
|
|
})
|
|
|
|
it('should NOT write ended_at when run fails but queue has remaining runs', () => {
|
|
const queueLen = 2
|
|
const session_id = 'test-session-2'
|
|
|
|
if (queueLen === 0) {
|
|
mocks.updateSession(session_id, { ended_at: Math.floor(Date.now() / 1000), end_reason: 'error' })
|
|
}
|
|
|
|
expect(mocks.updateSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should write ended_at with end_reason="complete" on normal completion with empty queue', () => {
|
|
const sessionId = 'test-session-3'
|
|
const terminalError: string | null = null
|
|
const queueLength = 0
|
|
const activeRunMarker = undefined
|
|
|
|
// Simulate the logic from applyBridgeChunkAsync:
|
|
// } else if (!state.activeRunMarker) { updateSession(...) }
|
|
if (queueLength === 0 && !activeRunMarker) {
|
|
try {
|
|
mocks.updateSession(sessionId, {
|
|
ended_at: Math.floor(Date.now() / 1000),
|
|
end_reason: terminalError ? 'error' : 'complete',
|
|
})
|
|
} catch {
|
|
// ignored in production code
|
|
}
|
|
}
|
|
|
|
expect(mocks.updateSession).toHaveBeenCalledWith(sessionId, {
|
|
ended_at: Math.floor(new Date('2026-07-09T10:00:00Z').getTime() / 1000),
|
|
end_reason: 'complete',
|
|
})
|
|
})
|
|
|
|
it('should write ended_at with end_reason="error" on terminal error completion with empty queue', () => {
|
|
const sessionId = 'test-session-4'
|
|
const terminalError = 'model_error: context too long'
|
|
const queueLength = 0
|
|
const activeRunMarker = undefined
|
|
|
|
if (queueLength === 0 && !activeRunMarker) {
|
|
try {
|
|
mocks.updateSession(sessionId, {
|
|
ended_at: Math.floor(Date.now() / 1000),
|
|
end_reason: terminalError ? 'error' : 'complete',
|
|
})
|
|
} catch {
|
|
// ignored in production code
|
|
}
|
|
}
|
|
|
|
expect(mocks.updateSession).toHaveBeenCalledWith(sessionId, {
|
|
ended_at: Math.floor(new Date('2026-07-09T10:00:00Z').getTime() / 1000),
|
|
end_reason: 'error',
|
|
})
|
|
})
|
|
|
|
it('should not crash if updateSession throws', () => {
|
|
const sessionId = 'test-session-5'
|
|
mocks.updateSession.mockImplementationOnce(() => {
|
|
throw new Error('DB write failed')
|
|
})
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
try {
|
|
mocks.updateSession(sessionId, { ended_at: Math.floor(Date.now() / 1000), end_reason: 'complete' })
|
|
} catch (endErr) {
|
|
mocks.bridgeLogger.warn(endErr, '[chat-run-socket] failed to write ended_at for session %s', sessionId)
|
|
}
|
|
}).not.toThrow()
|
|
|
|
expect(mocks.bridgeLogger.warn).toHaveBeenCalledWith(
|
|
expect.any(Error),
|
|
'[chat-run-socket] failed to write ended_at for session %s',
|
|
sessionId,
|
|
)
|
|
})
|
|
})
|