Adds the checks that make the constraints from Phase 001-007 mechanical
instead of tribal knowledge, and clears the legacy brand text that was still
reachable at runtime.
New red-line tests:
- no-legacy-brand mirrors the harness scan under vitest, and asserts its
allow-list has not drifted from harness-check.mjs
- hermes-db-readonly classifies every new DatabaseSync call site by
following the path it opens, and fails if a state.db handle drops
readOnly. It also pins the one legitimately writable database.
- i18n-parity compares the raw zh/en locale objects both ways. The
existing coverage test merged en into zh at import time, so a key
missing from zh could never fail there.
harness:check gains the four missing gates from the phase brief - retired
package directories, retired package scripts, a single UI library, and the
required root files. Each was verified to fail by mutating the repo.
The online-update channel had hardcoded upstream URLs. It now reads
HERMES_WEB_UI_VERSION_MANIFEST_URL and friends and degrades with a named
error, because a reimplementation has nowhere to pull releases from.
The remaining legacy strings are deliberate and each carries a reason in the
allow-list: the global-agent relay address and the OpenRouter referer are
load-bearing, the socket role identifier is a wire contract, and the pre-rename
DB filenames would orphan existing deployments if changed. AGENTS.md now says
so explicitly, so a future agent does not "clean them up".
Also removes a dead electron mock alias, an i18n exemption that had stopped
exempting anything, and a teardown race in plugins-view.test.ts where a-spin
animation frames outlived the jsdom environment.
Verified self-contained: with reference_code/ moved aside, harness:check,
build, test and test:e2e all pass, so the directory can now be deleted.
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: upstream issue #1998 (session ended_at / end_reason not persisted)
|
|
*/
|
|
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,
|
|
)
|
|
})
|
|
})
|