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:
Zhicheng Han
2026-04-22 02:09:58 +02:00
committed by GitHub
parent 83ad9642e2
commit 3f88553765
34 changed files with 2497 additions and 278 deletions
+110 -66
View File
@@ -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)
+263
View File
@@ -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')
})
})
+13 -3
View File
@@ -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 () => {
+59
View File
@@ -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' })
})
})