feat(web-ui): add pinned sessions and live monitor in Chat (#118)
* feat: add single-page live session monitor and chat pinning * fix: restore full test green after main merge * fix: use Array.from instead of Set spread for ts-node compatibility [...new Set()] requires downlevelIteration which isn't enabled in ts-node dev mode, causing sonic-boom crash on startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: ekko <fqsy1416@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockChatStore = vi.hoisted(() => ({
|
||||
sessions: [] as Array<Record<string, any>>,
|
||||
activeSessionId: null as string | null,
|
||||
activeSession: null as Record<string, any> | null,
|
||||
isLoadingSessions: false,
|
||||
sessionsLoaded: true,
|
||||
isSessionLive: vi.fn((sessionId: string) => sessionId === 'discord-active'),
|
||||
newChat: vi.fn(),
|
||||
switchSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/chat', () => ({
|
||||
useChatStore: () => mockChatStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
renameSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/MessageList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="message-list-mock" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="chat-input-mock" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/ConversationMonitorPane.vue', () => ({
|
||||
default: {
|
||||
props: ['humanOnly'],
|
||||
template: '<div class="conversation-monitor-mock">monitor {{ humanOnly }}</div>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
|
||||
function makeSession(id: string, overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
source: 'api_server',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
model: 'gpt-4o',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const NButtonStub = {
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" v-bind="$attrs" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
}
|
||||
|
||||
const NDropdownStub = {
|
||||
props: ['options', 'show'],
|
||||
emits: ['select', 'clickoutside'],
|
||||
template: `
|
||||
<div v-if="show" class="dropdown-stub">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.key"
|
||||
class="dropdown-option"
|
||||
@click="$emit('select', option.key)"
|
||||
>{{ option.label }}</button>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
describe('ChatPanel modes and pinning', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
vi.clearAllMocks()
|
||||
|
||||
const activeDiscord = makeSession('discord-active', {
|
||||
title: 'Discord Active',
|
||||
source: 'discord',
|
||||
createdAt: 100,
|
||||
updatedAt: 500,
|
||||
})
|
||||
const olderDiscord = makeSession('discord-older', {
|
||||
title: 'Discord Older',
|
||||
source: 'discord',
|
||||
createdAt: 200,
|
||||
updatedAt: 400,
|
||||
})
|
||||
const slackSession = makeSession('slack-1', {
|
||||
title: 'Slack Selected',
|
||||
source: 'slack',
|
||||
createdAt: 50,
|
||||
updatedAt: 50,
|
||||
})
|
||||
const apiSession = makeSession('api-1', {
|
||||
title: 'API Session',
|
||||
source: 'api_server',
|
||||
createdAt: 300,
|
||||
updatedAt: 300,
|
||||
})
|
||||
|
||||
mockChatStore.sessions = [apiSession, slackSession, olderDiscord, activeDiscord]
|
||||
mockChatStore.activeSessionId = apiSession.id
|
||||
mockChatStore.activeSession = apiSession
|
||||
mockChatStore.isLoadingSessions = false
|
||||
mockChatStore.sessionsLoaded = true
|
||||
mockChatStore.isSessionLive.mockImplementation((sessionId: string) => sessionId === activeDiscord.id)
|
||||
mockChatStore.switchSession.mockImplementation((sessionId: string) => {
|
||||
mockChatStore.activeSessionId = sessionId
|
||||
mockChatStore.activeSession = mockChatStore.sessions.find(s => s.id === sessionId) ?? null
|
||||
})
|
||||
})
|
||||
|
||||
it('pins and unpins a session through the context menu without duplicating it', async () => {
|
||||
const prefsStore = useSessionBrowserPrefsStore()
|
||||
const wrapper = mount(ChatPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
NButton: NButtonStub,
|
||||
NDropdown: NDropdownStub,
|
||||
NInput: true,
|
||||
NModal: true,
|
||||
NPopconfirm: true,
|
||||
NTooltip: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const slackRow = wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))
|
||||
expect(slackRow).toBeTruthy()
|
||||
await slackRow!.trigger('contextmenu')
|
||||
;(wrapper.vm as any).handleContextMenuSelect('pin')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(prefsStore.pinnedIds).toEqual(['slack-1'])
|
||||
const groupLabelsAfterPin = wrapper.findAll('.session-group-label').map(node => node.text())
|
||||
expect(groupLabelsAfterPin[0]).toBe('chat.pinned')
|
||||
expect(wrapper.findAll('.session-item-title').map(node => node.text()).filter(text => text === 'Slack Selected')).toHaveLength(1)
|
||||
|
||||
const pinnedRow = wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))
|
||||
await pinnedRow!.trigger('contextmenu')
|
||||
;(wrapper.vm as any).handleContextMenuSelect('pin')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(prefsStore.pinnedIds).toEqual([])
|
||||
expect(wrapper.findAll('.session-group-label').map(node => node.text())).not.toContain('chat.pinned')
|
||||
expect(wrapper.findAll('.session-item-title').map(node => node.text()).filter(text => text === 'Slack Selected')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not prune saved pins before sessions have completed loading or when the list is empty', () => {
|
||||
const prefsStore = useSessionBrowserPrefsStore()
|
||||
const pruneSpy = vi.spyOn(prefsStore, 'pruneMissingSessions')
|
||||
mockChatStore.sessions = []
|
||||
mockChatStore.activeSessionId = null
|
||||
mockChatStore.activeSession = null
|
||||
mockChatStore.sessionsLoaded = false
|
||||
|
||||
mount(ChatPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
NButton: NButtonStub,
|
||||
NDropdown: NDropdownStub,
|
||||
NInput: true,
|
||||
NModal: true,
|
||||
NPopconfirm: true,
|
||||
NTooltip: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(pruneSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches between live and chat mode with accessible pressed state and restores sidebar visibility', async () => {
|
||||
const wrapper = mount(ChatPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
NDropdown: NDropdownStub,
|
||||
NInput: true,
|
||||
NModal: true,
|
||||
NPopconfirm: true,
|
||||
NTooltip: true,
|
||||
NButton: NButtonStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modeButtons = wrapper.findAll('.chat-mode-toggle button')
|
||||
expect(modeButtons[0].attributes('aria-pressed')).toBe('true')
|
||||
expect(modeButtons[1].attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.find('.session-list').classes()).not.toContain('collapsed')
|
||||
|
||||
await modeButtons[1].trigger('click')
|
||||
const liveButtons = wrapper.findAll('.chat-mode-toggle button')
|
||||
expect(liveButtons[0].attributes('aria-pressed')).toBe('false')
|
||||
expect(liveButtons[1].attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.conversation-monitor-mock').exists()).toBe(true)
|
||||
|
||||
await liveButtons[0].trigger('click')
|
||||
const chatButtons = wrapper.findAll('.chat-mode-toggle button')
|
||||
expect(chatButtons[0].attributes('aria-pressed')).toBe('true')
|
||||
expect(chatButtons[1].attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.find('.session-list').classes()).not.toContain('collapsed')
|
||||
expect(wrapper.find('.chat-input-mock').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,10 +13,23 @@ const mockChatStore = vi.hoisted(() => ({
|
||||
deleteSession: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockPrefsStore = vi.hoisted(() => ({
|
||||
pinnedIds: [] as string[],
|
||||
humanOnly: true,
|
||||
isPinned: vi.fn(() => false),
|
||||
togglePinned: vi.fn(),
|
||||
setHumanOnly: vi.fn(),
|
||||
pruneMissingSessions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/chat', () => ({
|
||||
useChatStore: () => mockChatStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/session-browser-prefs', () => ({
|
||||
useSessionBrowserPrefsStore: () => mockPrefsStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
renameSession: vi.fn(),
|
||||
}))
|
||||
@@ -33,6 +46,12 @@ vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/ConversationMonitorPane.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="conversation-monitor-mock" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -128,8 +147,8 @@ describe('ChatPanel session list', () => {
|
||||
const sessionTitles = wrapper.findAll('.session-item-title').map(node => node.text())
|
||||
expect(sessionTitles.slice(0, 2)).toEqual(['Discord Active', 'Discord Older'])
|
||||
|
||||
const activeIndicator = wrapper.find('.session-item.active .session-item-active-indicator')
|
||||
expect(activeIndicator.exists()).toBe(true)
|
||||
const liveRow = wrapper.findAll('.session-item').find(node => node.text().includes('Discord Active'))
|
||||
expect(liveRow?.find('.session-item-active-indicator').exists()).toBe(true)
|
||||
|
||||
await wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))!.trigger('click')
|
||||
|
||||
@@ -137,8 +156,5 @@ describe('ChatPanel session list', () => {
|
||||
|
||||
const groupLabelsAfterClick = wrapper.findAll('.session-group-label').map(node => node.text())
|
||||
expect(groupLabelsAfterClick[0]).toBe('Discord')
|
||||
|
||||
const activeTitlesAfterClick = wrapper.findAll('.session-item.active .session-item-title').map(node => node.text())
|
||||
expect(activeTitlesAfterClick).toEqual(['Discord Active'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,12 @@ async function flushPromises() {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const PROFILE = 'default'
|
||||
const ACTIVE_SESSION_KEY = `hermes_active_session_${PROFILE}`
|
||||
const SESSIONS_CACHE_KEY = `hermes_sessions_cache_v1_${PROFILE}`
|
||||
const sessionMessagesKey = (sessionId: string) => `hermes_session_msgs_v1_${PROFILE}_${sessionId}_`
|
||||
const inFlightKey = (sessionId: string) => `hermes_in_flight_v1_${PROFILE}_${sessionId}`
|
||||
|
||||
describe('Chat Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -82,19 +88,20 @@ describe('Chat Store', () => {
|
||||
{ id: 'm1', role: 'user', content: 'draft', timestamp: 1 },
|
||||
]
|
||||
|
||||
window.localStorage.setItem('hermes_active_session', 'local-1')
|
||||
window.localStorage.setItem('hermes_sessions_cache_v1', JSON.stringify([cachedSession]))
|
||||
window.localStorage.setItem('hermes_session_msgs_v1_local-1', JSON.stringify(cachedMessages))
|
||||
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'local-1')
|
||||
window.localStorage.setItem(SESSIONS_CACHE_KEY, JSON.stringify([cachedSession]))
|
||||
window.localStorage.setItem(sessionMessagesKey('local-1'), JSON.stringify(cachedMessages))
|
||||
|
||||
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('remote-1', 'Remote Session')])
|
||||
mockSessionsApi.fetchSession.mockResolvedValue(null)
|
||||
|
||||
const store = useChatStore()
|
||||
const loadPromise = store.loadSessions()
|
||||
|
||||
expect(store.activeSessionId).toBe('local-1')
|
||||
expect(store.messages.map(m => m.content)).toEqual(['draft'])
|
||||
|
||||
await flushPromises()
|
||||
await loadPromise
|
||||
|
||||
expect(store.sessions.map(s => s.id)).toEqual(['local-1', 'remote-1'])
|
||||
expect(store.activeSession?.id).toBe('local-1')
|
||||
@@ -109,10 +116,10 @@ describe('Chat Store', () => {
|
||||
|
||||
const sid = store.activeSessionId
|
||||
expect(sid).toBeTruthy()
|
||||
expect(window.localStorage.getItem('hermes_active_session')).toBe(sid)
|
||||
expect(window.localStorage.getItem(ACTIVE_SESSION_KEY)).toBe(sid)
|
||||
|
||||
const cachedMessages = JSON.parse(
|
||||
window.localStorage.getItem(`hermes_session_msgs_v1_${sid}`) || '[]',
|
||||
window.localStorage.getItem(sessionMessagesKey(sid!)) || '[]',
|
||||
)
|
||||
expect(cachedMessages).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -127,9 +134,9 @@ describe('Chat Store', () => {
|
||||
it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
window.localStorage.setItem('hermes_active_session', 'sess-1')
|
||||
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'sess-1')
|
||||
window.localStorage.setItem(
|
||||
'hermes_sessions_cache_v1',
|
||||
SESSIONS_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'sess-1',
|
||||
@@ -142,7 +149,7 @@ describe('Chat Store', () => {
|
||||
]),
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
'hermes_session_msgs_v1_sess-1',
|
||||
sessionMessagesKey('sess-1'),
|
||||
JSON.stringify([
|
||||
{ id: 'old-user', role: 'user', content: 'old prompt', timestamp: 1 },
|
||||
]),
|
||||
@@ -221,6 +228,6 @@ describe('Chat Store', () => {
|
||||
expect(store.messages.some(m => m.role === 'system' && m.content.includes('SSE connection error'))).toBe(false)
|
||||
expect(store.messages.some(m => m.role === 'assistant' && m.content === 'final answer')).toBe(true)
|
||||
expect(store.isRunActive).toBe(false)
|
||||
expect(window.localStorage.getItem('hermes_in_flight_v1_sess-1')).toBeNull()
|
||||
expect(window.localStorage.getItem(inFlightKey('sess-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockConversationsApi = vi.hoisted(() => ({
|
||||
fetchConversationSummaries: vi.fn(),
|
||||
fetchConversationDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/conversations', () => mockConversationsApi)
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'chat.linkedSessions' && params?.count != null) return `${params.count} linked`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import ConversationMonitorPane from '@/components/hermes/chat/ConversationMonitorPane.vue'
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(res => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('ConversationMonitorPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockConversationsApi.fetchConversationSummaries.mockResolvedValue([
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: 'First conversation',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
started_at: 10,
|
||||
ended_at: 20,
|
||||
last_active: 20,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
preview: 'preview',
|
||||
is_active: true,
|
||||
thread_session_count: 1,
|
||||
},
|
||||
{
|
||||
id: 'conv-2',
|
||||
title: 'Second conversation',
|
||||
source: 'discord',
|
||||
model: 'openai/gpt-5.4',
|
||||
started_at: 30,
|
||||
ended_at: 40,
|
||||
last_active: 40,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
preview: 'preview-2',
|
||||
is_active: false,
|
||||
thread_session_count: 2,
|
||||
},
|
||||
])
|
||||
mockConversationsApi.fetchConversationDetail.mockResolvedValue({
|
||||
session_id: 'conv-1',
|
||||
visible_count: 2,
|
||||
thread_session_count: 1,
|
||||
messages: [
|
||||
{ id: 1, session_id: 'conv-1', role: 'user', content: 'hello', timestamp: 11 },
|
||||
{ id: 2, session_id: 'conv-1', role: 'assistant', content: 'world', timestamp: 12 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads conversations and the first transcript using the humanOnly preference', async () => {
|
||||
const wrapper = mount(ConversationMonitorPane, {
|
||||
props: { humanOnly: true },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockConversationsApi.fetchConversationSummaries).toHaveBeenCalledWith({ humanOnly: true })
|
||||
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true })
|
||||
expect(wrapper.text()).toContain('First conversation')
|
||||
expect(wrapper.text()).toContain('hello')
|
||||
expect(wrapper.text()).toContain('world')
|
||||
})
|
||||
|
||||
it('ignores stale detail responses when selection changes quickly', async () => {
|
||||
const first = deferred<any>()
|
||||
const second = deferred<any>()
|
||||
mockConversationsApi.fetchConversationDetail
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const wrapper = mount(ConversationMonitorPane, {
|
||||
props: { humanOnly: true },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true })
|
||||
|
||||
const sessionButtons = wrapper.findAll('.conversation-monitor__session')
|
||||
expect(sessionButtons).toHaveLength(2)
|
||||
await sessionButtons[1].trigger('click')
|
||||
|
||||
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenLastCalledWith('conv-2', { humanOnly: true })
|
||||
|
||||
second.resolve({
|
||||
session_id: 'conv-2',
|
||||
visible_count: 1,
|
||||
thread_session_count: 2,
|
||||
messages: [
|
||||
{ id: 21, session_id: 'conv-2', role: 'assistant', content: 'newer detail wins', timestamp: 41 },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
first.resolve({
|
||||
session_id: 'conv-1',
|
||||
visible_count: 1,
|
||||
thread_session_count: 1,
|
||||
messages: [
|
||||
{ id: 11, session_id: 'conv-1', role: 'assistant', content: 'stale detail loses', timestamp: 12 },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const renderedMessages = wrapper.findAll('.conversation-monitor__message-content').map(node => node.text())
|
||||
expect(renderedMessages).toEqual(['newer detail wins'])
|
||||
})
|
||||
|
||||
it('clears the polling interval on unmount', async () => {
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
|
||||
const wrapper = mount(ConversationMonitorPane, {
|
||||
props: { humanOnly: true },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockRequest = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
request: mockRequest,
|
||||
}))
|
||||
|
||||
import { fetchConversationDetail, fetchConversationSummaries } from '@/api/hermes/conversations'
|
||||
|
||||
describe('conversations api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('builds summaries URLs with optional params', async () => {
|
||||
mockRequest.mockResolvedValue({ sessions: [] })
|
||||
|
||||
await fetchConversationSummaries()
|
||||
await fetchConversationSummaries({ humanOnly: false, source: 'cli', limit: 25 })
|
||||
|
||||
expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/hermes/sessions/conversations')
|
||||
expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/hermes/sessions/conversations?humanOnly=false&source=cli&limit=25')
|
||||
})
|
||||
|
||||
it('encodes detail URLs and forwards optional params', async () => {
|
||||
mockRequest.mockResolvedValue({ session_id: 'conv', messages: [], visible_count: 0, thread_session_count: 1 })
|
||||
|
||||
await fetchConversationDetail('folder/with spaces', { humanOnly: false, source: 'discord' })
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/sessions/conversations/folder%2Fwith%20spaces/messages?humanOnly=false&source=discord')
|
||||
})
|
||||
|
||||
it('propagates conversation detail errors so the monitor can render an error state', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('boom'))
|
||||
|
||||
await expect(fetchConversationDetail('conv-1', { humanOnly: true })).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
@@ -72,12 +72,21 @@ describe('Profiles Store', () => {
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
])
|
||||
|
||||
window.localStorage.setItem('hermes_sessions_cache_v1_test', '[]')
|
||||
window.localStorage.setItem('hermes_session_msgs_v1_test_session-1', '[]')
|
||||
window.localStorage.setItem('hermes_in_flight_v1_test_session-1', '{}')
|
||||
window.localStorage.setItem('hermes_active_session_test', 'session-1')
|
||||
window.localStorage.setItem('hermes_session_pins_v1_test', '[]')
|
||||
window.localStorage.setItem('hermes_human_only_v1_test', 'false')
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', gateway: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
await store.deleteProfile('test')
|
||||
|
||||
expect(store.detailMap['test']).toBeUndefined()
|
||||
expect(window.localStorage.getItem('hermes_session_pins_v1_test')).toBeNull()
|
||||
expect(window.localStorage.getItem('hermes_human_only_v1_test')).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchProfileDetail uses cache', async () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
|
||||
describe('session browser prefs store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('persists pins per profile and prunes missing sessions', () => {
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
|
||||
const store = useSessionBrowserPrefsStore()
|
||||
expect(store.pinnedIds).toEqual([])
|
||||
|
||||
store.togglePinned('session-1')
|
||||
store.togglePinned('session-2')
|
||||
expect(store.pinnedIds).toEqual(['session-1', 'session-2'])
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-1', 'session-2'])
|
||||
|
||||
expect(store.pruneMissingSessions(['session-2'])).toBe(true)
|
||||
expect(store.pinnedIds).toEqual(['session-2'])
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-2'])
|
||||
})
|
||||
|
||||
it('does not erase saved pins when the current session list is transiently empty', () => {
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
const store = useSessionBrowserPrefsStore()
|
||||
|
||||
store.togglePinned('session-1')
|
||||
expect(store.pruneMissingSessions([])).toBe(false)
|
||||
expect(store.pinnedIds).toEqual(['session-1'])
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-1'])
|
||||
})
|
||||
|
||||
it('reloads pin and human-only preferences automatically when the active profile changes', async () => {
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
const store = useSessionBrowserPrefsStore()
|
||||
|
||||
expect(store.humanOnly).toBe(true)
|
||||
store.togglePinned('default-session')
|
||||
store.setHumanOnly(false)
|
||||
|
||||
window.localStorage.setItem('hermes_session_pins_v1_work', JSON.stringify(['work-session']))
|
||||
window.localStorage.setItem('hermes_human_only_v1_work', JSON.stringify(true))
|
||||
|
||||
profilesStore.activeProfileName = 'work'
|
||||
await nextTick()
|
||||
|
||||
expect(store.profileName).toBe('work')
|
||||
expect(store.pinnedIds).toEqual(['work-session'])
|
||||
expect(store.humanOnly).toBe(true)
|
||||
|
||||
profilesStore.activeProfileName = 'default'
|
||||
await nextTick()
|
||||
|
||||
expect(store.pinnedIds).toEqual(['default-session'])
|
||||
expect(store.humanOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockSettingsStore = vi.hoisted(() => ({
|
||||
sessionReset: { mode: 'both', idle_minutes: 60, at_hour: 0 },
|
||||
saveSection: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockPrefsStore = vi.hoisted(() => ({
|
||||
humanOnly: true,
|
||||
setHumanOnly: vi.fn((value: boolean) => {
|
||||
mockPrefsStore.humanOnly = value
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/settings', () => ({
|
||||
useSettingsStore: () => mockSettingsStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/session-browser-prefs', () => ({
|
||||
useSessionBrowserPrefsStore: () => mockPrefsStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
|
||||
|
||||
describe('SessionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrefsStore.humanOnly = true
|
||||
})
|
||||
|
||||
it('surfaces the human-only preference in the Session tab', async () => {
|
||||
const wrapper = mount(SessionSettings, {
|
||||
global: {
|
||||
stubs: {
|
||||
SettingRow: {
|
||||
props: ['label', 'hint'],
|
||||
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
|
||||
},
|
||||
NSelect: true,
|
||||
NInputNumber: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('settings.session.liveMonitorHumanOnly')
|
||||
|
||||
const toggle = wrapper.find('.n-switch')
|
||||
expect(toggle.exists()).toBe(true)
|
||||
|
||||
await toggle.trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockPrefsStore.setHumanOnly).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
+110
-66
@@ -1,21 +1,40 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
type FsMocks = {
|
||||
readFile: ReturnType<typeof vi.fn>
|
||||
writeFile: ReturnType<typeof vi.fn>
|
||||
mkdir: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Mock config
|
||||
vi.mock('../../packages/server/src/config', () => ({
|
||||
config: { dataDir: '/tmp/hermes-test-data' },
|
||||
}))
|
||||
async function loadAuth(overrides: Partial<FsMocks> & { home?: string } = {}) {
|
||||
const readFile = overrides.readFile ?? vi.fn()
|
||||
const writeFile = overrides.writeFile ?? vi.fn()
|
||||
const mkdir = overrides.mkdir ?? vi.fn()
|
||||
const home = overrides.home ?? '/tmp/hermes-home'
|
||||
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { getToken, authMiddleware } from '../../packages/server/src/services/auth'
|
||||
vi.resetModules()
|
||||
vi.doMock('fs/promises', () => ({ readFile, writeFile, mkdir }))
|
||||
vi.doMock('os', () => ({ homedir: () => home }))
|
||||
|
||||
const mockedReadFile = vi.mocked(readFile)
|
||||
const mockedWriteFile = vi.mocked(writeFile)
|
||||
const mod = await import('../../packages/server/src/services/auth')
|
||||
return {
|
||||
...mod,
|
||||
mocks: { readFile, writeFile, mkdir },
|
||||
appHome: `${home}/.hermes-web-ui`,
|
||||
tokenFile: `${home}/.hermes-web-ui/.token`,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCtx(path: string, headers: Record<string, string> = {}, query: Record<string, string> = {}) {
|
||||
return {
|
||||
path,
|
||||
headers,
|
||||
query,
|
||||
status: 200,
|
||||
body: null,
|
||||
set: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Auth Service', () => {
|
||||
const originalEnv = process.env
|
||||
@@ -32,98 +51,125 @@ describe('Auth Service', () => {
|
||||
describe('getToken', () => {
|
||||
it('returns null when AUTH_DISABLED=1', async () => {
|
||||
process.env.AUTH_DISABLED = '1'
|
||||
const { getToken, mocks } = await loadAuth()
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockedReadFile).not.toHaveBeenCalled()
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when AUTH_DISABLED=true', async () => {
|
||||
process.env.AUTH_DISABLED = 'true'
|
||||
const { getToken } = await loadAuth()
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
await expect(getToken()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns AUTH_TOKEN env var if set', async () => {
|
||||
process.env.AUTH_TOKEN = 'my-custom-token'
|
||||
const { getToken, mocks } = await loadAuth()
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBe('my-custom-token')
|
||||
expect(mockedReadFile).not.toHaveBeenCalled()
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads token from file if exists', async () => {
|
||||
mockedReadFile.mockResolvedValue('file-token\n')
|
||||
it('reads token from file if it exists', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue('file-token\n')
|
||||
const { getToken, tokenFile } = await loadAuth({ readFile })
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBe('file-token')
|
||||
expect(mockedReadFile).toHaveBeenCalledWith('/tmp/hermes-test-data/.token', 'utf-8')
|
||||
expect(readFile).toHaveBeenCalledWith(tokenFile, 'utf-8')
|
||||
})
|
||||
|
||||
it('generates and saves new token if file missing', async () => {
|
||||
mockedReadFile.mockRejectedValue(new Error('ENOENT'))
|
||||
it('generates and saves a token if the token file is missing', async () => {
|
||||
const readFile = vi.fn().mockRejectedValue(new Error('ENOENT'))
|
||||
const writeFile = vi.fn()
|
||||
const mkdir = vi.fn()
|
||||
const { getToken, appHome, tokenFile } = await loadAuth({ readFile, writeFile, mkdir })
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeTruthy()
|
||||
expect(token).toHaveLength(64) // 32 bytes hex
|
||||
expect(mockedWriteFile).toHaveBeenCalledWith(
|
||||
'/tmp/hermes-test-data/.token',
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(mkdir).toHaveBeenCalledWith(appHome, { recursive: true })
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
tokenFile,
|
||||
expect.stringMatching(/^[a-f0-9]{64}\n$/),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
function createMockCtx(path: string, headers: Record<string, string> = {}, query: Record<string, string> = {}) {
|
||||
return {
|
||||
path,
|
||||
headers,
|
||||
query,
|
||||
status: 200,
|
||||
body: null,
|
||||
set: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
const next = vi.fn()
|
||||
|
||||
describe('requireAuth', () => {
|
||||
it('allows all requests when auth is disabled (null token)', async () => {
|
||||
const middleware = await authMiddleware(null)
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth(null)
|
||||
const ctx = createMockCtx('/api/hermes/sessions')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips /health path', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('skips /health', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/health')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(ctx.status).toBe(200)
|
||||
})
|
||||
|
||||
it('skips /webhook because it is treated as a public non-API path', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/webhook')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(ctx.status).toBe(200)
|
||||
})
|
||||
|
||||
it('skips non-API paths', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/index.html')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(ctx.status).toBe(200)
|
||||
})
|
||||
|
||||
it('requires auth for /webhook path (it is an API-like endpoint)', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/webhook', {})
|
||||
it('requires auth for /upload', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/upload')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request without auth header for protected API routes', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
@@ -131,19 +177,11 @@ describe('Auth Service', () => {
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request without auth header', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request with wrong token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('rejects request with the wrong bearer token', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
@@ -151,18 +189,22 @@ describe('Auth Service', () => {
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows request with correct Bearer token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('allows request with the correct bearer token', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer secret' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows request with correct query token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('allows request with the correct query token', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', {}, { token: 'secret' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
@@ -170,8 +212,10 @@ describe('Auth Service', () => {
|
||||
})
|
||||
|
||||
it('returns 401 JSON on auth failure', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const exportSessionsRawMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
exportSessionsRaw: exportSessionsRawMock,
|
||||
}))
|
||||
|
||||
describe('conversations service', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-20T00:00:00Z'))
|
||||
exportSessionsRawMock.mockReset()
|
||||
})
|
||||
|
||||
it('aggregates a single compression continuation even when the child preview differs', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'root',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: null,
|
||||
started_at: 100,
|
||||
ended_at: 110,
|
||||
end_reason: 'compression',
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 5,
|
||||
output_tokens: 8,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0.1,
|
||||
actual_cost_usd: 0.1,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{ id: 1, session_id: 'root', role: 'user', content: 'Start here', timestamp: 101 },
|
||||
{ id: 2, session_id: 'root', role: 'assistant', content: 'Assistant reply', timestamp: 102 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'root-cont',
|
||||
parent_session_id: 'root',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Continuation',
|
||||
started_at: 110,
|
||||
ended_at: 111,
|
||||
end_reason: null,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 3,
|
||||
output_tokens: 4,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0.2,
|
||||
actual_cost_usd: 0.2,
|
||||
cost_status: 'final',
|
||||
messages: [
|
||||
{ id: 3, session_id: 'root-cont', role: 'user', content: 'Continue with more detail', timestamp: 110 },
|
||||
{ id: 4, session_id: 'root-cont', role: 'assistant', content: 'Continued answer', timestamp: 111 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const summaries = await mod.listConversationSummaries({ humanOnly: true })
|
||||
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(summaries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'root',
|
||||
thread_session_count: 2,
|
||||
ended_at: 111,
|
||||
cost_status: 'mixed',
|
||||
actual_cost_usd: 0.30000000000000004,
|
||||
}),
|
||||
)
|
||||
|
||||
const detail = await mod.getConversationDetail('root', { humanOnly: true })
|
||||
expect(detail?.thread_session_count).toBe(2)
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual([
|
||||
'Start here',
|
||||
'Assistant reply',
|
||||
'Continue with more detail',
|
||||
'Continued answer',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats branched children as their own visible conversations', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'root',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Root',
|
||||
started_at: 100,
|
||||
ended_at: 200,
|
||||
end_reason: 'branched',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [{ id: 1, session_id: 'root', role: 'user', content: 'Root prompt', timestamp: 101 }],
|
||||
},
|
||||
{
|
||||
id: 'branch-child',
|
||||
parent_session_id: 'root',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Branch child',
|
||||
started_at: 201,
|
||||
ended_at: 210,
|
||||
end_reason: null,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{ id: 2, session_id: 'branch-child', role: 'user', content: 'Branch prompt', timestamp: 202 },
|
||||
{ id: 3, session_id: 'branch-child', role: 'assistant', content: 'Branch answer', timestamp: 203 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const summaries = await mod.listConversationSummaries({ humanOnly: true })
|
||||
|
||||
expect(summaries.map((summary: any) => summary.id)).toEqual(['branch-child', 'root'])
|
||||
|
||||
const detail = await mod.getConversationDetail('branch-child', { humanOnly: true })
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
|
||||
})
|
||||
|
||||
it('excludes human-only conversations with no visible human messages', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'synthetic-root',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: null,
|
||||
started_at: 100,
|
||||
ended_at: 101,
|
||||
end_reason: null,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: 'synthetic-root',
|
||||
role: 'user',
|
||||
content: "You've reached the maximum number of tool-calling iterations allowed.",
|
||||
timestamp: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const summaries = await mod.listConversationSummaries({ humanOnly: true })
|
||||
const detail = await mod.getConversationDetail('synthetic-root', { humanOnly: true })
|
||||
|
||||
expect(summaries).toEqual([])
|
||||
expect(detail).toBeNull()
|
||||
})
|
||||
|
||||
it('caches raw exports briefly and normalizes structured message content', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'recent-open',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Recent open',
|
||||
started_at: 1776643190,
|
||||
ended_at: null,
|
||||
end_reason: null,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{
|
||||
id: 11,
|
||||
session_id: 'recent-open',
|
||||
role: 'assistant',
|
||||
content: [{ text: 'hello' }, { text: 'world' }],
|
||||
timestamp: 1776643198,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stale-open',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Stale open',
|
||||
started_at: 1776642000,
|
||||
ended_at: null,
|
||||
end_reason: null,
|
||||
message_count: 0,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const firstSummaries = await mod.listConversationSummaries({ humanOnly: false })
|
||||
const detail = await mod.getConversationDetail('recent-open', { humanOnly: false })
|
||||
const secondSummaries = await mod.listConversationSummaries({ humanOnly: false })
|
||||
|
||||
expect(exportSessionsRawMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstSummaries.find((summary: any) => summary.id === 'recent-open')?.is_active).toBe(true)
|
||||
expect(secondSummaries.find((summary: any) => summary.id === 'stale-open')?.is_active).toBe(false)
|
||||
expect(detail?.messages[0].content).toBe('hello\nworld')
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,16 @@ vi.mock('../../packages/server/src/config', () => ({
|
||||
config: { upstream: 'http://127.0.0.1:8642' },
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: () => null,
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
import { proxy } from '../../packages/server/src/routes/hermes/proxy-handler'
|
||||
|
||||
function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
let headersSent = false
|
||||
const ctx: any = {
|
||||
path: '/api/hermes/jobs',
|
||||
method: 'GET',
|
||||
@@ -31,6 +34,11 @@ function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
body: null,
|
||||
...overrides,
|
||||
}
|
||||
ctx.get = (name: string) => {
|
||||
const match = Object.entries(ctx.headers).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
const value = match?.[1]
|
||||
return Array.isArray(value) ? value[0] : value || ''
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -104,7 +112,7 @@ describe('Proxy Handler', () => {
|
||||
expect(options.headers.host).toBe('127.0.0.1:8642')
|
||||
})
|
||||
|
||||
it('forwards query string', async () => {
|
||||
it('forwards query string while stripping the web-ui token parameter', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'text/event-stream' }),
|
||||
@@ -112,11 +120,13 @@ describe('Proxy Handler', () => {
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx({ search: '?include_disabled=true' })
|
||||
const ctx = createMockCtx({ search: '?include_disabled=true&token=web-ui-token&profile=work' })
|
||||
await proxy(ctx)
|
||||
|
||||
const url = mockFetch.mock.calls[0][0]
|
||||
expect(url).toContain('?include_disabled=true')
|
||||
expect(url).toContain('profile=work')
|
||||
expect(url).not.toContain('token=')
|
||||
})
|
||||
|
||||
it('returns 502 on connection failure', async () => {
|
||||
|
||||
@@ -2,11 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const listSessionSummariesMock = vi.fn()
|
||||
const listSessionsMock = vi.fn()
|
||||
const listConversationSummariesMock = vi.fn()
|
||||
const getConversationDetailMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: listSessionSummariesMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
|
||||
listConversationSummaries: listConversationSummariesMock,
|
||||
getConversationDetail: getConversationDetailMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
listSessions: listSessionsMock,
|
||||
getSession: vi.fn(),
|
||||
@@ -19,6 +26,8 @@ describe('session routes', () => {
|
||||
vi.resetModules()
|
||||
listSessionSummariesMock.mockReset()
|
||||
listSessionsMock.mockReset()
|
||||
listConversationSummariesMock.mockReset()
|
||||
getConversationDetailMock.mockReset()
|
||||
})
|
||||
|
||||
it('serves summaries from sqlite-backed helper when available', async () => {
|
||||
@@ -49,4 +58,54 @@ describe('session routes', () => {
|
||||
expect(listSessionsMock).toHaveBeenCalledWith(undefined, 7)
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback' }] })
|
||||
})
|
||||
|
||||
it('serves live conversations with humanOnly defaulting to true', async () => {
|
||||
listConversationSummariesMock.mockResolvedValue([{ id: 'conversation-1' }])
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
|
||||
const handler = layer.stack[0]
|
||||
const ctx: any = { query: {}, body: null }
|
||||
|
||||
await handler(ctx)
|
||||
|
||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: true, source: undefined, limit: undefined })
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
|
||||
})
|
||||
|
||||
it('supports disabling humanOnly and forwarding limit/source for live conversations', async () => {
|
||||
listConversationSummariesMock.mockResolvedValue([{ id: 'child-session' }])
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
|
||||
|
||||
const listCtx: any = { query: { humanOnly: 'false', source: 'cli', limit: '25' }, body: null }
|
||||
await listLayer.stack[0](listCtx)
|
||||
|
||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: false, source: 'cli', limit: 25 })
|
||||
expect(listCtx.body).toEqual({ sessions: [{ id: 'child-session' }] })
|
||||
})
|
||||
|
||||
it('returns conversation detail and forwards humanOnly/source', async () => {
|
||||
getConversationDetailMock.mockResolvedValue({ session_id: 'child-session', messages: [] })
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
|
||||
|
||||
const detailCtx: any = { params: { id: 'child-session' }, query: { humanOnly: 'false', source: 'discord' }, body: null, status: 200 }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
|
||||
expect(getConversationDetailMock).toHaveBeenCalledWith('child-session', { humanOnly: false, source: 'discord' })
|
||||
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
|
||||
})
|
||||
|
||||
it('returns 404 when a conversation detail is not found', async () => {
|
||||
getConversationDetailMock.mockResolvedValue(null)
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
|
||||
|
||||
const detailCtx: any = { params: { id: 'missing' }, query: {}, body: null, status: 200 }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
|
||||
expect(getConversationDetailMock).toHaveBeenCalledWith('missing', { humanOnly: true, source: undefined })
|
||||
expect(detailCtx.status).toBe(404)
|
||||
expect(detailCtx.body).toEqual({ error: 'Conversation not found' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user