feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// vi.mock is hoisted, so mockReplace must be inside the factory
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { name: 'hermes.chat' } },
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
|
||||
import { getDownloadUrl } from '../../packages/client/src/api/hermes/download'
|
||||
import { uploadFiles } from '../../packages/client/src/api/hermes/files'
|
||||
import { batchDeleteSessions, importHermesSession } from '../../packages/client/src/api/hermes/sessions'
|
||||
import router from '@/router'
|
||||
|
||||
function fakeJwt(payload: Record<string, unknown>) {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('token management', () => {
|
||||
it('hasApiKey returns false when no token', () => {
|
||||
expect(hasApiKey()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasApiKey returns true after setApiKey', () => {
|
||||
setApiKey('test-token')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('getApiKey returns the stored token', () => {
|
||||
setApiKey('my-token')
|
||||
expect(getApiKey()).toBe('my-token')
|
||||
})
|
||||
|
||||
it('clearApiKey removes the token', () => {
|
||||
setApiKey('my-token')
|
||||
clearApiKey()
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(getApiKey()).toBe('')
|
||||
})
|
||||
|
||||
it('reads the role from the stored JWT payload', () => {
|
||||
setApiKey(fakeJwt({ sub: '1', role: 'super_admin' }))
|
||||
|
||||
expect(getStoredUserRole()).toBe('super_admin')
|
||||
expect(isStoredSuperAdmin()).toBe(true)
|
||||
|
||||
setApiKey(fakeJwt({ sub: '2', role: 'admin' }))
|
||||
expect(getStoredUserRole()).toBe('admin')
|
||||
expect(isStoredSuperAdmin()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request', () => {
|
||||
it('adds Authorization header when token exists', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers.Authorization).toBe('Bearer secret-key')
|
||||
})
|
||||
|
||||
it('adds the active profile header, including default', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'default')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions/session-1')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('default')
|
||||
})
|
||||
|
||||
it('does not add the active profile header to profile-wide session collection requests', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers['X-Hermes-Profile']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not add Authorization header when no token', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears token and redirects on 401 for local BFF endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401 })
|
||||
|
||||
await expect(request('/api/hermes/sessions')).rejects.toThrow('Unauthorized')
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('emits a global auth notice on local 403 responses', async () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('hermes-auth-notice', listener)
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve('Forbidden') })
|
||||
|
||||
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'forbidden' })
|
||||
window.removeEventListener('hermes-auth-notice', listener)
|
||||
})
|
||||
|
||||
it('clears token and redirects when the JWT user no longer exists', async () => {
|
||||
setApiKey('stale-jwt')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve('{"error":"User is disabled or does not exist"}'),
|
||||
})
|
||||
|
||||
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
|
||||
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied v1 endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
|
||||
await expect(request('/api/hermes/v1/runs')).rejects.toThrow('API Error 401')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('throws error on non-401 failure', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
})
|
||||
|
||||
await expect(request('/api/hermes/sessions')).rejects.toThrow('API Error 500: Internal Server Error')
|
||||
})
|
||||
|
||||
it('returns parsed JSON on success', async () => {
|
||||
const data = { sessions: [{ id: '1' }] }
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(data) })
|
||||
|
||||
const result = await request('/api/hermes/sessions')
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download URLs', () => {
|
||||
it('adds the active profile selector to direct download URLs', () => {
|
||||
setApiKey('secret-key')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
|
||||
const url = new URL(getDownloadUrl('/tmp/report.txt', 'report.txt'), 'http://localhost')
|
||||
|
||||
expect(url.pathname).toBe('/api/hermes/download')
|
||||
expect(url.searchParams.get('path')).toBe('/tmp/report.txt')
|
||||
expect(url.searchParams.get('name')).toBe('report.txt')
|
||||
expect(url.searchParams.get('profile')).toBe('research')
|
||||
expect(url.searchParams.get('token')).toBe('secret-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('file upload', () => {
|
||||
it('adds auth and active profile headers to multipart uploads', async () => {
|
||||
setApiKey('secret-key')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ files: [] }),
|
||||
})
|
||||
|
||||
await uploadFiles('notes', [new File(['hello'], 'hello.txt', { type: 'text/plain' })])
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/hermes/files/upload?path=notes')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.headers.Authorization).toBe('Bearer secret-key')
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
||||
expect(options.body).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions API', () => {
|
||||
it('sends profile-qualified targets for batch deletes', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ deleted: 2, failed: 0, errors: [] }),
|
||||
})
|
||||
|
||||
await batchDeleteSessions([
|
||||
{ id: 'session-default', profile: 'default' },
|
||||
{ id: 'session-travel', profile: 'travel' },
|
||||
])
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/hermes/sessions/batch-delete')
|
||||
expect(options.method).toBe('POST')
|
||||
expect(options.headers['X-Hermes-Profile']).toBeUndefined()
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
ids: ['session-default', 'session-travel'],
|
||||
sessions: [
|
||||
{ id: 'session-default', profile: 'default' },
|
||||
{ id: 'session-travel', profile: 'travel' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the profile selector when importing a Hermes session', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ ok: true, imported: true }),
|
||||
})
|
||||
|
||||
await importHermesSession('cli-1', 'travel')
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/hermes/sessions/hermes/cli-1/import?profile=travel')
|
||||
expect(options.method).toBe('POST')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,492 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockSystemApi = vi.hoisted(() => ({
|
||||
checkHealth: vi.fn(),
|
||||
fetchAvailableModels: vi.fn(),
|
||||
addCustomModel: vi.fn(),
|
||||
removeCustomModel: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
updateModelAlias: vi.fn(),
|
||||
updateModelVisibility: vi.fn(),
|
||||
triggerUpdate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/system', () => mockSystemApi)
|
||||
vi.mock('@/api/client', () => ({ hasApiKey: () => true }))
|
||||
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
|
||||
describe('App Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockSystemApi.addCustomModel.mockResolvedValue({ success: true, custom_models: {} })
|
||||
mockSystemApi.removeCustomModel.mockResolvedValue({ success: true, custom_models: {} })
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('persists desktop sidebar collapsed state to localStorage', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
|
||||
store.toggleSidebarCollapsed()
|
||||
expect(store.sidebarCollapsed).toBe(true)
|
||||
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('1')
|
||||
|
||||
store.toggleSidebarCollapsed()
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
|
||||
})
|
||||
|
||||
it('loads model visibility and falls back when the configured default is hidden', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-chat',
|
||||
default_provider: 'deepseek',
|
||||
groups: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
allProviders: [],
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.modelVisibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(store.customModels).toEqual({})
|
||||
expect(store.isModelVisible('deepseek', 'deepseek-reasoner')).toBe(true)
|
||||
expect(store.isModelVisible('deepseek', 'deepseek-chat')).toBe(false)
|
||||
})
|
||||
|
||||
it('loads aliases while falling back from a hidden default without rehydrating it as custom', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-chat',
|
||||
default_provider: 'deepseek',
|
||||
groups: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-reasoner'],
|
||||
available_models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
allProviders: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
model_aliases: {
|
||||
deepseek: { 'deepseek-reasoner': 'Reasoner Alias' },
|
||||
},
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.modelAliases).toEqual({
|
||||
deepseek: { 'deepseek-reasoner': 'Reasoner Alias' },
|
||||
})
|
||||
expect(store.modelVisibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(store.displayModelName('deepseek-reasoner', 'deepseek')).toBe('Reasoner Alias')
|
||||
expect(store.customModels).toEqual({})
|
||||
})
|
||||
|
||||
it('persists model visibility without changing the canonical selected model id', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-reasoner',
|
||||
default_provider: 'deepseek',
|
||||
groups: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
allProviders: [],
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
mockSystemApi.updateModelVisibility.mockResolvedValue({
|
||||
success: true,
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.setModelVisibility('deepseek', { mode: 'include', models: ['deepseek-reasoner'] })
|
||||
|
||||
expect(mockSystemApi.updateModelVisibility).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
mode: 'include',
|
||||
models: ['deepseek-reasoner'],
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(mockSystemApi.updateDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks the client stale when the served Web UI version changes', async () => {
|
||||
mockSystemApi.checkHealth.mockResolvedValue({
|
||||
status: 'ok',
|
||||
webui_version: '0.5.17',
|
||||
webui_latest: '0.5.17',
|
||||
webui_update_available: false,
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.checkConnection()
|
||||
|
||||
expect(store.connected).toBe(true)
|
||||
expect(store.serverVersion).toBe('0.5.17')
|
||||
expect(store.clientOutdated).toBe(true)
|
||||
expect(store.updateAvailable).toBe(false)
|
||||
})
|
||||
|
||||
it('does not mark the client stale when the served Web UI version matches this bundle', async () => {
|
||||
mockSystemApi.checkHealth.mockResolvedValue({
|
||||
status: 'ok',
|
||||
webui_version: 'test',
|
||||
webui_latest: 'test',
|
||||
webui_update_available: false,
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.checkConnection()
|
||||
|
||||
expect(store.serverVersion).toBe('test')
|
||||
expect(store.clientOutdated).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the updating state and reports failure when self-update request fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
|
||||
const store = useAppStore()
|
||||
|
||||
const ok = await store.doUpdate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(store.updating).toBe(false)
|
||||
expect(consoleError).toHaveBeenCalledWith('Failed to update Hermes Web UI:', expect.any(Error))
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('loads model aliases and resolves display names without changing canonical IDs', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
model_aliases: {
|
||||
deepseek: { 'deepseek-v4-flash': 'Flash Alias' },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(store.getModelAlias('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias')
|
||||
expect(store.displayModelName('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias')
|
||||
expect(store.displayModelName('unknown', 'deepseek')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('selects the browser active profile default instead of the aggregate response default', async () => {
|
||||
window.localStorage.setItem('hermes_active_profile_name', 'tester')
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'glm-5-turbo',
|
||||
default_provider: 'custom:glm-coding-plan',
|
||||
groups: [{
|
||||
provider: 'custom:glm-coding-plan',
|
||||
label: 'glm-coding-plan',
|
||||
base_url: 'https://api.z.ai/api/anthropic',
|
||||
models: ['glm-5-turbo', 'glm-5.1'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
profiles: [
|
||||
{
|
||||
profile: 'default',
|
||||
default: 'glm-5-turbo',
|
||||
default_provider: 'custom:glm-coding-plan',
|
||||
groups: [{
|
||||
provider: 'custom:glm-coding-plan',
|
||||
label: 'glm-coding-plan',
|
||||
base_url: 'https://api.z.ai/api/anthropic',
|
||||
models: ['glm-5-turbo', 'glm-5.1'],
|
||||
api_key: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
profile: 'tester',
|
||||
default: 'claude-opus-4-6',
|
||||
default_provider: 'custom:subrouter',
|
||||
groups: [{
|
||||
provider: 'custom:subrouter',
|
||||
label: 'subrouter',
|
||||
base_url: 'https://subrouter.ai/v1',
|
||||
models: ['claude-opus-4-6', 'gpt-5.5'],
|
||||
api_key: '',
|
||||
}],
|
||||
},
|
||||
],
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('claude-opus-4-6')
|
||||
expect(store.selectedProvider).toBe('custom:subrouter')
|
||||
})
|
||||
|
||||
it('does not refetch available models within the cache window after an empty response', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: '',
|
||||
default_provider: '',
|
||||
groups: [],
|
||||
allProviders: [],
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
await store.loadModels()
|
||||
|
||||
expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits only up to the run timeout for the first available models request', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockSystemApi.fetchAvailableModels.mockReturnValue(new Promise(() => {}))
|
||||
const store = useAppStore()
|
||||
let resolved = false
|
||||
|
||||
const waitPromise = store.waitForModelsForRun(15000).then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(14999)
|
||||
expect(resolved).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await waitPromise
|
||||
expect(resolved).toBe(true)
|
||||
expect(store.modelGroups).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps aliases scoped to their provider when model IDs overlap', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'shared-model',
|
||||
default_provider: 'provider-a',
|
||||
groups: [
|
||||
{
|
||||
provider: 'provider-a',
|
||||
label: 'Provider A',
|
||||
base_url: 'https://a.example/v1',
|
||||
models: ['shared-model'],
|
||||
api_key: '',
|
||||
},
|
||||
{
|
||||
provider: 'provider-b',
|
||||
label: 'Provider B',
|
||||
base_url: 'https://b.example/v1',
|
||||
models: ['shared-model'],
|
||||
api_key: '',
|
||||
},
|
||||
],
|
||||
allProviders: [],
|
||||
model_aliases: {
|
||||
'provider-a': { 'shared-model': 'A Alias' },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.displayModelName('shared-model', 'provider-a')).toBe('A Alias')
|
||||
expect(store.displayModelName('shared-model', 'provider-b')).toBe('shared-model')
|
||||
expect(store.displayModelName('shared-model')).toBe('A Alias')
|
||||
})
|
||||
|
||||
it('rehydrates an active unlisted default model as removable after loading models', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'manually-supported-id',
|
||||
default_provider: 'deepseek',
|
||||
groups: [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
model_aliases: {},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('manually-supported-id')
|
||||
expect(store.customModels).toEqual({ deepseek: ['manually-supported-id'] })
|
||||
})
|
||||
|
||||
it('loads persisted custom models from the server response', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'gemma-4-26b-a4b-it',
|
||||
default_provider: 'google-ai-studio',
|
||||
groups: [{
|
||||
provider: 'google-ai-studio',
|
||||
label: 'Google AI Studio',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
models: ['gemma-4-26b-a4b-it'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
custom_models: {
|
||||
'google-ai-studio': ['gemma-4-26b-a4b-it'],
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('gemma-4-26b-a4b-it')
|
||||
expect(store.customModels).toEqual({
|
||||
'google-ai-studio': ['gemma-4-26b-a4b-it'],
|
||||
})
|
||||
})
|
||||
|
||||
it('saves and clears model aliases via the Web UI-only alias API', async () => {
|
||||
mockSystemApi.updateModelAlias.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.setModelAlias('deepseek-v4-flash', 'deepseek', ' Flash Alias ')
|
||||
|
||||
expect(mockSystemApi.updateModelAlias).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
alias: 'Flash Alias',
|
||||
})
|
||||
expect(store.modelAliases).toEqual({ deepseek: { 'deepseek-v4-flash': 'Flash Alias' } })
|
||||
|
||||
await store.setModelAlias('deepseek-v4-flash', 'deepseek', '')
|
||||
expect(store.modelAliases).toEqual({})
|
||||
})
|
||||
|
||||
it('removes an unlisted custom model and falls back to a listed model when active', async () => {
|
||||
mockSystemApi.updateDefaultModel.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
store.modelGroups = [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}]
|
||||
mockSystemApi.addCustomModel.mockResolvedValue({
|
||||
success: true,
|
||||
custom_models: { deepseek: ['test'] },
|
||||
})
|
||||
mockSystemApi.removeCustomModel.mockResolvedValue({
|
||||
success: true,
|
||||
custom_models: {},
|
||||
})
|
||||
|
||||
await store.switchModel('test', 'deepseek')
|
||||
expect(store.selectedModel).toBe('test')
|
||||
expect(store.customModels).toEqual({ deepseek: ['test'] })
|
||||
expect(mockSystemApi.addCustomModel).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
model: 'test',
|
||||
})
|
||||
|
||||
await store.removeCustomModel('test', 'deepseek')
|
||||
expect(store.customModels).toEqual({})
|
||||
expect(mockSystemApi.removeCustomModel).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
model: 'test',
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(mockSystemApi.updateDefaultModel).toHaveBeenLastCalledWith({
|
||||
default: 'deepseek-v4-flash',
|
||||
provider: 'deepseek',
|
||||
})
|
||||
})
|
||||
|
||||
it('removes deleted custom models from loaded model groups immediately', async () => {
|
||||
mockSystemApi.removeCustomModel.mockResolvedValue({
|
||||
success: true,
|
||||
custom_models: {},
|
||||
})
|
||||
const store = useAppStore()
|
||||
store.customModels = { deepseek: ['manual-model'] }
|
||||
store.modelGroups = [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash', 'manual-model'],
|
||||
available_models: ['deepseek-v4-flash', 'manual-model'],
|
||||
api_key: '',
|
||||
}]
|
||||
store.profileModelGroups = [{
|
||||
profile: 'default',
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash', 'manual-model'],
|
||||
available_models: ['deepseek-v4-flash', 'manual-model'],
|
||||
api_key: '',
|
||||
}],
|
||||
}]
|
||||
|
||||
await store.removeCustomModel('manual-model', 'deepseek')
|
||||
|
||||
expect(store.modelGroups[0].models).toEqual(['deepseek-v4-flash'])
|
||||
expect(store.modelGroups[0].available_models).toEqual(['deepseek-v4-flash'])
|
||||
expect(store.profileModelGroups[0].groups[0].models).toEqual(['deepseek-v4-flash'])
|
||||
expect(store.profileModelGroups[0].groups[0].available_models).toEqual(['deepseek-v4-flash'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { nextTick } from 'vue'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
|
||||
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
|
||||
NSwitch: { template: '<button type="button"></button>' },
|
||||
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
NInputNumber: { template: '<input />' },
|
||||
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
fetchContextLength: vi.fn().mockResolvedValue(256000),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/model-context', () => ({
|
||||
setModelContext: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToolTraceVisibility', () => ({
|
||||
useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }),
|
||||
}))
|
||||
|
||||
function mountForSession(sessionId: string) {
|
||||
const pinia = createTestingPinia({ stubActions: false, createSpy: vi.fn })
|
||||
const chatStore = useChatStore()
|
||||
chatStore.sessions = [
|
||||
{ id: sessionId, title: sessionId, source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now() },
|
||||
]
|
||||
chatStore.activeSessionId = sessionId
|
||||
chatStore.activeSession = chatStore.sessions[0]
|
||||
return mount(ChatInput, { global: { plugins: [pinia] } })
|
||||
}
|
||||
|
||||
describe('ChatInput draft persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('restores unsent text for the active session after the chat view is remounted', async () => {
|
||||
const wrapper = mountForSession('session-a')
|
||||
const textarea = wrapper.get('textarea')
|
||||
|
||||
await textarea.setValue('draft before tab switch')
|
||||
await nextTick()
|
||||
wrapper.unmount()
|
||||
|
||||
const remounted = mountForSession('session-a')
|
||||
await nextTick()
|
||||
|
||||
expect((remounted.get('textarea').element as HTMLTextAreaElement).value).toBe('draft before tab switch')
|
||||
})
|
||||
|
||||
it('stores drafts under one localStorage key mapped by session id', async () => {
|
||||
const wrapperA = mountForSession('session-a')
|
||||
await wrapperA.get('textarea').setValue('draft for session a')
|
||||
await nextTick()
|
||||
wrapperA.unmount()
|
||||
|
||||
const wrapperB = mountForSession('session-b')
|
||||
await wrapperB.get('textarea').setValue('draft for session b')
|
||||
await nextTick()
|
||||
wrapperB.unmount()
|
||||
|
||||
expect(localStorage.getItem('hermes_chat_input_draft_v1')).toBeNull()
|
||||
expect(JSON.parse(localStorage.getItem('hermes_chat_input_drafts_v1') || '{}')).toEqual({
|
||||
'session-a': 'draft for session a',
|
||||
'session-b': 'draft for session b',
|
||||
})
|
||||
|
||||
const remountedA = mountForSession('session-a')
|
||||
await nextTick()
|
||||
expect((remountedA.get('textarea').element as HTMLTextAreaElement).value).toBe('draft for session a')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
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 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const chatApi = vi.hoisted(() => ({
|
||||
resumeSession: vi.fn(),
|
||||
registerSessionHandlers: vi.fn(),
|
||||
unregisterSessionHandlers: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/chat', () => ({
|
||||
startRunViaSocket: vi.fn(),
|
||||
resumeSession: chatApi.resumeSession,
|
||||
registerSessionHandlers: chatApi.registerSessionHandlers,
|
||||
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
|
||||
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
|
||||
respondToolApproval: vi.fn(),
|
||||
respondClarify: vi.fn(),
|
||||
onPeerUserMessage: vi.fn(() => vi.fn()),
|
||||
onSessionCommand: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getActiveProfileName: () => 'default',
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
deleteSession: vi.fn(),
|
||||
fetchSession: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
setSessionModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/download', () => ({
|
||||
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/completion-sound', () => ({
|
||||
primeCompletionSound: vi.fn(),
|
||||
playCompletionSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
||||
|
||||
function makeSession(id: string): Session {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat store compression state', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
chatApi.resumeSession.mockImplementation((sessionId: string, onResumed: (data: any) => void) => {
|
||||
onResumed({
|
||||
session_id: sessionId,
|
||||
messages: [],
|
||||
isWorking: sessionId === 'session-1',
|
||||
events: [],
|
||||
})
|
||||
return {} as any
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a background session compression indicator in the active session', async () => {
|
||||
const store = useChatStore()
|
||||
store.sessions = [makeSession('session-1'), makeSession('session-2')]
|
||||
|
||||
await store.switchSession('session-1')
|
||||
const handlers = chatApi.registerSessionHandlers.mock.calls.find(call => call[0] === 'session-1')?.[1]
|
||||
expect(handlers).toBeTruthy()
|
||||
|
||||
await store.switchSession('session-2')
|
||||
handlers.onCompressionStarted({
|
||||
event: 'compression.started',
|
||||
session_id: 'session-1',
|
||||
message_count: 6,
|
||||
token_count: 1234,
|
||||
})
|
||||
|
||||
expect(store.activeSessionId).toBe('session-2')
|
||||
expect(store.compressionState).toBeNull()
|
||||
|
||||
await store.switchSession('session-1')
|
||||
expect(store.compressionState).toEqual(expect.objectContaining({
|
||||
compressing: true,
|
||||
messageCount: 6,
|
||||
beforeTokens: 1234,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const chatApi = vi.hoisted(() => ({
|
||||
registerSessionHandlers: vi.fn(),
|
||||
unregisterSessionHandlers: vi.fn(),
|
||||
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
|
||||
sessionCommandHandlers: [] as Array<(event: any) => void>,
|
||||
peerUserMessageHandlers: [] as Array<(event: any) => void>,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/chat', () => ({
|
||||
startRunViaSocket: vi.fn(),
|
||||
resumeSession: vi.fn(),
|
||||
registerSessionHandlers: chatApi.registerSessionHandlers,
|
||||
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
|
||||
getChatRunSocket: chatApi.getChatRunSocket,
|
||||
respondToolApproval: vi.fn(),
|
||||
respondClarify: vi.fn(),
|
||||
onPeerUserMessage: vi.fn((handler: (event: any) => void) => {
|
||||
chatApi.peerUserMessageHandlers.push(handler)
|
||||
return vi.fn()
|
||||
}),
|
||||
onSessionCommand: vi.fn((handler: (event: any) => void) => {
|
||||
chatApi.sessionCommandHandlers.push(handler)
|
||||
return vi.fn()
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getActiveProfileName: () => 'default',
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
deleteSession: vi.fn(),
|
||||
fetchSession: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
setSessionModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/download', () => ({
|
||||
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/completion-sound', () => ({
|
||||
primeCompletionSound: vi.fn(),
|
||||
playCompletionSound: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
||||
|
||||
function makeSession(): Session {
|
||||
return {
|
||||
id: 'session-1',
|
||||
title: 'session',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat store session.command fanout', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
chatApi.sessionCommandHandlers = []
|
||||
chatApi.peerUserMessageHandlers = []
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('attaches to a goal resume run started from another window', () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
expect(chatApi.sessionCommandHandlers).toHaveLength(1)
|
||||
|
||||
chatApi.sessionCommandHandlers[0]({
|
||||
event: 'session.command',
|
||||
session_id: 'session-1',
|
||||
command: 'goal',
|
||||
action: 'resume',
|
||||
message: 'Goal resumed',
|
||||
started: true,
|
||||
terminal: false,
|
||||
})
|
||||
|
||||
expect(store.isStreaming).toBe(true)
|
||||
expect(chatApi.registerSessionHandlers).toHaveBeenCalledWith('session-1', expect.objectContaining({
|
||||
onRunStarted: expect.any(Function),
|
||||
onSessionCommand: expect.any(Function),
|
||||
}))
|
||||
expect(store.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'command',
|
||||
content: 'Goal resumed',
|
||||
commandAction: 'resume',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('does not clear the transcript for goal done commands', () => {
|
||||
const store = useChatStore()
|
||||
const session = makeSession()
|
||||
session.messages = [
|
||||
{ id: 'user-1', role: 'user', content: 'keep me', timestamp: 1 },
|
||||
]
|
||||
store.sessions = [session]
|
||||
store.activeSessionId = 'session-1'
|
||||
store.activeSession = session
|
||||
|
||||
chatApi.sessionCommandHandlers[0]({
|
||||
event: 'session.command',
|
||||
session_id: 'session-1',
|
||||
command: 'goal',
|
||||
action: 'clear',
|
||||
message: 'Goal cleared.',
|
||||
terminal: true,
|
||||
})
|
||||
|
||||
expect(store.messages).toEqual([
|
||||
expect.objectContaining({ id: 'user-1', content: 'keep me' }),
|
||||
expect.objectContaining({
|
||||
role: 'command',
|
||||
content: 'Goal cleared.',
|
||||
commandAction: 'clear',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
|
||||
describe('chat store thinkingObservation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const store = useChatStore()
|
||||
expect(store.getThinkingObservation('any-id')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records startedAt when delta first introduces an opening tag', () => {
|
||||
const store = useChatStore()
|
||||
store.noteThinkingDelta('msg-1', '', '<think>hi')
|
||||
const ob = store.getThinkingObservation('msg-1')
|
||||
expect(ob).toBeDefined()
|
||||
expect(typeof ob!.startedAt).toBe('number')
|
||||
expect(ob!.endedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records endedAt when delta first introduces closing tag', () => {
|
||||
const store = useChatStore()
|
||||
store.noteThinkingDelta('msg-1', '', '<think>hi')
|
||||
store.noteThinkingDelta('msg-1', '<think>hi', '<think>hi</think>done')
|
||||
const ob = store.getThinkingObservation('msg-1')
|
||||
expect(ob!.startedAt).toBeDefined()
|
||||
expect(typeof ob!.endedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('is idempotent for subsequent openings/closings', () => {
|
||||
const store = useChatStore()
|
||||
store.noteThinkingDelta('m', '', '<think>a</think>')
|
||||
const first = store.getThinkingObservation('m')!
|
||||
const firstStarted = first.startedAt
|
||||
const firstEnded = first.endedAt
|
||||
store.noteThinkingDelta(
|
||||
'm',
|
||||
'<think>a</think>',
|
||||
'<think>a</think><think>b</think>',
|
||||
)
|
||||
const second = store.getThinkingObservation('m')!
|
||||
expect(second.startedAt).toBe(firstStarted)
|
||||
expect(second.endedAt).toBe(firstEnded)
|
||||
})
|
||||
|
||||
it('is ignored when delta is inside a code block', () => {
|
||||
const store = useChatStore()
|
||||
store.noteThinkingDelta('m', '', '```\n<think>fake</think>\n```')
|
||||
expect(store.getThinkingObservation('m')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears observations on clearThinkingObservationFor', () => {
|
||||
const store = useChatStore()
|
||||
store.noteThinkingDelta('m', '', '<think>hi</think>')
|
||||
expect(store.getThinkingObservation('m')).toBeDefined()
|
||||
store.clearThinkingObservationFor('any-session')
|
||||
expect(store.getThinkingObservation('m')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('noteReasoningStart records startedAt only once', () => {
|
||||
const store = useChatStore()
|
||||
store.noteReasoningStart('r1')
|
||||
const t1 = store.getThinkingObservation('r1')!.startedAt
|
||||
expect(typeof t1).toBe('number')
|
||||
store.noteReasoningStart('r1')
|
||||
expect(store.getThinkingObservation('r1')!.startedAt).toBe(t1)
|
||||
})
|
||||
|
||||
it('noteReasoningEnd requires prior start', () => {
|
||||
const store = useChatStore()
|
||||
store.noteReasoningEnd('r2')
|
||||
expect(store.getThinkingObservation('r2')).toBeUndefined()
|
||||
store.noteReasoningStart('r2')
|
||||
store.noteReasoningEnd('r2')
|
||||
expect(store.getThinkingObservation('r2')!.endedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('noteReasoningEnd is idempotent', () => {
|
||||
const store = useChatStore()
|
||||
store.noteReasoningStart('r3')
|
||||
store.noteReasoningEnd('r3')
|
||||
const end1 = store.getThinkingObservation('r3')!.endedAt
|
||||
store.noteReasoningEnd('r3')
|
||||
expect(store.getThinkingObservation('r3')!.endedAt).toBe(end1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { __resetCompletionSoundForTests, playCompletionSound, primeCompletionSound } from '@/utils/completion-sound'
|
||||
|
||||
function installMockAudioContext(initialState: AudioContextState = 'running') {
|
||||
const oscillator = {
|
||||
type: 'sine' as OscillatorType,
|
||||
frequency: {
|
||||
setValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
|
||||
const gain = {
|
||||
gain: {
|
||||
setValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const context = {
|
||||
state: initialState,
|
||||
currentTime: 10,
|
||||
destination: {},
|
||||
resume: vi.fn(async () => {
|
||||
context.state = 'running'
|
||||
}),
|
||||
createOscillator: vi.fn(() => oscillator),
|
||||
createGain: vi.fn(() => gain),
|
||||
}
|
||||
|
||||
const AudioContextMock = vi.fn(() => context)
|
||||
Object.defineProperty(window, 'AudioContext', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: AudioContextMock,
|
||||
})
|
||||
|
||||
return { AudioContextMock, context, oscillator, gain }
|
||||
}
|
||||
|
||||
describe('completion sound', () => {
|
||||
beforeEach(() => {
|
||||
__resetCompletionSoundForTests()
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(window, 'AudioContext', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false when Web Audio is unavailable', async () => {
|
||||
await expect(playCompletionSound()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('primes a suspended audio context from user interaction', () => {
|
||||
const { context } = installMockAudioContext('suspended')
|
||||
|
||||
primeCompletionSound()
|
||||
|
||||
expect(context.resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('plays a short tone through Web Audio', async () => {
|
||||
const { context, oscillator, gain } = installMockAudioContext('running')
|
||||
|
||||
await expect(playCompletionSound()).resolves.toBe(true)
|
||||
|
||||
expect(context.createOscillator).toHaveBeenCalledTimes(1)
|
||||
expect(context.createGain).toHaveBeenCalledTimes(1)
|
||||
expect(oscillator.connect).toHaveBeenCalledWith(gain)
|
||||
expect(gain.connect).toHaveBeenCalledWith(context.destination)
|
||||
expect(oscillator.start).toHaveBeenCalledWith(10)
|
||||
expect(oscillator.stop).toHaveBeenCalledWith(10.16)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
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()
|
||||
setActivePinia(createPinia())
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
startCopilotLogin: vi.fn(),
|
||||
pollCopilotLogin: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockMessage = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/copilot-auth', () => mockApi)
|
||||
vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn(async () => true) }))
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (k: string) => k }),
|
||||
}))
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
NButton: { template: '<button @click="$emit(\'click\')"><slot /></button>' },
|
||||
NSpin: { template: '<span class="spin" />' },
|
||||
useMessage: () => mockMessage,
|
||||
}))
|
||||
|
||||
import CopilotLoginModal from '@/components/hermes/models/CopilotLoginModal.vue'
|
||||
|
||||
function mountModal() {
|
||||
return mount(CopilotLoginModal)
|
||||
}
|
||||
|
||||
describe('CopilotLoginModal device-flow state machine', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockApi.startCopilotLogin.mockReset()
|
||||
mockApi.pollCopilotLogin.mockReset()
|
||||
mockMessage.success.mockReset()
|
||||
mockMessage.warning.mockReset()
|
||||
mockMessage.error.mockReset()
|
||||
})
|
||||
|
||||
it('启动后进入 waiting 并显示 user_code', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-1',
|
||||
user_code: 'ABCD-1234',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'pending', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('ABCD-1234')
|
||||
expect(mockApi.startCopilotLogin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('approved 时 emit success 且消息为 copilotApproved', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
user_code: 'WXYZ-9999',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'approved', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
|
||||
// 推动一次 poll timer
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockMessage.success).toHaveBeenCalledWith('models.copilotApproved')
|
||||
|
||||
// approved 后 1s 自动关闭
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('success')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expired 时进入 expired 状态并显示重试按钮', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-3',
|
||||
user_code: 'EXPI-RED!',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'expired', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('models.copilotExpired')
|
||||
expect(wrapper.emitted('success')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('startCopilotLogin 抛错时显示 error 且不 emit success', async () => {
|
||||
mockApi.startCopilotLogin.mockRejectedValue(new Error('boom'))
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockMessage.error).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('success')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('denied 时进入 error 状态', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-4',
|
||||
user_code: 'NOPE',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'denied', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('models.copilotDenied')
|
||||
expect(wrapper.emitted('success')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockFetchCurrentUser = vi.hoisted(() => vi.fn())
|
||||
const mockGetApiKey = vi.hoisted(() => vi.fn())
|
||||
const routeState = vi.hoisted(() => ({ fullPath: '/hermes/chat', name: 'hermes.chat' as any }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
fetchCurrentUser: mockFetchCurrentUser,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getApiKey: mockGetApiKey,
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
NModal: defineComponent({
|
||||
props: { show: Boolean, title: String },
|
||||
setup(props, { slots }) {
|
||||
return () => props.show
|
||||
? h('div', { class: 'modal' }, [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
h('div', { class: 'modal-actions' }, slots.action?.()),
|
||||
])
|
||||
: null
|
||||
},
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
emits: ['click'],
|
||||
setup(_props, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
|
||||
|
||||
describe('DefaultCredentialPrompt', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
routeState.fullPath = '/hermes/chat'
|
||||
routeState.name = 'hermes.chat'
|
||||
mockGetApiKey.mockReturnValue('jwt-token')
|
||||
})
|
||||
|
||||
it('prompts after login when the current user still has default credentials', async () => {
|
||||
mockFetchCurrentUser.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
role: 'super_admin',
|
||||
status: 'active',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
last_login_at: 1,
|
||||
requiresCredentialChange: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(DefaultCredentialPrompt)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(mockFetchCurrentUser).toHaveBeenCalledOnce()
|
||||
expect(wrapper.text()).toContain('login.defaultCredentialMessage')
|
||||
await wrapper.findAll('button')[1].trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'hermes.settings', query: { tab: 'account' } })
|
||||
})
|
||||
|
||||
it('does not prompt on the login route', async () => {
|
||||
routeState.fullPath = '/'
|
||||
routeState.name = 'login'
|
||||
|
||||
mount(DefaultCredentialPrompt)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockFetchCurrentUser).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getClipboardPathForEntry } from '@/utils/file-path'
|
||||
|
||||
const baseEntry = {
|
||||
name: 'app.log',
|
||||
path: 'logs/app.log',
|
||||
isDir: false,
|
||||
size: 12,
|
||||
modTime: '2026-05-20T00:00:00.000Z',
|
||||
}
|
||||
|
||||
describe('file path clipboard helpers', () => {
|
||||
it('prefers absolute path metadata when available', () => {
|
||||
expect(getClipboardPathForEntry({
|
||||
...baseEntry,
|
||||
absolutePath: '/home/agent/.hermes/logs/app.log',
|
||||
})).toBe('/home/agent/.hermes/logs/app.log')
|
||||
})
|
||||
|
||||
it('falls back to the relative operation path for older API responses', () => {
|
||||
expect(getClipboardPathForEntry(baseEntry)).toBe('logs/app.log')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMentionOptions } from '@/components/hermes/group-chat/mention-options'
|
||||
|
||||
describe('group chat mention options', () => {
|
||||
const agents = [
|
||||
{ name: 'Alice', profile: 'alice-profile' },
|
||||
{ name: 'Bob', profile: 'bob-profile' },
|
||||
{ name: 'all', profile: 'literal-all-agent' },
|
||||
]
|
||||
|
||||
it('offers @all before agent mentions when the mention query is empty', () => {
|
||||
expect(buildMentionOptions(agents, '').map(option => option.key)).toEqual([
|
||||
'special:all',
|
||||
'agent:Alice',
|
||||
'agent:Bob',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps @all reserved when filtering by all and hides a literal all agent', () => {
|
||||
expect(buildMentionOptions(agents, 'all')).toEqual([
|
||||
{
|
||||
key: 'special:all',
|
||||
type: 'all',
|
||||
name: 'all',
|
||||
label: '@all',
|
||||
description: 'All agents',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('filters normal agent mentions without showing @all for unrelated queries', () => {
|
||||
expect(buildMentionOptions(agents, 'bo').map(option => option.key)).toEqual(['agent:Bob'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { ChatMessage, RoomInfo } from '@/api/hermes/group-chat'
|
||||
|
||||
const groupChatApiMock = vi.hoisted(() => {
|
||||
const handlers = new Map<string, Function[]>()
|
||||
const socket: any = {
|
||||
connected: true,
|
||||
id: 'socket-1',
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
const existing = handlers.get(event) || []
|
||||
existing.push(cb)
|
||||
handlers.set(event, existing)
|
||||
return socket
|
||||
}),
|
||||
emit: vi.fn((event: string, _data?: unknown, ack?: Function) => {
|
||||
if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] })
|
||||
return socket
|
||||
}),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
return {
|
||||
handlers,
|
||||
socket,
|
||||
connectGroupChat: vi.fn(() => socket),
|
||||
disconnectGroupChat: vi.fn(),
|
||||
getSocket: vi.fn(() => socket),
|
||||
getStoredUserId: vi.fn(() => 'user-1'),
|
||||
getStoredUserName: vi.fn(() => 'tester'),
|
||||
createRoom: vi.fn(),
|
||||
listRooms: vi.fn(),
|
||||
getRoomDetail: vi.fn(),
|
||||
joinRoomByCode: vi.fn(),
|
||||
addAgent: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
removeAgent: vi.fn(),
|
||||
cloneRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
clearRoomContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/hermes/group-chat', () => groupChatApiMock)
|
||||
vi.mock('@/api/client', () => ({ getApiKey: vi.fn(() => 'test-token') }))
|
||||
vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) }))
|
||||
|
||||
function emitSocket(event: string, payload: unknown) {
|
||||
for (const cb of groupChatApiMock.handlers.get(event) || []) cb(payload)
|
||||
}
|
||||
|
||||
const room: RoomInfo = {
|
||||
id: 'room-1',
|
||||
name: 'Test Room',
|
||||
inviteCode: 'ROOM1',
|
||||
}
|
||||
|
||||
function assistantMessage(overrides: Partial<ChatMessage>): ChatMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
roomId: 'room-1',
|
||||
senderId: 'agent-1',
|
||||
senderName: 'bot',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
role: 'assistant',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function createJoinedStore(initialMessages: ChatMessage[] = []) {
|
||||
groupChatApiMock.getRoomDetail.mockResolvedValue({
|
||||
room,
|
||||
messages: initialMessages,
|
||||
agents: [],
|
||||
members: [],
|
||||
})
|
||||
const { useGroupChatStore } = await import('@/stores/hermes/group-chat')
|
||||
const store = useGroupChatStore()
|
||||
store.connect()
|
||||
await store.joinRoom('room-1')
|
||||
groupChatApiMock.getRoomDetail.mockClear()
|
||||
return store
|
||||
}
|
||||
|
||||
describe('group chat store streaming merge', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
setActivePinia(createPinia())
|
||||
groupChatApiMock.handlers.clear()
|
||||
for (const key of Object.keys(groupChatApiMock)) {
|
||||
const value = (groupChatApiMock as any)[key]
|
||||
if (value?.mockReset && key !== 'socket') value.mockReset()
|
||||
}
|
||||
groupChatApiMock.connectGroupChat.mockReturnValue(groupChatApiMock.socket)
|
||||
groupChatApiMock.getSocket.mockReturnValue(groupChatApiMock.socket)
|
||||
groupChatApiMock.getStoredUserId.mockReturnValue('user-1')
|
||||
groupChatApiMock.getStoredUserName.mockReturnValue('tester')
|
||||
groupChatApiMock.socket.on.mockClear()
|
||||
groupChatApiMock.socket.emit.mockClear()
|
||||
groupChatApiMock.socket.disconnect.mockClear()
|
||||
})
|
||||
|
||||
it('preserves streamed reasoning when the final message supplies content only', async () => {
|
||||
const store = await createJoinedStore()
|
||||
|
||||
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
|
||||
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' })
|
||||
emitSocket('message', assistantMessage({ id: 'msg-1', content: '收到', reasoning: null, reasoning_content: null }))
|
||||
|
||||
expect(store.messages).toHaveLength(1)
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: '收到',
|
||||
reasoning: 'thinking...',
|
||||
reasoning_content: 'thinking...',
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves streamed content when the final message payload is blank', async () => {
|
||||
const store = await createJoinedStore()
|
||||
|
||||
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
|
||||
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: 'final' })
|
||||
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' answer' })
|
||||
emitSocket('message', assistantMessage({ id: 'msg-1', content: '', reasoning: 'thinking...' }))
|
||||
|
||||
expect(store.messages).toHaveLength(1)
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: 'final answer',
|
||||
reasoning: 'thinking...',
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores late content deltas for a completed message', async () => {
|
||||
const store = await createJoinedStore()
|
||||
|
||||
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
|
||||
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
|
||||
|
||||
expect(store.messages).toHaveLength(1)
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: 'final answer',
|
||||
reasoning: 'thinking...',
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores late reasoning deltas for a completed message', async () => {
|
||||
const store = await createJoinedStore()
|
||||
|
||||
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
|
||||
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
|
||||
|
||||
expect(store.messages).toHaveLength(1)
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: 'final answer',
|
||||
reasoning: 'thinking...',
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a late empty stream start for a completed message', async () => {
|
||||
const store = await createJoinedStore()
|
||||
|
||||
emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' }))
|
||||
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 }))
|
||||
|
||||
expect(store.messages).toHaveLength(1)
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: 'final answer',
|
||||
reasoning: 'thinking...',
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a late stream start for a completed empty tool-call message', async () => {
|
||||
const store = await createJoinedStore()
|
||||
const toolCalls = [{ id: 'tool-1', type: 'function', function: { name: 'lookup', arguments: '{}' } }]
|
||||
|
||||
emitSocket('message', assistantMessage({ id: 'msg-1', content: '', tool_calls: toolCalls }))
|
||||
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 }))
|
||||
emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' })
|
||||
|
||||
expect(store.messages).toHaveLength(1)
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: '',
|
||||
tool_calls: toolCalls,
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('refetches room detail when a stream ends with reasoning but no final content', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = await createJoinedStore()
|
||||
groupChatApiMock.getRoomDetail.mockResolvedValue({
|
||||
room,
|
||||
agents: [],
|
||||
members: [],
|
||||
messages: [assistantMessage({ id: 'msg-1', content: 'final from db', reasoning: 'thinking...' })],
|
||||
})
|
||||
|
||||
emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' }))
|
||||
emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' })
|
||||
emitSocket('message_stream_end', { roomId: 'room-1', id: 'msg-1' })
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1')
|
||||
expect(store.messages[0]).toMatchObject({
|
||||
id: 'msg-1',
|
||||
content: 'final from db',
|
||||
reasoning: 'thinking...',
|
||||
isStreaming: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const highlightJsMock = vi.hoisted(() => ({
|
||||
getLanguage: vi.fn((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || '')),
|
||||
highlight: vi.fn((content: string, { language }: { language: string }) => ({
|
||||
value: `<span class="mock-${language}">${content}</span>`,
|
||||
})),
|
||||
registerLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('highlight.js', () => ({
|
||||
default: highlightJsMock,
|
||||
}))
|
||||
|
||||
import { normalizeHighlightLanguage, renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight'
|
||||
|
||||
describe('highlight helper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
highlightJsMock.getLanguage.mockImplementation((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || ''))
|
||||
highlightJsMock.highlight.mockImplementation((content: string, { language }: { language: string }) => ({
|
||||
value: `<span class="mock-${language}">${content}</span>`,
|
||||
}))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['vue', 'xml'],
|
||||
['yml', 'yaml'],
|
||||
['sh', 'bash'],
|
||||
['zsh', 'bash'],
|
||||
['shellscript', 'bash'],
|
||||
['shell', 'shell'],
|
||||
])('normalizes %s to %s', (input, expected) => {
|
||||
expect(normalizeHighlightLanguage(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses a delegated copy attribute instead of inline javascript', () => {
|
||||
const html = renderHighlightedCodeBlock('x', 'json', 'Copy')
|
||||
|
||||
expect(html).toContain('data-copy-code="true"')
|
||||
expect(html).not.toContain('onclick=')
|
||||
})
|
||||
|
||||
it('preserves shell-session highlighting instead of remapping shell fences to bash', () => {
|
||||
const html = renderHighlightedCodeBlock('$ ls\nfoo.txt\n', 'shell', 'Copy')
|
||||
|
||||
expect(highlightJsMock.highlight).toHaveBeenCalledWith('$ ls\nfoo.txt\n', {
|
||||
language: 'shell',
|
||||
ignoreIllegals: true,
|
||||
})
|
||||
expect(html).toContain('class="code-lang">shell</span>')
|
||||
})
|
||||
|
||||
it('skips highlighting for large known-language blocks when a render limit is set', () => {
|
||||
const html = renderHighlightedCodeBlock('x'.repeat(5000), 'vue', 'Copy', {
|
||||
maxHighlightLength: 2000,
|
||||
})
|
||||
|
||||
expect(highlightJsMock.highlight).not.toHaveBeenCalled()
|
||||
expect(html).toContain('class="code-lang">vue</span>')
|
||||
})
|
||||
|
||||
it('falls back to escaped plaintext for unsupported fence labels', () => {
|
||||
const html = renderHighlightedCodeBlock('<tag>', 'unknown', 'Copy')
|
||||
|
||||
expect(highlightJsMock.highlight).not.toHaveBeenCalled()
|
||||
expect(html).toContain('<tag>')
|
||||
expect(html).toContain('class="code-lang">unknown</span>')
|
||||
})
|
||||
|
||||
it('falls back to escaped plaintext when direct highlighting throws', () => {
|
||||
highlightJsMock.highlight.mockImplementationOnce(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
const html = renderHighlightedCodeBlock('<tag>', 'vue', 'Copy')
|
||||
|
||||
expect(html).toContain('<tag>')
|
||||
expect(html).toContain('class="code-lang">vue</span>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight'
|
||||
|
||||
describe('highlight safety', () => {
|
||||
it('escapes large unknown code content', () => {
|
||||
const html = renderHighlightedCodeBlock('<img src=x onerror=alert(1)>'.repeat(100), 'unknown', 'Copy')
|
||||
|
||||
expect(html).toContain('<img')
|
||||
expect(html).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('does not emit executable HTML for known-language code', () => {
|
||||
const html = renderHighlightedCodeBlock('<script>alert(1)</script>', 'xml', 'Copy')
|
||||
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).toContain('<')
|
||||
})
|
||||
|
||||
it('escapes the language label', () => {
|
||||
const html = renderHighlightedCodeBlock('x'.repeat(5000), '<script>alert(1)</script>', 'Copy')
|
||||
|
||||
expect(html).toContain('<script>alert(1)</script>')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('sanitizes the language class', () => {
|
||||
const html = renderHighlightedCodeBlock('x'.repeat(5000), 'foo bar"><img', 'Copy')
|
||||
|
||||
expect(html).toContain('language-foo-bar---img')
|
||||
})
|
||||
|
||||
it('escapes the copy label', () => {
|
||||
const html = renderHighlightedCodeBlock('x', 'json', 'Copy <now>')
|
||||
|
||||
expect(html).toContain('Copy <now>')
|
||||
expect(html).not.toContain('Copy <now>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, beforeAll } from 'vitest'
|
||||
import { readdirSync, readFileSync } from 'fs'
|
||||
import { join, relative } from 'path'
|
||||
|
||||
import { changelog } from '@/data/changelog'
|
||||
import { messages, supportedLocales } from '@/i18n/messages'
|
||||
import en from '@/i18n/locales/en'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const SOURCE_ROOT = join(process.cwd(), 'packages/client/src')
|
||||
|
||||
const allMessages: Record<string, Record<string, unknown>> = { en }
|
||||
|
||||
function walkFiles(dir: string, files: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(path, files)
|
||||
} else if (/\.(ts|vue)$/.test(entry.name) && !path.replace(/\\/g, '/').includes('/i18n/locales/')) {
|
||||
files.push(path)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function collectLiteralTranslationKeys(): string[] {
|
||||
const keys = new Set<string>()
|
||||
const translationCall = /(?:\b|\$)t\(\s*['"]([^'"]+)['"]/g
|
||||
|
||||
for (const file of walkFiles(SOURCE_ROOT)) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
for (const match of source.matchAll(translationCall)) {
|
||||
keys.add(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of changelog) {
|
||||
for (const change of entry.changes) {
|
||||
keys.add(change)
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys].sort()
|
||||
}
|
||||
|
||||
function getPath(messages: Record<string, unknown>, key: string): unknown {
|
||||
let current: unknown = messages
|
||||
for (const part of key.split('.')) {
|
||||
if (!current || typeof current !== 'object' || !(part in current)) return undefined
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function hasPath(messages: Record<string, unknown>, key: string): boolean {
|
||||
return typeof getPath(messages, key) !== 'undefined'
|
||||
}
|
||||
|
||||
const SKILLS_USAGE_LOCALIZED_KEYS = [
|
||||
'sidebar.skillsUsage',
|
||||
'skillsUsage.title',
|
||||
'skillsUsage.subtitle',
|
||||
'skillsUsage.refresh',
|
||||
'skillsUsage.periodSelector',
|
||||
'skillsUsage.periodLabel',
|
||||
'skillsUsage.summary',
|
||||
'skillsUsage.totalActions',
|
||||
'skillsUsage.loads',
|
||||
'skillsUsage.edits',
|
||||
'skillsUsage.distinctSkills',
|
||||
'skillsUsage.topSkills',
|
||||
'skillsUsage.dailyTrend',
|
||||
'skillsUsage.periodSummary',
|
||||
'skillsUsage.skill',
|
||||
'skillsUsage.share',
|
||||
'skillsUsage.lastUsed',
|
||||
'skillsUsage.noData',
|
||||
'skillsUsage.loadFailed',
|
||||
'skillsUsage.otherSkills',
|
||||
]
|
||||
|
||||
const SKILLS_USAGE_COMPACT_LABEL_LIMITS: Record<string, number> = {
|
||||
'skillsUsage.totalActions': 12,
|
||||
'skillsUsage.loads': 10,
|
||||
'skillsUsage.edits': 10,
|
||||
'skillsUsage.distinctSkills': 12,
|
||||
'skillsUsage.topSkills': 16,
|
||||
'skillsUsage.dailyTrend': 16,
|
||||
'skillsUsage.skill': 10,
|
||||
'skillsUsage.share': 10,
|
||||
'skillsUsage.lastUsed': 12,
|
||||
'skillsUsage.otherSkills': 16,
|
||||
}
|
||||
|
||||
function labelLength(value: unknown): number {
|
||||
return typeof value === 'string' ? Array.from(value.replace(/\{[^}]+\}/g, '')).length : Infinity
|
||||
}
|
||||
|
||||
describe('i18n locale coverage', () => {
|
||||
const ALLOWED_MISSING_KEYS = new Set([
|
||||
'changelog.new_0_5_4_7',
|
||||
'chat.sessionNotFound',
|
||||
])
|
||||
|
||||
beforeAll(() => {
|
||||
for (const l of supportedLocales) {
|
||||
if (l !== 'en' && messages[l]) {
|
||||
allMessages[l] = messages[l]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('defines every statically referenced translation key in the English source locale', () => {
|
||||
const missing = collectLiteralTranslationKeys()
|
||||
.filter((key) => !hasPath(en, key))
|
||||
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('defines every statically referenced translation key in effective runtime messages', () => {
|
||||
const requiredKeys = collectLiteralTranslationKeys()
|
||||
const missing = Object.entries(allMessages).flatMap(([locale, localeMessages]) =>
|
||||
requiredKeys
|
||||
.filter((key) => !hasPath(localeMessages, key))
|
||||
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
||||
.map((key) => `${locale}: ${key}`),
|
||||
)
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('localizes Skills Usage page copy in every non-English locale instead of falling back to English', () => {
|
||||
const englishMessages = messages.en
|
||||
const untranslated = Object.entries(messages).flatMap(([locale, localeMessages]) => {
|
||||
if (locale === 'en') return []
|
||||
|
||||
return SKILLS_USAGE_LOCALIZED_KEYS.flatMap((key) => {
|
||||
const localeValue = getPath(localeMessages, key)
|
||||
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
|
||||
return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : []
|
||||
})
|
||||
})
|
||||
|
||||
expect(untranslated).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
it('keeps Skills Usage summary and table labels compact across locales', () => {
|
||||
const oversized = Object.entries(messages).flatMap(([locale, localeMessages]) =>
|
||||
Object.entries(SKILLS_USAGE_COMPACT_LABEL_LIMITS).flatMap(([key, maxLength]) => {
|
||||
const localeValue = getPath(localeMessages, key)
|
||||
return labelLength(localeValue) > maxLength
|
||||
? [`${locale}: ${key} (${labelLength(localeValue)} > ${maxLength})`]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
|
||||
expect(oversized).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the coverage scanner rooted in client source files', () => {
|
||||
expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const mockMessage = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockSettingsStore = vi.hoisted(() => ({
|
||||
platforms: {} as Record<string, any>,
|
||||
fetchSettings: vi.fn(async () => {
|
||||
mockSettingsStore.platforms = {
|
||||
telegram: { token: 'telegram-token' },
|
||||
discord: { token: 'discord-token' },
|
||||
slack: { token: 'slack-token' },
|
||||
whatsapp: { enabled: true },
|
||||
matrix: { token: 'matrix-token' },
|
||||
weixin: { token: 'weixin-token' },
|
||||
wecom: { extra: { bot_id: 'wecom-bot' } },
|
||||
feishu: { extra: { app_id: 'feishu-app' } },
|
||||
dingtalk: { extra: { client_id: 'dingtalk-client' } },
|
||||
qqbot: { extra: { app_id: 'qq-app', client_secret: 'qq-secret' } },
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockJobsStore = vi.hoisted(() => ({
|
||||
createJob: vi.fn(),
|
||||
updateJob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/settings', () => ({
|
||||
useSettingsStore: () => mockSettingsStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/jobs', () => ({
|
||||
useJobsStore: () => mockJobsStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/jobs', async () => {
|
||||
const actual = await vi.importActual<any>('@/api/hermes/jobs')
|
||||
return {
|
||||
...actual,
|
||||
getJob: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: defineComponent({
|
||||
template: '<div class="n-modal-stub"><slot /><slot name="footer" /></div>',
|
||||
}),
|
||||
NForm: defineComponent({ template: '<form><slot /></form>' }),
|
||||
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
|
||||
NInput: defineComponent({
|
||||
props: { value: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NInputNumber: defineComponent({
|
||||
props: { value: { required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-number-stub" :value="value" type="number" @input="$emit(\'update:value\', Number($event.target.value))" />',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
props: { value: { required: false }, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub"><option v-for="option in options" :key="option.value" :value="option.value" :disabled="option.disabled">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
useMessage: () => mockMessage,
|
||||
}))
|
||||
|
||||
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
|
||||
|
||||
describe('JobFormModal deliver targets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingsStore.platforms = {}
|
||||
})
|
||||
|
||||
it('loads platform settings when the store has not been hydrated', async () => {
|
||||
mount(JobFormModal, {
|
||||
props: { jobId: null },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSettingsStore.fetchSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows every supported platform channel in deliver target options', async () => {
|
||||
mockSettingsStore.platforms = {
|
||||
telegram: { token: 'telegram-token' },
|
||||
whatsapp: { enabled: false },
|
||||
qqbot: { extra: { app_id: 'qq-app', client_secret: 'qq-secret' } },
|
||||
}
|
||||
const wrapper = mount(JobFormModal, {
|
||||
props: { jobId: null },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSettingsStore.fetchSettings).not.toHaveBeenCalled()
|
||||
const labels = wrapper.findAll('.n-select-stub')[1].text()
|
||||
expect(labels).toContain('Telegram')
|
||||
expect(labels).toContain('Discord')
|
||||
expect(labels).toContain('Slack')
|
||||
expect(labels).toContain('WhatsApp')
|
||||
expect(labels).toContain('Matrix')
|
||||
expect(labels).toContain('WeChat')
|
||||
expect(labels).toContain('WeCom')
|
||||
expect(labels).toContain('Feishu')
|
||||
expect(labels).toContain('DingTalk')
|
||||
expect(labels).toContain('QQBot')
|
||||
|
||||
const options = wrapper.findAll('.n-select-stub')[1].findAll('option')
|
||||
const optionByValue = Object.fromEntries(options.map(option => [option.attributes('value'), option]))
|
||||
expect(optionByValue.telegram.attributes('disabled')).toBeUndefined()
|
||||
expect(optionByValue.qqbot.attributes('disabled')).toBeUndefined()
|
||||
expect(optionByValue.discord.attributes('disabled')).toBe('')
|
||||
expect(optionByValue.whatsapp.attributes('disabled')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { name: 'hermes.jobs' } },
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
buildJobUpdateRequest,
|
||||
scheduleToDisplayText,
|
||||
scheduleToEditableInput,
|
||||
updateJob,
|
||||
} from '../../packages/client/src/api/hermes/jobs'
|
||||
import { listCronRuns } from '../../packages/client/src/api/hermes/cron-history'
|
||||
import type { Job } from '../../packages/client/src/api/hermes/jobs'
|
||||
|
||||
function makeJob(overrides: Partial<Job> = {}): Job {
|
||||
return {
|
||||
job_id: 'job-1',
|
||||
id: 'job-1',
|
||||
name: 'artifact cleanup',
|
||||
prompt: 'short prompt',
|
||||
skills: [],
|
||||
skill: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
base_url: null,
|
||||
script: null,
|
||||
schedule: { kind: 'interval', minutes: 7200, display: 'every 7200m' },
|
||||
schedule_display: 'every 7200m',
|
||||
repeat: { times: null, completed: 0 },
|
||||
enabled: true,
|
||||
state: 'scheduled',
|
||||
paused_at: null,
|
||||
paused_reason: null,
|
||||
created_at: '2026-04-30T00:00:00Z',
|
||||
next_run_at: null,
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
last_error: null,
|
||||
deliver: 'origin',
|
||||
origin: null,
|
||||
last_delivery_error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Hermes jobs edit payloads', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses display text for interval schedules without manufacturing expr', () => {
|
||||
const schedule = { kind: 'interval' as const, minutes: 7200, display: 'every 7200m' }
|
||||
|
||||
expect(scheduleToEditableInput(schedule, '')).toBe('every 7200m')
|
||||
expect(scheduleToDisplayText(schedule, '—')).toBe('every 7200m')
|
||||
})
|
||||
|
||||
it('keeps cron expr as the editable schedule string', () => {
|
||||
const schedule = { kind: 'cron' as const, expr: '0 9 * * 1', display: '0 9 * * 1' }
|
||||
|
||||
expect(scheduleToEditableInput(schedule, '')).toBe('0 9 * * 1')
|
||||
expect(scheduleToDisplayText(schedule, '—')).toBe('0 9 * * 1')
|
||||
})
|
||||
|
||||
it('omits unchanged long prompts from name-only updates', () => {
|
||||
const prompt = 'x'.repeat(9484)
|
||||
const original = makeJob({ prompt })
|
||||
|
||||
const payload = buildJobUpdateRequest(original, {
|
||||
name: 'artifact cleanup renamed',
|
||||
schedule: 'every 7200m',
|
||||
prompt,
|
||||
deliver: 'origin',
|
||||
repeat_times: null,
|
||||
})
|
||||
|
||||
expect(payload).toEqual({ name: 'artifact cleanup renamed' })
|
||||
expect(payload).not.toHaveProperty('prompt')
|
||||
expect(payload).not.toHaveProperty('schedule')
|
||||
})
|
||||
|
||||
it('sends changed interval schedules as raw strings', () => {
|
||||
const original = makeJob()
|
||||
|
||||
const payload = buildJobUpdateRequest(original, {
|
||||
name: original.name,
|
||||
schedule: 'every 14400m',
|
||||
prompt: original.prompt,
|
||||
deliver: 'origin',
|
||||
repeat_times: null,
|
||||
})
|
||||
|
||||
expect(payload).toEqual({ schedule: 'every 14400m' })
|
||||
})
|
||||
|
||||
it('does not send a PATCH body with structured schedule objects', async () => {
|
||||
const returnedJob = makeJob({ name: 'artifact cleanup renamed' })
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ job: returnedJob }),
|
||||
})
|
||||
|
||||
await updateJob('job-1', { name: 'artifact cleanup renamed', schedule: 'every 14400m' })
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(JSON.parse(options.body as string)).toEqual({
|
||||
name: 'artifact cleanup renamed',
|
||||
schedule: 'every 14400m',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends active profile header when loading job run history', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ runs: [] }),
|
||||
})
|
||||
|
||||
await listCronRuns('job-1')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const [url, options] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('/api/cron-history?jobId=job-1')
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('research')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockRequest = vi.hoisted(() => vi.fn())
|
||||
const mockGetApiKey = vi.hoisted(() => vi.fn(() => ''))
|
||||
const mockGetBaseUrlValue = vi.hoisted(() => vi.fn(() => ''))
|
||||
|
||||
vi.mock('../../packages/client/src/api/client', () => ({
|
||||
request: mockRequest,
|
||||
getApiKey: mockGetApiKey,
|
||||
getBaseUrlValue: mockGetBaseUrlValue,
|
||||
}))
|
||||
|
||||
import {
|
||||
listBoards,
|
||||
createBoard,
|
||||
archiveBoard,
|
||||
getCapabilities,
|
||||
listTasks,
|
||||
getTask,
|
||||
createTask,
|
||||
completeTasks,
|
||||
blockTask,
|
||||
unblockTasks,
|
||||
assignTask,
|
||||
addComment,
|
||||
linkTasks,
|
||||
unlinkTasks,
|
||||
bulkUpdateTasks,
|
||||
getTaskLog,
|
||||
getDiagnostics,
|
||||
reclaimTask,
|
||||
reassignTask,
|
||||
specifyTask,
|
||||
dispatch,
|
||||
getStats,
|
||||
getAssignees,
|
||||
buildKanbanEventsWebSocketUrl,
|
||||
} from '../../packages/client/src/api/hermes/kanban'
|
||||
|
||||
describe('Kanban API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockGetApiKey.mockReturnValue('')
|
||||
mockGetBaseUrlValue.mockReturnValue('')
|
||||
})
|
||||
|
||||
it('builds board-scoped kanban event websocket URLs with auth token', () => {
|
||||
mockGetBaseUrlValue.mockReturnValue('https://wui.example.test')
|
||||
mockGetApiKey.mockReturnValue('token value')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
|
||||
expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value&profile=research')
|
||||
expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value&profile=research')
|
||||
})
|
||||
|
||||
it('serializes board, list filters, and archived inclusion into query params', async () => {
|
||||
mockRequest.mockResolvedValue({ tasks: [{ id: 'task-1' }] })
|
||||
|
||||
const result = await listTasks({ board: 'default', status: 'blocked', assignee: 'alice', tenant: 'ops', includeArchived: true })
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban?board=default&status=blocked&assignee=alice&tenant=ops&includeArchived=true')
|
||||
expect(result).toEqual([{ id: 'task-1' }])
|
||||
})
|
||||
|
||||
it('keeps default board explicit when no board is supplied', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ tasks: [] })
|
||||
.mockResolvedValueOnce({ stats: { total: 0, by_status: {}, by_assignee: {} } })
|
||||
.mockResolvedValueOnce({ assignees: [] })
|
||||
.mockResolvedValueOnce({ task: { id: 'task-1' }, comments: [], events: [], runs: [] })
|
||||
|
||||
await listTasks()
|
||||
await getStats()
|
||||
await getAssignees()
|
||||
await getTask('task-1')
|
||||
|
||||
expect(mockRequest.mock.calls.map(call => call[0])).toEqual([
|
||||
'/api/hermes/kanban?board=default',
|
||||
'/api/hermes/kanban/stats?board=default',
|
||||
'/api/hermes/kanban/assignees?board=default',
|
||||
'/api/hermes/kanban/task-1?board=default',
|
||||
])
|
||||
})
|
||||
|
||||
it('posts create and action payloads with explicit board in the URL', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ task: { id: 'task-1' } })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
|
||||
expect(await createTask({ title: 'Ship', assignee: 'alice', priority: 3 }, { board: 'project-a' })).toEqual({ id: 'task-1' })
|
||||
await completeTasks(['task-1'], 'done', { board: 'project-a' })
|
||||
await blockTask('task-1', 'waiting', { board: 'project-a' })
|
||||
await unblockTasks(['task-1'], { board: 'project-a' })
|
||||
await assignTask('task-1', 'bob', { board: 'project-a' })
|
||||
|
||||
expect(mockRequest.mock.calls).toEqual([
|
||||
['/api/hermes/kanban?board=project-a', { method: 'POST', body: JSON.stringify({ title: 'Ship', assignee: 'alice', priority: 3 }) }],
|
||||
['/api/hermes/kanban/complete?board=project-a', { method: 'POST', body: JSON.stringify({ task_ids: ['task-1'], summary: 'done' }) }],
|
||||
['/api/hermes/kanban/task-1/block?board=project-a', { method: 'POST', body: JSON.stringify({ reason: 'waiting' }) }],
|
||||
['/api/hermes/kanban/unblock?board=project-a', { method: 'POST', body: JSON.stringify({ task_ids: ['task-1'] }) }],
|
||||
['/api/hermes/kanban/task-1/assign?board=project-a', { method: 'POST', body: JSON.stringify({ profile: 'bob' }) }],
|
||||
])
|
||||
})
|
||||
|
||||
it('lists and manages boards through explicit board endpoints', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ boards: [{ slug: 'default' }] })
|
||||
.mockResolvedValueOnce({ board: { slug: 'project-a' } })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ capabilities: { source: 'hermes-cli', supports: { boardsList: true }, missing: [] } })
|
||||
.mockResolvedValueOnce({ stats: { total: 3, by_status: {}, by_assignee: {} } })
|
||||
.mockResolvedValueOnce({ assignees: [{ name: 'alice', on_disk: true, counts: { todo: 1 } }] })
|
||||
|
||||
await expect(listBoards({ includeArchived: true })).resolves.toEqual([{ slug: 'default' }])
|
||||
await expect(createBoard({ slug: 'project-a', name: 'Project A' })).resolves.toEqual({ slug: 'project-a' })
|
||||
await expect(archiveBoard('project-a')).resolves.toEqual({ ok: true })
|
||||
await expect(getCapabilities()).resolves.toEqual({ source: 'hermes-cli', supports: { boardsList: true }, missing: [] })
|
||||
await expect(getStats({ board: 'project-a' })).resolves.toEqual({ total: 3, by_status: {}, by_assignee: {} })
|
||||
await expect(getAssignees({ board: 'project-a' })).resolves.toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
|
||||
|
||||
expect(mockRequest.mock.calls).toEqual([
|
||||
['/api/hermes/kanban/boards?includeArchived=true'],
|
||||
['/api/hermes/kanban/boards', { method: 'POST', body: JSON.stringify({ slug: 'project-a', name: 'Project A' }) }],
|
||||
['/api/hermes/kanban/boards/project-a', { method: 'DELETE' }],
|
||||
['/api/hermes/kanban/capabilities'],
|
||||
['/api/hermes/kanban/stats?board=project-a'],
|
||||
['/api/hermes/kanban/assignees?board=project-a'],
|
||||
])
|
||||
})
|
||||
|
||||
it('calls parity-gap APIs with explicit board query params', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ ok: true, output: 'commented' })
|
||||
.mockResolvedValueOnce({ ok: true, output: 'linked' })
|
||||
.mockResolvedValueOnce({ ok: true, output: 'unlinked' })
|
||||
.mockResolvedValueOnce({ results: [{ id: 'task-1', ok: true }] })
|
||||
.mockResolvedValueOnce({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
|
||||
.mockResolvedValueOnce({ diagnostics: [{ task_id: 'task-1' }] })
|
||||
.mockResolvedValueOnce({ ok: true, output: 'reclaimed' })
|
||||
.mockResolvedValueOnce({ ok: true, output: 'reassigned' })
|
||||
.mockResolvedValueOnce({ results: [{ task_id: 'task-1' }] })
|
||||
.mockResolvedValueOnce({ result: { spawned: 1 } })
|
||||
|
||||
await addComment('task-1', { body: 'needs review', author: 'han' }, { board: 'default' })
|
||||
await linkTasks({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
|
||||
await unlinkTasks({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
|
||||
await expect(bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] })
|
||||
await expect(getTaskLog('task-1', { board: 'default', tail: 4000 })).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
|
||||
await expect(getDiagnostics({ board: 'default', task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }])
|
||||
await reclaimTask('task-1', { board: 'project-a', reason: 'stale' })
|
||||
await reassignTask('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
|
||||
await expect(specifyTask('task-1', { board: 'default', author: 'han' })).resolves.toEqual([{ task_id: 'task-1' }])
|
||||
await expect(dispatch({ board: 'default', dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
|
||||
|
||||
expect(mockRequest.mock.calls).toEqual([
|
||||
['/api/hermes/kanban/task-1/comments?board=default', { method: 'POST', body: JSON.stringify({ body: 'needs review', author: 'han' }) }],
|
||||
['/api/hermes/kanban/links?board=project-a', { method: 'POST', body: JSON.stringify({ parent_id: 'task-1', child_id: 'task-2' }) }],
|
||||
['/api/hermes/kanban/links?board=project-a&parent_id=task-1&child_id=task-2', { method: 'DELETE' }],
|
||||
['/api/hermes/kanban/tasks/bulk?board=project-a', { method: 'POST', body: JSON.stringify({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }) }],
|
||||
['/api/hermes/kanban/task-1/log?board=default&tail=4000'],
|
||||
['/api/hermes/kanban/diagnostics?board=default&task=task-1&severity=warning'],
|
||||
['/api/hermes/kanban/task-1/reclaim?board=project-a', { method: 'POST', body: JSON.stringify({ reason: 'stale' }) }],
|
||||
['/api/hermes/kanban/task-1/reassign?board=project-a', { method: 'POST', body: JSON.stringify({ profile: 'bob', reclaim: true, reason: 'handoff' }) }],
|
||||
['/api/hermes/kanban/task-1/specify?board=default', { method: 'POST', body: JSON.stringify({ author: 'han' }) }],
|
||||
['/api/hermes/kanban/dispatch?board=default', { method: 'POST', body: JSON.stringify({ dryRun: true, max: 2, failureLimit: 3 }) }],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const mockCreateTask = vi.hoisted(() => vi.fn())
|
||||
const mockMessage = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/kanban', () => ({
|
||||
useKanbanStore: () => ({
|
||||
assignees: [{ name: 'alice', counts: { todo: 1 } }],
|
||||
createTask: mockCreateTask,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: defineComponent({
|
||||
emits: ['close'],
|
||||
template: '<div class="n-modal-stub"><slot /><slot name="action" /></div>',
|
||||
}),
|
||||
NForm: defineComponent({ template: '<form><slot /></form>' }),
|
||||
NFormItem: defineComponent({ template: '<div><slot /></div>' }),
|
||||
NInput: defineComponent({
|
||||
props: { value: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
props: { value: { required: false }, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value === \'\' ? null : (/^\\d+$/.test($event.target.value) ? Number($event.target.value) : $event.target.value))"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click.prevent="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
useMessage: () => mockMessage,
|
||||
}))
|
||||
|
||||
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
|
||||
|
||||
describe('KanbanCreateForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates required title before submit', async () => {
|
||||
const wrapper = mount(KanbanCreateForm)
|
||||
|
||||
await wrapper.findAll('.n-button-stub')[1].trigger('click')
|
||||
|
||||
expect(mockMessage.warning).toHaveBeenCalledWith('kanban.form.titleRequired')
|
||||
expect(mockCreateTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits trimmed values and emits created/close', async () => {
|
||||
mockCreateTask.mockResolvedValue({ id: 'task-1' })
|
||||
const wrapper = mount(KanbanCreateForm)
|
||||
|
||||
const inputs = wrapper.findAll('.n-input-stub')
|
||||
await inputs[0].setValue(' Ship kanban ')
|
||||
await inputs[1].setValue(' write tests ')
|
||||
const selects = wrapper.findAll('.n-select-stub')
|
||||
await selects[0].setValue('alice')
|
||||
await selects[1].setValue('3')
|
||||
await wrapper.findAll('.n-button-stub')[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreateTask).toHaveBeenCalledWith({
|
||||
title: 'Ship kanban',
|
||||
body: 'write tests',
|
||||
assignee: 'alice',
|
||||
priority: 3,
|
||||
})
|
||||
expect(mockMessage.success).toHaveBeenCalledWith('kanban.message.taskCreated')
|
||||
expect(wrapper.emitted('created')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses compact profile names for assignee options', () => {
|
||||
const wrapper = mount(KanbanCreateForm)
|
||||
|
||||
expect(wrapper.text()).toContain('alice')
|
||||
expect(wrapper.text()).not.toContain('default')
|
||||
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockKanbanApi = vi.hoisted(() => ({
|
||||
listBoards: vi.fn(),
|
||||
createBoard: vi.fn(),
|
||||
archiveBoard: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getAssignees: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
completeTasks: vi.fn(),
|
||||
blockTask: vi.fn(),
|
||||
unblockTasks: vi.fn(),
|
||||
assignTask: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
linkTasks: vi.fn(),
|
||||
unlinkTasks: vi.fn(),
|
||||
bulkUpdateTasks: vi.fn(),
|
||||
getTaskLog: vi.fn(),
|
||||
getDiagnostics: vi.fn(),
|
||||
reclaimTask: vi.fn(),
|
||||
reassignTask: vi.fn(),
|
||||
specifyTask: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
openKanbanEventStream: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/kanban', () => mockKanbanApi)
|
||||
|
||||
import { KANBAN_SELECTED_BOARD_STORAGE_KEY, normalizeBoardSlug, useKanbanStore } from '@/stores/hermes/kanban'
|
||||
|
||||
describe('Kanban store', () => {
|
||||
it('normalizes board slugs with canonical underscore, uppercase, and length rules', () => {
|
||||
const sixtyFour = 'a'.repeat(64)
|
||||
|
||||
expect(normalizeBoardSlug(' Team_Alpha ')).toBe('team_alpha')
|
||||
expect(normalizeBoardSlug(sixtyFour)).toBe(sixtyFour)
|
||||
expect(normalizeBoardSlug('default')).toBe('default')
|
||||
expect(normalizeBoardSlug('bad/slug')).toBe('default')
|
||||
expect(normalizeBoardSlug('bad.slug')).toBe('default')
|
||||
expect(normalizeBoardSlug('bad slug')).toBe('default')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockKanbanApi.listBoards.mockResolvedValue([
|
||||
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
|
||||
{ slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 1 }, total: 1 },
|
||||
])
|
||||
mockKanbanApi.getCapabilities.mockResolvedValue({ source: 'hermes-cli', supports: { boardsList: true }, missing: [] })
|
||||
mockKanbanApi.openKanbanEventStream.mockReturnValue({ close: vi.fn(), onmessage: null, onclose: null, onerror: null })
|
||||
})
|
||||
|
||||
it('persists selected board, including default, and falls back to default for missing boards', async () => {
|
||||
const store = useKanbanStore()
|
||||
await store.fetchBoards()
|
||||
|
||||
expect(store.setSelectedBoard('project-a')).toBe('project-a')
|
||||
expect(window.localStorage.getItem(KANBAN_SELECTED_BOARD_STORAGE_KEY)).toBe('project-a')
|
||||
|
||||
expect(store.setSelectedBoard('default')).toBe('default')
|
||||
expect(window.localStorage.getItem(KANBAN_SELECTED_BOARD_STORAGE_KEY)).toBe('default')
|
||||
|
||||
const recovered = store.recoverSelectedBoard('missing-board')
|
||||
expect(recovered).toEqual({ board: 'default', recovered: true })
|
||||
expect(store.selectedBoard).toBe('default')
|
||||
expect(store.boardWarning).toContain('missing-board')
|
||||
})
|
||||
|
||||
it('fetchTasks uses active filters and selected board while updating loading', async () => {
|
||||
mockKanbanApi.listTasks.mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve([{ id: 'task-1', status: 'todo' }]), 0))
|
||||
)
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
store.setFilter('status', 'blocked')
|
||||
store.setFilter('assignee', 'alice')
|
||||
const promise = store.fetchTasks()
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
await promise
|
||||
|
||||
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'blocked', assignee: 'alice', includeArchived: true })
|
||||
expect(store.tasks).toEqual([{ id: 'task-1', status: 'todo' }])
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('create and status actions pass selected board, update local task state, and refresh board counts', async () => {
|
||||
mockKanbanApi.createTask.mockResolvedValue({ id: 'task-2', status: 'todo', assignee: null })
|
||||
mockKanbanApi.completeTasks.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.blockTask.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.unblockTasks.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.assignTask.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.getStats.mockResolvedValue({ total: 2, by_status: { done: 1 }, by_assignee: {} })
|
||||
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: { ready: 1 } }])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
store.tasks = [{ id: 'task-1', status: 'running', assignee: null }] as any
|
||||
|
||||
await store.createTask({ title: 'Ship' })
|
||||
await store.completeTasks(['task-1'], 'done')
|
||||
await store.blockTask('task-2', 'waiting')
|
||||
await store.unblockTasks(['task-2'])
|
||||
await store.assignTask('task-2', 'bob')
|
||||
|
||||
expect(mockKanbanApi.createTask).toHaveBeenCalledWith({ title: 'Ship' }, { board: 'project-a' })
|
||||
expect(mockKanbanApi.completeTasks).toHaveBeenCalledWith(['task-1'], 'done', { board: 'project-a' })
|
||||
expect(mockKanbanApi.blockTask).toHaveBeenCalledWith('task-2', 'waiting', { board: 'project-a' })
|
||||
expect(mockKanbanApi.unblockTasks).toHaveBeenCalledWith(['task-2'], { board: 'project-a' })
|
||||
expect(mockKanbanApi.assignTask).toHaveBeenCalledWith('task-2', 'bob', { board: 'project-a' })
|
||||
expect(mockKanbanApi.listBoards).toHaveBeenCalledTimes(4)
|
||||
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
|
||||
expect(store.tasks[0]).toMatchObject({ id: 'task-2', status: 'ready', assignee: 'bob' })
|
||||
expect(store.tasks[1]).toMatchObject({ id: 'task-1', status: 'done' })
|
||||
})
|
||||
|
||||
it('uses capability metadata before calling parity APIs', async () => {
|
||||
mockKanbanApi.getCapabilities.mockResolvedValue({
|
||||
source: 'hermes-cli',
|
||||
supports: { commentsWrite: true, dispatch: false },
|
||||
missing: ['dispatch'],
|
||||
})
|
||||
mockKanbanApi.addComment.mockResolvedValue({ ok: true })
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
await store.fetchCapabilities()
|
||||
|
||||
expect(store.isCapabilitySupported('commentsWrite')).toBe(true)
|
||||
expect(store.isCapabilitySupported('dispatch')).toBe(false)
|
||||
await store.addComment('task-1', 'needs review', 'han')
|
||||
await expect(store.dispatch({ dryRun: true })).rejects.toThrow('dispatch')
|
||||
|
||||
expect(mockKanbanApi.addComment).toHaveBeenCalledWith('task-1', { body: 'needs review', author: 'han' }, { board: 'project-a' })
|
||||
expect(mockKanbanApi.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes selected board to link and partial bulk parity actions', async () => {
|
||||
mockKanbanApi.getCapabilities.mockResolvedValue({
|
||||
source: 'hermes-cli',
|
||||
supports: { links: true, bulk: false },
|
||||
missing: ['bulk'],
|
||||
capabilities: [
|
||||
{ key: 'links', status: 'supported', requiresBoard: true },
|
||||
{ key: 'bulk', status: 'partial', requiresBoard: true },
|
||||
],
|
||||
})
|
||||
mockKanbanApi.linkTasks.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.unlinkTasks.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.bulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] })
|
||||
mockKanbanApi.listTasks.mockResolvedValue([])
|
||||
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
|
||||
mockKanbanApi.getAssignees.mockResolvedValue([])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
await store.fetchCapabilities()
|
||||
|
||||
await store.linkTasks('task-1', 'task-2')
|
||||
await store.unlinkTasks('task-1', 'task-2')
|
||||
await expect(store.bulkUpdateTasks({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' })).resolves.toEqual({ results: [{ id: 'task-1', ok: true }] })
|
||||
|
||||
expect(mockKanbanApi.linkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
|
||||
expect(mockKanbanApi.unlinkTasks).toHaveBeenCalledWith({ parent_id: 'task-1', child_id: 'task-2' }, { board: 'project-a' })
|
||||
expect(mockKanbanApi.bulkUpdateTasks).toHaveBeenCalledWith({ ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' }, { board: 'project-a' })
|
||||
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
|
||||
})
|
||||
|
||||
it('opens board-scoped event streams, refreshes on events, and reconnects on board switch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const socketA = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null }
|
||||
const socketB = { close: vi.fn(), onmessage: null as ((event: { data: string }) => void) | null, onclose: null, onerror: null }
|
||||
mockKanbanApi.openKanbanEventStream
|
||||
.mockReturnValueOnce(socketA)
|
||||
.mockReturnValueOnce(socketB)
|
||||
mockKanbanApi.getCapabilities.mockResolvedValue({
|
||||
source: 'hermes-cli',
|
||||
supports: {},
|
||||
missing: [],
|
||||
capabilities: [{ key: 'events', status: 'partial', requiresBoard: true }],
|
||||
})
|
||||
mockKanbanApi.listTasks.mockResolvedValue([])
|
||||
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
|
||||
mockKanbanApi.getAssignees.mockResolvedValue([])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
await store.fetchCapabilities()
|
||||
|
||||
expect(store.startEventStream()).toBe(true)
|
||||
expect(mockKanbanApi.openKanbanEventStream).toHaveBeenCalledWith({ board: 'project-a' })
|
||||
|
||||
socketA.onmessage?.({ data: JSON.stringify({ type: 'event', line: 'changed' }) })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
|
||||
expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' })
|
||||
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
|
||||
|
||||
store.setSelectedBoard('default')
|
||||
expect(socketA.close).toHaveBeenCalled()
|
||||
expect(mockKanbanApi.openKanbanEventStream).toHaveBeenLastCalledWith({ board: 'default' })
|
||||
store.stopEventStream()
|
||||
expect(socketB.close).toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('passes selected board to parity actions and refreshes affected board state', async () => {
|
||||
mockKanbanApi.getCapabilities.mockResolvedValue({
|
||||
source: 'hermes-cli',
|
||||
supports: { taskLog: true, diagnostics: true, reclaim: true, reassign: true, specify: true, dispatch: true },
|
||||
missing: [],
|
||||
})
|
||||
mockKanbanApi.getTaskLog.mockResolvedValue({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
|
||||
mockKanbanApi.getDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
|
||||
mockKanbanApi.reclaimTask.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.reassignTask.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.specifyTask.mockResolvedValue([{ task_id: 'task-1' }])
|
||||
mockKanbanApi.dispatch.mockResolvedValue({ spawned: 1 })
|
||||
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
|
||||
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'bob', on_disk: true, counts: {} }])
|
||||
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1', assignee: 'bob' }])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
store.tasks = [{ id: 'task-1', status: 'running', assignee: 'alice' }] as any
|
||||
await store.fetchCapabilities()
|
||||
|
||||
await expect(store.getTaskLog('task-1', 4000)).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false })
|
||||
await expect(store.getDiagnostics({ task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1' }])
|
||||
await store.reclaimTask('task-1', 'stale')
|
||||
await store.reassignTask('task-1', 'bob', { reclaim: true, reason: 'handoff' })
|
||||
await expect(store.specifyTask('task-1', 'han')).resolves.toEqual([{ task_id: 'task-1' }])
|
||||
await expect(store.dispatch({ dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
|
||||
|
||||
expect(mockKanbanApi.getTaskLog).toHaveBeenCalledWith('task-1', { board: 'project-a', tail: 4000 })
|
||||
expect(mockKanbanApi.getDiagnostics).toHaveBeenCalledWith({ board: 'project-a', task: 'task-1', severity: 'warning' })
|
||||
expect(mockKanbanApi.reclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
|
||||
expect(mockKanbanApi.reassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
|
||||
expect(mockKanbanApi.specifyTask).toHaveBeenCalledWith('task-1', { board: 'project-a', author: 'han' })
|
||||
expect(mockKanbanApi.dispatch).toHaveBeenCalledWith({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })
|
||||
expect(store.tasks[0]).toMatchObject({ id: 'task-1', assignee: 'bob' })
|
||||
})
|
||||
|
||||
it('creates and archives boards without relying on CLI current board', async () => {
|
||||
mockKanbanApi.listBoards.mockResolvedValue([
|
||||
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
|
||||
{ slug: 'new-board', name: 'New Board', archived: false, counts: {}, total: 0 },
|
||||
])
|
||||
mockKanbanApi.createBoard.mockResolvedValue({ slug: 'new-board', name: 'New Board', archived: false, counts: {}, total: 0 })
|
||||
mockKanbanApi.archiveBoard.mockResolvedValue({ ok: true })
|
||||
mockKanbanApi.listTasks.mockResolvedValue([])
|
||||
mockKanbanApi.getStats.mockResolvedValue({ total: 0, by_status: {}, by_assignee: {} })
|
||||
mockKanbanApi.getAssignees.mockResolvedValue([])
|
||||
|
||||
const store = useKanbanStore()
|
||||
await store.createBoard({ slug: 'new-board', name: 'New Board' })
|
||||
expect(mockKanbanApi.createBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
|
||||
expect(store.selectedBoard).toBe('new-board')
|
||||
|
||||
await store.archiveSelectedBoard()
|
||||
expect(mockKanbanApi.archiveBoard).toHaveBeenCalledWith('new-board')
|
||||
expect(store.selectedBoard).toBe('default')
|
||||
})
|
||||
|
||||
it('refreshAll loads boards, tasks, stats, and assignees for the same board', async () => {
|
||||
mockKanbanApi.listTasks.mockResolvedValue([{ id: 'task-1' }])
|
||||
mockKanbanApi.getStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
|
||||
mockKanbanApi.getAssignees.mockResolvedValue([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
await store.refreshAll()
|
||||
|
||||
expect(mockKanbanApi.listTasks).toHaveBeenCalledWith({ board: 'project-a', status: undefined, assignee: undefined, includeArchived: true })
|
||||
expect(mockKanbanApi.getStats).toHaveBeenCalledWith({ board: 'project-a' })
|
||||
expect(mockKanbanApi.getAssignees).toHaveBeenCalledWith({ board: 'project-a' })
|
||||
expect(mockKanbanApi.listBoards).toHaveBeenCalledWith({ includeArchived: false })
|
||||
expect(store.tasks).toEqual([{ id: 'task-1' }])
|
||||
expect(store.stats).toEqual({ total: 1, by_status: {}, by_assignee: {} })
|
||||
expect(store.assignees).toEqual([{ name: 'alice', on_disk: true, counts: { todo: 1 } }])
|
||||
})
|
||||
|
||||
it('ignores stale board-list responses after a newer request', async () => {
|
||||
let resolveSlowBoards: (value: unknown) => void = () => {}
|
||||
mockKanbanApi.listBoards
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveSlowBoards = resolve }))
|
||||
.mockResolvedValueOnce([
|
||||
{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 },
|
||||
{ slug: 'project-a', name: 'Project A', archived: false, counts: { todo: 2 }, total: 2 },
|
||||
])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
const slowFetch = store.fetchBoards()
|
||||
await store.fetchBoards()
|
||||
resolveSlowBoards([{ slug: 'default', name: 'Default', archived: false, counts: {}, total: 0 }])
|
||||
await slowFetch
|
||||
|
||||
expect(store.selectedBoard).toBe('project-a')
|
||||
expect(store.activeBoards).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ slug: 'project-a', total: 2 }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('ignores stale same-board fetch responses after a newer request', async () => {
|
||||
let resolveSlow: (value: unknown) => void = () => {}
|
||||
mockKanbanApi.listTasks
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveSlow = resolve }))
|
||||
.mockResolvedValueOnce([{ id: 'new-filter-task' }])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('project-a')
|
||||
const slowFetch = store.fetchTasks()
|
||||
await store.fetchTasks()
|
||||
resolveSlow([{ id: 'old-filter-task' }])
|
||||
await slowFetch
|
||||
|
||||
expect(store.tasks).toEqual([{ id: 'new-filter-task' }])
|
||||
})
|
||||
|
||||
it('does not leave loading stuck when a silent fetch supersedes a visible fetch', async () => {
|
||||
let resolveVisible: (value: unknown) => void = () => {}
|
||||
mockKanbanApi.listTasks
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveVisible = resolve }))
|
||||
.mockResolvedValueOnce([{ id: 'silent-task' }])
|
||||
|
||||
const store = useKanbanStore()
|
||||
const visibleFetch = store.fetchTasks()
|
||||
expect(store.loading).toBe(true)
|
||||
await store.fetchTasks(true)
|
||||
resolveVisible([{ id: 'visible-task' }])
|
||||
await visibleFetch
|
||||
|
||||
expect(store.tasks).toEqual([{ id: 'silent-task' }])
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores stale fetch responses after a board switch', async () => {
|
||||
let resolveSlow: (value: unknown) => void = () => {}
|
||||
mockKanbanApi.listTasks
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveSlow = resolve }))
|
||||
.mockResolvedValueOnce([{ id: 'new-board-task' }])
|
||||
|
||||
const store = useKanbanStore()
|
||||
store.setSelectedBoard('default')
|
||||
const slowFetch = store.fetchTasks()
|
||||
store.setSelectedBoard('project-a')
|
||||
await store.fetchTasks()
|
||||
resolveSlow([{ id: 'old-board-task' }])
|
||||
await slowFetch
|
||||
|
||||
expect(store.tasks).toEqual([{ id: 'new-board-task' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, number>) => {
|
||||
if (key === 'kanban.card.timeAgo.justNow') return '刚刚'
|
||||
if (key === 'kanban.card.timeAgo.minutes') return `${params?.count}分钟前`
|
||||
if (key === 'kanban.card.timeAgo.hours') return `${params?.count}小时前`
|
||||
if (key === 'kanban.card.timeAgo.days') return `${params?.count}天前`
|
||||
if (key === 'kanban.card.priority.high') return '高'
|
||||
if (key === 'kanban.card.priority.medium') return '中'
|
||||
if (key === 'kanban.card.priority.low') return '低'
|
||||
if (key === 'kanban.card.assigneeTooltip') return '负责人'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NTooltip: defineComponent({
|
||||
name: 'NTooltip',
|
||||
template: '<div class="n-tooltip-stub"><slot name="trigger" /><div class="tooltip-content"><slot /></div></div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/profiles/ProfileAvatar.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ProfileAvatar',
|
||||
props: { name: { type: String, required: true }, avatar: { type: Object, required: false }, size: { type: Number, required: false } },
|
||||
template: '<span class="assignee-profile-avatar-stub" :data-name="name" :data-avatar-type="avatar?.type || null" :data-avatar-seed="avatar?.seed || null"></span>',
|
||||
}),
|
||||
}))
|
||||
|
||||
import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
|
||||
|
||||
describe('KanbanTaskCard i18n', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders localized priority, tooltip, and relative time', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-08T03:00:00Z'))
|
||||
|
||||
const wrapper = mount(KanbanTaskCard, {
|
||||
props: {
|
||||
assigneeAvatar: { type: 'generated', seed: 'alice-seed' },
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Ship kanban i18n',
|
||||
body: 'Body preview content',
|
||||
assignee: 'alice',
|
||||
status: 'todo',
|
||||
priority: 3,
|
||||
created_by: null,
|
||||
created_at: Math.floor(new Date('2026-05-08T02:58:00Z').getTime() / 1000),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
workspace_kind: 'local',
|
||||
workspace_path: null,
|
||||
tenant: null,
|
||||
result: null,
|
||||
skills: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('高')
|
||||
expect(wrapper.text()).toContain('2分钟前')
|
||||
expect(wrapper.text()).toContain('负责人')
|
||||
expect(wrapper.classes()).toContain('status-todo')
|
||||
const avatar = wrapper.find('.assignee-profile-avatar-stub')
|
||||
expect(avatar.attributes('data-name')).toBe('alice')
|
||||
expect(avatar.attributes('data-avatar-type')).toBe('generated')
|
||||
expect(avatar.attributes('data-avatar-seed')).toBe('alice-seed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const mockGetTask = vi.hoisted(() => vi.fn())
|
||||
const mockRequest = vi.hoisted(() => vi.fn())
|
||||
const mockCompleteTasks = vi.hoisted(() => vi.fn())
|
||||
const mockBlockTask = vi.hoisted(() => vi.fn())
|
||||
const mockUnblockTasks = vi.hoisted(() => vi.fn())
|
||||
const mockAssignTask = vi.hoisted(() => vi.fn())
|
||||
const mockRouterPush = vi.hoisted(() => vi.fn())
|
||||
const mockUseMessage = vi.hoisted(() => vi.fn(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
request: mockRequest,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/kanban', () => ({
|
||||
getTask: mockGetTask,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/kanban', () => ({
|
||||
useKanbanStore: () => ({
|
||||
selectedBoard: 'project-a',
|
||||
assignees: [{ name: 'alice', counts: { todo: 1 } }, { name: 'bob', counts: { ready: 1 } }],
|
||||
completeTasks: mockCompleteTasks,
|
||||
blockTask: mockBlockTask,
|
||||
unblockTasks: mockUnblockTasks,
|
||||
assignTask: mockAssignTask,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/HistoryMessageList.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'HistoryMessageList',
|
||||
props: { session: { type: Object, required: false } },
|
||||
template: '<div class="history-message-list-stub">{{ session ? session.id : "none" }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: defineComponent({
|
||||
name: 'NDrawer',
|
||||
props: { show: { type: Boolean, required: false } },
|
||||
emits: ['update:show'],
|
||||
template: '<div class="n-drawer-stub"><slot /></div>',
|
||||
}),
|
||||
NDrawerContent: defineComponent({
|
||||
name: 'NDrawerContent',
|
||||
props: { title: { type: String, required: false }, closable: { type: Boolean, required: false } },
|
||||
template: '<div class="n-drawer-content-stub"><slot /></div>',
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
name: 'NSelect',
|
||||
props: { value: { required: false }, options: { type: Array, default: () => [] } },
|
||||
emits: ['update:value'],
|
||||
template: '<select class="n-select-stub" @change="$emit(\'update:value\', $event.target.value || null)"><option value=""></option><option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option></select>',
|
||||
}),
|
||||
NInput: defineComponent({
|
||||
name: 'NInput',
|
||||
props: { value: { required: false }, size: { type: String, required: false }, placeholder: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NSpin: defineComponent({
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
}),
|
||||
NModal: defineComponent({
|
||||
name: 'NModal',
|
||||
props: { show: { type: Boolean, required: false }, title: { type: String, required: false } },
|
||||
emits: ['close'],
|
||||
template: '<div v-if="show" class="n-modal-stub" :data-title="title"><slot /></div>',
|
||||
}),
|
||||
useMessage: mockUseMessage,
|
||||
}))
|
||||
|
||||
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
|
||||
|
||||
describe('KanbanTaskDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRequest.mockResolvedValue({ results: [] })
|
||||
mockCompleteTasks.mockResolvedValue(undefined)
|
||||
mockBlockTask.mockResolvedValue(undefined)
|
||||
mockUnblockTasks.mockResolvedValue(undefined)
|
||||
mockAssignTask.mockResolvedValue(undefined)
|
||||
mockGetTask.mockResolvedValue({
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Ship kanban',
|
||||
body: 'Implement feature',
|
||||
assignee: 'alice',
|
||||
status: 'done',
|
||||
priority: 2,
|
||||
created_at: 100,
|
||||
started_at: 110,
|
||||
completed_at: 120,
|
||||
tenant: null,
|
||||
result: 'Done summary',
|
||||
},
|
||||
latest_summary: 'Done summary',
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [{ id: 1, profile: 'alice', status: 'done', started_at: 110, ended_at: 120 }],
|
||||
session: {
|
||||
id: 'session-1',
|
||||
title: 'Hermes session',
|
||||
source: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
started_at: 110,
|
||||
ended_at: 120,
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', content: 'hello', timestamp: 111 },
|
||||
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 112 },
|
||||
{ id: 'm3', role: 'tool', content: 'ignore', timestamp: 113 },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders completed-result messages through HistoryMessageList', async () => {
|
||||
const wrapper = mount(KanbanTaskDrawer, {
|
||||
props: { taskId: 'task-1' },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.result-summary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const modal = wrapper.find('.n-modal-stub')
|
||||
expect(modal.exists()).toBe(true)
|
||||
expect(modal.attributes('data-title')).toBe('Ship kanban')
|
||||
|
||||
const history = wrapper.find('.history-message-list-stub')
|
||||
expect(history.exists()).toBe(true)
|
||||
expect(history.text()).toBe('session-1')
|
||||
|
||||
const sessionProp = wrapper.getComponent({ name: 'HistoryMessageList' }).props('session') as any
|
||||
expect(sessionProp.messages).toEqual([
|
||||
{ id: 'm1', role: 'user', content: 'hello', timestamp: 111 },
|
||||
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 112 },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the latest run profile when searching related sessions', async () => {
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
task: {
|
||||
id: 'task-2',
|
||||
title: 'Retry task',
|
||||
body: null,
|
||||
assignee: 'bob',
|
||||
status: 'running',
|
||||
priority: 2,
|
||||
created_at: 100,
|
||||
started_at: 110,
|
||||
completed_at: null,
|
||||
tenant: null,
|
||||
result: null,
|
||||
},
|
||||
latest_summary: null,
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [
|
||||
{ id: 1, profile: 'stale', status: 'failed', started_at: 110, ended_at: 120 },
|
||||
{ id: 2, profile: 'fresh', status: 'running', started_at: 130, ended_at: null },
|
||||
],
|
||||
})
|
||||
mockRequest.mockResolvedValueOnce({
|
||||
results: [{ id: 'session-2', title: 'Found session', source: 'codex', model: 'gpt-5.5', started_at: 130 }],
|
||||
})
|
||||
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-2' } })
|
||||
await flushPromises()
|
||||
|
||||
const sessionsTitle = wrapper.findAll('.section-title').find(node => node.text() === 'kanban.detail.sessions')
|
||||
await sessionsTitle?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/kanban/search-sessions?task_id=task-2&profile=fresh&board=project-a')
|
||||
await wrapper.find('.session-item').trigger('click')
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'hermes.chat', query: { session: 'session-2' } })
|
||||
})
|
||||
|
||||
it('does not expose mutation actions for archived tasks', async () => {
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
task: {
|
||||
id: 'task-archived',
|
||||
title: 'Archived task',
|
||||
body: null,
|
||||
assignee: 'alice',
|
||||
status: 'archived',
|
||||
priority: 1,
|
||||
created_at: 100,
|
||||
started_at: 110,
|
||||
completed_at: 120,
|
||||
tenant: null,
|
||||
result: 'Archived summary',
|
||||
},
|
||||
latest_summary: 'Archived summary',
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [],
|
||||
})
|
||||
|
||||
const wrapper = mount(KanbanTaskDrawer, { props: { taskId: 'task-archived' } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('kanban.action.complete')
|
||||
expect(wrapper.text()).not.toContain('kanban.action.block')
|
||||
expect(wrapper.text()).not.toContain('kanban.action.assign')
|
||||
})
|
||||
|
||||
it('executes complete, block, unblock, and assign actions', async () => {
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
task: {
|
||||
id: 'task-0',
|
||||
title: 'Todo task',
|
||||
body: null,
|
||||
assignee: null,
|
||||
status: 'todo',
|
||||
priority: 1,
|
||||
created_at: 100,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
tenant: null,
|
||||
result: null,
|
||||
},
|
||||
latest_summary: null,
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [],
|
||||
})
|
||||
const wrapper = mount(KanbanTaskDrawer, {
|
||||
props: { taskId: 'task-0' },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const buttons = wrapper.findAll('.n-button-stub')
|
||||
await buttons.find(node => node.text() === 'kanban.action.complete')?.trigger('click')
|
||||
await wrapper.find('.n-input-stub').setValue('done summary')
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockCompleteTasks).toHaveBeenCalledWith(['task-0'], 'done summary')
|
||||
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
task: {
|
||||
id: 'task-3',
|
||||
title: 'Blocked task',
|
||||
body: null,
|
||||
assignee: 'alice',
|
||||
status: 'blocked',
|
||||
priority: 1,
|
||||
created_at: 100,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
tenant: null,
|
||||
result: null,
|
||||
},
|
||||
latest_summary: null,
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [],
|
||||
})
|
||||
await wrapper.setProps({ taskId: 'task-3' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.unblock')?.trigger('click')
|
||||
expect(mockUnblockTasks).toHaveBeenCalledWith(['task-3'])
|
||||
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
task: {
|
||||
id: 'task-4',
|
||||
title: 'Todo task',
|
||||
body: null,
|
||||
assignee: null,
|
||||
status: 'todo',
|
||||
priority: 1,
|
||||
created_at: 100,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
tenant: null,
|
||||
result: null,
|
||||
},
|
||||
latest_summary: null,
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [],
|
||||
})
|
||||
mockGetTask.mockResolvedValueOnce({
|
||||
task: {
|
||||
id: 'task-4',
|
||||
title: 'Todo task',
|
||||
body: null,
|
||||
assignee: 'bob',
|
||||
status: 'todo',
|
||||
priority: 1,
|
||||
created_at: 100,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
tenant: null,
|
||||
result: null,
|
||||
},
|
||||
latest_summary: null,
|
||||
comments: [],
|
||||
events: [],
|
||||
runs: [],
|
||||
})
|
||||
await wrapper.setProps({ taskId: 'task-4' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.block')?.trigger('click')
|
||||
await wrapper.find('.n-input-stub').setValue('waiting dependency')
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'common.ok')?.trigger('click')
|
||||
expect(mockBlockTask).toHaveBeenCalledWith('task-4', 'waiting dependency')
|
||||
|
||||
const select = wrapper.find('.n-select-stub')
|
||||
await select.setValue('bob')
|
||||
await wrapper.findAll('.n-button-stub').find(node => node.text() === 'kanban.action.assign')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockAssignTask).toHaveBeenCalledWith('task-4', 'bob')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
query: { board: 'project-a' } as Record<string, string>,
|
||||
}))
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
tasks: [] as Array<{ id: string; title: string; status: string; created_at: number; assignee?: string | null }>,
|
||||
stats: { by_status: { todo: 1, done: 0 }, by_assignee: {}, total: 1 } as Record<string, any>,
|
||||
assignees: [] as Array<{ name: string; counts: Record<string, number> | null }>,
|
||||
activeBoards: [] as Array<{ slug: string; name: string; icon?: string; total?: number }>,
|
||||
loading: false,
|
||||
boardsLoading: false,
|
||||
selectedBoard: 'default',
|
||||
boardWarning: null as string | null,
|
||||
capabilities: null as Record<string, any> | null,
|
||||
filterStatus: null as string | null,
|
||||
filterAssignee: null as string | null,
|
||||
}))
|
||||
|
||||
const mockFetchBoards = vi.hoisted(() => vi.fn())
|
||||
const mockFetchCapabilities = vi.hoisted(() => vi.fn())
|
||||
const mockRefreshAll = vi.hoisted(() => vi.fn())
|
||||
const mockFetchTasks = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStats = vi.hoisted(() => vi.fn())
|
||||
const mockSetFilter = vi.hoisted(() => vi.fn())
|
||||
const mockRecoverSelectedBoard = vi.hoisted(() => vi.fn())
|
||||
const mockCreateBoard = vi.hoisted(() => vi.fn())
|
||||
const mockArchiveSelectedBoard = vi.hoisted(() => vi.fn())
|
||||
const mockStartEventStream = vi.hoisted(() => vi.fn())
|
||||
const mockStopEventStream = vi.hoisted(() => vi.fn())
|
||||
const mockFetchProfiles = vi.hoisted(() => vi.fn())
|
||||
const profilesState = vi.hoisted(() => ({
|
||||
profiles: [] as Array<{ name: string; avatar?: Record<string, any> | null }>,
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({ replace: routerReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/kanban', () => ({
|
||||
DEFAULT_KANBAN_BOARD: 'default',
|
||||
useKanbanStore: () => ({
|
||||
...storeState,
|
||||
fetchBoards: mockFetchBoards,
|
||||
fetchCapabilities: mockFetchCapabilities,
|
||||
refreshAll: mockRefreshAll,
|
||||
fetchTasks: mockFetchTasks,
|
||||
fetchStats: mockFetchStats,
|
||||
setFilter: mockSetFilter,
|
||||
recoverSelectedBoard: mockRecoverSelectedBoard,
|
||||
createBoard: mockCreateBoard,
|
||||
archiveSelectedBoard: mockArchiveSelectedBoard,
|
||||
startEventStream: mockStartEventStream,
|
||||
stopEventStream: mockStopEventStream,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/profiles', () => ({
|
||||
useProfilesStore: () => ({
|
||||
profiles: profilesState.profiles,
|
||||
fetchProfiles: mockFetchProfiles,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanTaskCard.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanTaskCard',
|
||||
props: { task: { type: Object, required: true }, assigneeAvatar: { type: Object, required: false } },
|
||||
template: '<div class="kanban-task-card-stub" :data-avatar-seed="assigneeAvatar?.seed || null">{{ task.title }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanTaskDrawer.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanTaskDrawer',
|
||||
emits: ['updated', 'close'],
|
||||
template: '<button class="drawer-updated" @click="$emit(\'updated\')">drawer</button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/kanban/KanbanCreateForm.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'KanbanCreateForm',
|
||||
emits: ['created', 'close'],
|
||||
template: '<button class="form-created" @click="$emit(\'created\')">form</button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
useMessage: () => ({ warning: vi.fn(), error: vi.fn(), success: vi.fn() }),
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
}),
|
||||
NSelect: defineComponent({
|
||||
name: 'NSelect',
|
||||
props: { value: null, options: { type: Array, default: () => [] }, loading: Boolean },
|
||||
emits: ['update:value'],
|
||||
template: '<button class="n-select-stub" @click="$emit(\'update:value\', options[1]?.value || value)"><span v-for="option in options" :key="option.value">{{ option.label }}</span>{{ value }}</button>',
|
||||
}),
|
||||
NInput: defineComponent({
|
||||
name: 'NInput',
|
||||
props: { value: { type: String, default: '' }, placeholder: { type: String, required: false } },
|
||||
emits: ['update:value'],
|
||||
template: '<input class="n-input-stub" :placeholder="placeholder" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
|
||||
}),
|
||||
NModal: defineComponent({
|
||||
name: 'NModal',
|
||||
props: { show: Boolean },
|
||||
emits: ['update:show', 'close'],
|
||||
template: '<div v-if="show" class="n-modal-stub"><slot /><slot name="action" /></div>',
|
||||
}),
|
||||
NSpin: defineComponent({
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
}),
|
||||
NCollapse: defineComponent({
|
||||
name: 'NCollapse',
|
||||
props: { expandedNames: { type: Array, required: false }, defaultExpandedNames: { type: Array, required: false } },
|
||||
emits: ['update:expandedNames'],
|
||||
template: '<div class="n-collapse-stub" :data-expanded="JSON.stringify(expandedNames ?? null)" :data-default-expanded="JSON.stringify(defaultExpandedNames ?? null)"><slot /></div>',
|
||||
}),
|
||||
NCollapseItem: defineComponent({
|
||||
name: 'NCollapseItem',
|
||||
props: { title: { type: String, required: false }, name: { type: String, required: false } },
|
||||
template: '<section class="n-collapse-item-stub" :data-name="name"><slot /></section>',
|
||||
}),
|
||||
}))
|
||||
|
||||
import KanbanView from '@/views/hermes/KanbanView.vue'
|
||||
|
||||
describe('KanbanView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
routeState.query = { board: 'project-a' }
|
||||
routerReplace.mockResolvedValue(undefined)
|
||||
storeState.tasks = [
|
||||
{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10 },
|
||||
{ id: 'task-2', title: 'Task two', status: 'done', created_at: 20 },
|
||||
]
|
||||
storeState.stats = {
|
||||
by_status: { triage: 0, todo: 1, ready: 0, running: 0, blocked: 0, done: 1, archived: 0 },
|
||||
by_assignee: {},
|
||||
total: 2,
|
||||
}
|
||||
storeState.assignees = []
|
||||
storeState.activeBoards = [
|
||||
{ slug: 'default', name: 'Default', total: 0 },
|
||||
{ slug: 'project-a', name: 'Project A', total: 2 },
|
||||
]
|
||||
storeState.loading = false
|
||||
storeState.boardsLoading = false
|
||||
storeState.selectedBoard = 'default'
|
||||
storeState.boardWarning = null
|
||||
storeState.capabilities = null
|
||||
storeState.filterStatus = null
|
||||
storeState.filterAssignee = null
|
||||
profilesState.profiles = []
|
||||
mockFetchBoards.mockResolvedValue(undefined)
|
||||
mockFetchCapabilities.mockResolvedValue(undefined)
|
||||
mockRefreshAll.mockResolvedValue(undefined)
|
||||
mockFetchTasks.mockResolvedValue(undefined)
|
||||
mockFetchStats.mockResolvedValue(undefined)
|
||||
mockFetchProfiles.mockResolvedValue(undefined)
|
||||
mockCreateBoard.mockResolvedValue({ slug: 'new-board' })
|
||||
mockArchiveSelectedBoard.mockResolvedValue(undefined)
|
||||
mockRecoverSelectedBoard.mockImplementation((candidate: string) => {
|
||||
storeState.selectedBoard = candidate || 'default'
|
||||
return { board: storeState.selectedBoard, recovered: false }
|
||||
})
|
||||
mockSetFilter.mockImplementation((key: 'status' | 'assignee', value: string | null) => {
|
||||
if (key === 'status') storeState.filterStatus = value
|
||||
else storeState.filterAssignee = value
|
||||
})
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'visible',
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes board from route query and refreshes stats alongside tasks', async () => {
|
||||
const wrapper = mount(KanbanView)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchBoards).toHaveBeenCalledOnce()
|
||||
expect(mockFetchCapabilities).toHaveBeenCalledOnce()
|
||||
expect(mockFetchProfiles).toHaveBeenCalledOnce()
|
||||
expect(mockRecoverSelectedBoard).toHaveBeenCalledWith('project-a')
|
||||
expect(mockRefreshAll).toHaveBeenCalledOnce()
|
||||
expect(routerReplace).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["triage","todo","ready","running","blocked","done","archived"]')
|
||||
|
||||
await wrapper.find('.drawer-updated').trigger('click')
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchStats).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchBoards).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchStats).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders board count labels and compact assignee profile labels', async () => {
|
||||
storeState.assignees = [{ name: 'alice', counts: { todo: 2, done: 1 } }]
|
||||
const wrapper = mount(KanbanView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
|
||||
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
|
||||
const assigneeSelect = wrapper.findAll('.n-select-stub')[2]
|
||||
expect(assigneeSelect.text()).toContain('alice')
|
||||
expect(assigneeSelect.text()).not.toContain('default')
|
||||
expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice')
|
||||
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||
})
|
||||
|
||||
it('passes matching profile avatars to task cards', async () => {
|
||||
storeState.tasks = [{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10, assignee: 'alice' }]
|
||||
profilesState.profiles = [{ name: 'alice', avatar: { type: 'generated', seed: 'alice-seed' } }]
|
||||
|
||||
const wrapper = mount(KanbanView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.kanban-task-card-stub').attributes('data-avatar-seed')).toBe('alice-seed')
|
||||
})
|
||||
|
||||
it('filters the visible board columns from stats chips', async () => {
|
||||
storeState.filterStatus = 'done'
|
||||
|
||||
const wrapper = mount(KanbanView)
|
||||
await flushPromises()
|
||||
|
||||
const columns = wrapper.findAll('.n-collapse-item-stub')
|
||||
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["done"]')
|
||||
expect(columns).toHaveLength(1)
|
||||
expect(columns[0].attributes('data-name')).toBe('done')
|
||||
expect(wrapper.text()).toContain('Task two')
|
||||
expect(wrapper.text()).not.toContain('Task one')
|
||||
|
||||
await wrapper.find('.stat-chip.todo').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSetFilter).toHaveBeenCalledWith('status', 'todo')
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.find('.stat-chip.total').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSetFilter).toHaveBeenCalledWith('status', null)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('creates and archives boards from the board toolbar', async () => {
|
||||
storeState.selectedBoard = 'project-a'
|
||||
const wrapper = mount(KanbanView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('.n-button-stub')[0].trigger('click')
|
||||
await flushPromises()
|
||||
const inputs = wrapper.findAll('.n-input-stub')
|
||||
await inputs[0].setValue('new-board')
|
||||
await inputs[1].setValue('New Board')
|
||||
await wrapper.findAll('.n-button-stub').at(-1)!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCreateBoard).toHaveBeenCalledWith({ slug: 'new-board', name: 'New Board' })
|
||||
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'new-board' } })
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
await wrapper.findAll('.n-button-stub')[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockArchiveSelectedBoard).toHaveBeenCalled()
|
||||
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })
|
||||
})
|
||||
|
||||
it('makes default board explicit when route query is absent', async () => {
|
||||
routeState.query = {}
|
||||
mockRecoverSelectedBoard.mockImplementation(() => {
|
||||
storeState.selectedBoard = 'default'
|
||||
return { board: 'default', recovered: false }
|
||||
})
|
||||
|
||||
mount(KanbanView)
|
||||
await flushPromises()
|
||||
|
||||
expect(routerReplace).toHaveBeenCalledWith({ query: { board: 'default' } })
|
||||
expect(mockRefreshAll).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockReplace = vi.hoisted(() => vi.fn())
|
||||
const mockFetchAuthStatus = vi.hoisted(() => vi.fn())
|
||||
const mockLoginWithPassword = vi.hoisted(() => vi.fn())
|
||||
const mockSetApiKey = vi.hoisted(() => vi.fn())
|
||||
const mockHasApiKey = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
setApiKey: mockSetApiKey,
|
||||
hasApiKey: mockHasApiKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
fetchAuthStatus: mockFetchAuthStatus,
|
||||
loginWithPassword: mockLoginWithPassword,
|
||||
}))
|
||||
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
|
||||
describe('LoginView password login', () => {
|
||||
beforeEach(() => {
|
||||
delete (window as any).__LOGIN_TOKEN__
|
||||
vi.clearAllMocks()
|
||||
mockHasApiKey.mockReturnValue(false)
|
||||
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: true, username: 'admin' })
|
||||
})
|
||||
|
||||
it('logs in with username and password', async () => {
|
||||
mockLoginWithPassword.mockResolvedValue('jwt-token')
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
const inputs = wrapper.findAll('input.login-input')
|
||||
await inputs[0].setValue('admin')
|
||||
await inputs[1].setValue('123456')
|
||||
await wrapper.find('form.login-form').trigger('submit')
|
||||
|
||||
expect(mockLoginWithPassword).toHaveBeenCalledWith('admin', '123456')
|
||||
expect(mockSetApiKey).toHaveBeenCalledWith('jwt-token')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/hermes/chat')
|
||||
})
|
||||
|
||||
it('shows the default login hint', () => {
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
expect(wrapper.text()).toContain('login.defaultCredentialsHint')
|
||||
})
|
||||
|
||||
it('shows an error when password login fails', async () => {
|
||||
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
const inputs = wrapper.findAll('input.login-input')
|
||||
await inputs[0].setValue('admin')
|
||||
await inputs[1].setValue('bad-password')
|
||||
await wrapper.find('form.login-form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('.login-error').text()).toBe('Invalid username or password')
|
||||
expect(mockSetApiKey).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the reset command hint when the login IP is locked', async () => {
|
||||
const err: any = new Error('Too many login attempts')
|
||||
err.status = 429
|
||||
mockLoginWithPassword.mockRejectedValue(err)
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
const inputs = wrapper.findAll('input.login-input')
|
||||
await inputs[0].setValue('admin')
|
||||
await inputs[1].setValue('123456')
|
||||
await wrapper.find('form.login-form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('.login-error').text()).toBe('login.tooManyAttempts')
|
||||
expect(wrapper.find('.login-lock-hint').text()).toContain('login.lockResetHint')
|
||||
expect(wrapper.find('.login-lock-hint').text()).toContain('login.defaultLoginResetHint')
|
||||
const commands = wrapper.findAll('.login-lock-hint code').map(command => command.text())
|
||||
expect(commands).toEqual([
|
||||
'hermes-web-ui clear-login-locks --restart',
|
||||
'hermes-web-ui reset-default-login',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
vi.mock('mermaid', () => new Promise(() => {}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: {
|
||||
props: ['show'],
|
||||
template: '<div v-if="show"><slot /></div>',
|
||||
},
|
||||
NDrawerContent: {
|
||||
template: '<section><slot /></section>',
|
||||
},
|
||||
NSpin: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
async function flushMermaidRender(): Promise<void> {
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('MarkdownRenderer Mermaid import timeout', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('falls back to copyable code when the mermaid dynamic import never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,712 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mermaidMock = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn(async (id: string, source: string) => ({
|
||||
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
|
||||
})),
|
||||
}))
|
||||
|
||||
const downloadApiMock = vi.hoisted(() => ({
|
||||
downloadFile: vi.fn(() => Promise.resolve()),
|
||||
fetchFileText: vi.fn(() => Promise.resolve('preview content')),
|
||||
getDownloadUrl: vi.fn((path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`),
|
||||
}))
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: mermaidMock,
|
||||
}))
|
||||
|
||||
async function flushMermaidRender(): Promise<void> {
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: {
|
||||
props: ['show', 'width'],
|
||||
template: '<div v-if="show" class="n-drawer-stub" :data-width="width"><slot /></div>',
|
||||
},
|
||||
NDrawerContent: {
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
closable: { type: Boolean, default: false },
|
||||
bodyContentStyle: { type: [Object, String], default: undefined },
|
||||
},
|
||||
template: '<section class="n-drawer-content-stub" :data-body-padding="bodyContentStyle && bodyContentStyle.padding"><header class="n-drawer-header-stub">{{ title }}<button v-if="closable" class="n-drawer-close-stub" @click="$emit(\'close\')">x</button></header><slot /></section>',
|
||||
},
|
||||
NSpin: {
|
||||
props: ['show'],
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
},
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/download', () => ({
|
||||
downloadFile: downloadApiMock.downloadFile,
|
||||
fetchFileText: downloadApiMock.fetchFileText,
|
||||
getDownloadUrl: downloadApiMock.getDownloadUrl,
|
||||
}))
|
||||
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
describe('MarkdownRenderer', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mermaidMock.initialize.mockClear()
|
||||
mermaidMock.render.mockClear()
|
||||
downloadApiMock.downloadFile.mockClear()
|
||||
downloadApiMock.fetchFileText.mockClear()
|
||||
downloadApiMock.getDownloadUrl.mockClear()
|
||||
mermaidMock.render.mockImplementation(async (id: string, source: string) => ({
|
||||
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
|
||||
}))
|
||||
downloadApiMock.downloadFile.mockResolvedValue(undefined)
|
||||
downloadApiMock.fetchFileText.mockResolvedValue('preview content')
|
||||
downloadApiMock.getDownloadUrl.mockImplementation((path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`)
|
||||
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('highlights vue fenced blocks instead of rendering them as plain text', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```vue\n<template><div>Hello</div></template>\n```',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.code-lang').text()).toBe('vue')
|
||||
expect(wrapper.find('code.hljs').html()).toContain('hljs-tag')
|
||||
})
|
||||
|
||||
it('keeps shell-session fences on the shell grammar', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```shell\n$ ls\nfoo.txt\n```',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.code-lang').text()).toBe('shell')
|
||||
expect(wrapper.find('code.hljs').html()).toContain('hljs-meta')
|
||||
})
|
||||
|
||||
it('still highlights long supported code fences', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: `\`\`\`json\n${JSON.stringify({ content: 'x'.repeat(2500), ok: true })}\n\`\`\``,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.code-lang').text()).toBe('json')
|
||||
expect(wrapper.find('code.hljs').html()).toMatch(/hljs-(attr|string|punctuation)/)
|
||||
})
|
||||
|
||||
it('falls back to plain escaped text when a fence language is unsupported', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```foobar\n{"answer":42,"ok":true}\n```',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.code-lang').text()).toBe('foobar')
|
||||
expect(wrapper.find('code.hljs').findAll('span')).toHaveLength(0)
|
||||
expect(wrapper.find('code.hljs').text()).toContain('{"answer":42,"ok":true}')
|
||||
})
|
||||
|
||||
it('keeps unlabeled code fences as plain text instead of guessing a grammar', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```\nINFO Starting server\nConnected to 127.0.0.1\nDone\n```',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.code-lang').text()).toBe('text')
|
||||
expect(wrapper.find('code.hljs').findAll('span')).toHaveLength(0)
|
||||
expect(wrapper.find('code.hljs').text()).toContain('INFO Starting server')
|
||||
})
|
||||
|
||||
it('renders outer markdown draft fences as markdown while preserving nested fenced examples', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'下面是可直接手动编辑的 PR draft。',
|
||||
'',
|
||||
'```md',
|
||||
'标题: fix(chat): 保留附件在同一聊天后续轮次的上下文',
|
||||
'',
|
||||
'## Summary',
|
||||
'',
|
||||
'附件上传后,首轮 `startRun()` 的 `input` 已包含上传文件引用:',
|
||||
'',
|
||||
'```md',
|
||||
'[File: screenshot.png](/uploaded/path)',
|
||||
'```',
|
||||
'',
|
||||
'但本地保存的用户消息只保留 UI 可见文本。',
|
||||
'',
|
||||
'## Fix',
|
||||
'- Preserve context.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('md')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('[File: screenshot.png](/uploaded/path)')
|
||||
expect(wrapper.find('.markdown-body').findAll('h2')).toHaveLength(2)
|
||||
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Summary')
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('但本地保存的用户消息只保留 UI 可见文本。')
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Preserve context.')
|
||||
})
|
||||
|
||||
it('keeps markdown examples with their own nested fences intact after unwrapping a draft fence', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```md',
|
||||
'## Regression Coverage',
|
||||
'',
|
||||
'```md',
|
||||
'下面是一个 PR draft。',
|
||||
'',
|
||||
'```md',
|
||||
'[File: Screenshot.png](/tmp/example.png)',
|
||||
'```',
|
||||
'',
|
||||
'## Fix',
|
||||
'',
|
||||
'- 后续 heading 不应被截断。',
|
||||
'```',
|
||||
'',
|
||||
'## Local Verification',
|
||||
'',
|
||||
'- localhost renders after the example.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
const headings = wrapper.find('.markdown-body').findAll('h2').map(heading => heading.text())
|
||||
expect(headings).toEqual(['Regression Coverage', 'Local Verification'])
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
|
||||
|
||||
const codeText = wrapper.find('code.hljs').text()
|
||||
expect(codeText).toContain('下面是一个 PR draft。')
|
||||
expect(codeText).toContain('```md\n[File: Screenshot.png](/tmp/example.png)\n```')
|
||||
expect(codeText).toContain('## Fix')
|
||||
expect(codeText).toContain('- 后续 heading 不应被截断。')
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('localhost renders after the example.')
|
||||
})
|
||||
|
||||
it('keeps markdown examples with unlabeled nested fences intact', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```md',
|
||||
'## Unlabeled Fence Example',
|
||||
'',
|
||||
'```md',
|
||||
'```',
|
||||
'plain nested block',
|
||||
'```',
|
||||
'```',
|
||||
'',
|
||||
'Done outside.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Unlabeled Fence Example')
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
|
||||
expect(wrapper.find('code.hljs').text()).toContain('```\nplain nested block\n```')
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
|
||||
})
|
||||
|
||||
it('renders local mov links as inline video players', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[录屏2026-05-08 15.19.46.mov](/Users/ekko/Desktop/录屏2026-05-08%2015.19.46.mov)',
|
||||
},
|
||||
})
|
||||
|
||||
const video = wrapper.find('video.markdown-video')
|
||||
expect(video.exists()).toBe(true)
|
||||
expect(video.attributes('src')).toContain('/api/hermes/download?path=')
|
||||
const src = new URL(video.attributes('src'))
|
||||
expect(decodeURIComponent(src.searchParams.get('path') || '')).toBe('/Users/ekko/Desktop/录屏2026-05-08 15.19.46.mov')
|
||||
expect(wrapper.find('.markdown-video-footer .att-name').text()).toBe('录屏2026-05-08 15.19.46.mov')
|
||||
})
|
||||
|
||||
it('renders MSYS-style Windows image paths through the download endpoint', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '',
|
||||
},
|
||||
})
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toContain('/api/hermes/download?path=')
|
||||
const src = new URL(img.attributes('src'))
|
||||
expect(decodeURIComponent(src.searchParams.get('path') || '')).toBe('/c/Users/Administrator/Desktop/screenshot.png')
|
||||
expect(img.attributes('alt')).toBe('桌面截图')
|
||||
})
|
||||
|
||||
it('downloads local text files when the file card download icon is clicked', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[notes.txt](/tmp/notes.txt)',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-file-card').exists()).toBe(true)
|
||||
expect(wrapper.find('.att-download-btn .att-download-icon').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.att-download-btn').trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(downloadApiMock.downloadFile).toHaveBeenCalledTimes(1)
|
||||
expect(downloadApiMock.downloadFile).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
|
||||
expect(downloadApiMock.fetchFileText).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens text previews in a responsive drawer with a close control', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[notes.txt](/tmp/notes.txt)',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.markdown-file-card').trigger('click')
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
|
||||
const drawer = wrapper.find('.n-drawer-stub')
|
||||
expect(drawer.exists()).toBe(true)
|
||||
expect(drawer.attributes('data-width')).toBe('min(800px, 100vw)')
|
||||
expect(drawer.find('.n-drawer-content-stub').attributes('data-body-padding')).toBe('0')
|
||||
expect(drawer.text()).toContain('download.contentDisplay')
|
||||
expect(downloadApiMock.fetchFileText).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
|
||||
|
||||
await drawer.find('.n-drawer-close-stub').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders markdown file previews as markdown content', async () => {
|
||||
downloadApiMock.fetchFileText.mockResolvedValue('# Preview Title\n\n**bold text**')
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[notes.md](/tmp/notes.md)',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.markdown-file-card').trigger('click')
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
|
||||
const drawer = wrapper.find('.n-drawer-stub')
|
||||
expect(drawer.exists()).toBe(true)
|
||||
expect(drawer.find('.text-preview-markdown').exists()).toBe(true)
|
||||
expect(drawer.find('.text-preview-body').exists()).toBe(false)
|
||||
expect(drawer.find('.text-preview-markdown h1').text()).toBe('Preview Title')
|
||||
expect(drawer.find('.text-preview-markdown strong').text()).toBe('bold text')
|
||||
})
|
||||
|
||||
it('keeps tilde-fenced markdown examples with nested tilde fences intact', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```md',
|
||||
'## Tilde Example',
|
||||
'',
|
||||
'~~~md',
|
||||
'~~~yaml',
|
||||
'ok: true',
|
||||
'~~~',
|
||||
'~~~',
|
||||
'',
|
||||
'Done outside.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Tilde Example')
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
|
||||
expect(wrapper.find('code.hljs').text()).toContain('~~~yaml\nok: true\n~~~')
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
|
||||
})
|
||||
|
||||
it('keeps already-valid longer markdown example fences valid', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```md',
|
||||
'## Longer Fence Example',
|
||||
'',
|
||||
'````md',
|
||||
'```ts',
|
||||
'const answer = 42',
|
||||
'```',
|
||||
'````',
|
||||
'',
|
||||
'Done outside.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Longer Fence Example')
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(1)
|
||||
expect(wrapper.find('code.hljs').text()).toContain('```ts\nconst answer = 42\n```')
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
|
||||
})
|
||||
|
||||
it('renders mermaid fences as diagrams instead of raw highlighted code', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
'A[User] --> B[Web UI<br/>command]',
|
||||
'```',
|
||||
'',
|
||||
'具体 behavior:',
|
||||
'- Markdown below still renders.',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
}))
|
||||
expect(mermaidMock.render).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^hermes-mermaid-/),
|
||||
expect.stringContaining('flowchart TD'),
|
||||
)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(0)
|
||||
expect(wrapper.find('.markdown-body').find('ul').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders mermaid inside repaired outer markdown draft fences', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```md',
|
||||
'## Command flow',
|
||||
'',
|
||||
'```Mermaid title',
|
||||
'flowchart LR',
|
||||
'A --> B',
|
||||
'```',
|
||||
'',
|
||||
'Done outside.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Command flow')
|
||||
expect(mermaidMock.render).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^hermes-mermaid-/),
|
||||
expect.stringContaining('flowchart LR'),
|
||||
)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
|
||||
})
|
||||
|
||||
it('falls back to a copyable code block when mermaid rendering fails', async () => {
|
||||
mermaidMock.render.mockImplementationOnce((id: string) => {
|
||||
const errorContainer = document.createElement('div')
|
||||
errorContainer.id = `d${id}`
|
||||
errorContainer.textContent = 'Syntax error in text\nmermaid version 11.14.0'
|
||||
document.body.appendChild(errorContainer)
|
||||
return Promise.reject(new Error('bad diagram'))
|
||||
})
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nnot valid mermaid\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('not valid mermaid')
|
||||
expect(wrapper.find('[data-copy-code="true"]').exists()).toBe(true)
|
||||
expect(document.body.textContent).not.toContain('Syntax error in text')
|
||||
})
|
||||
|
||||
it('falls back to copyable code blocks when mermaid initialization fails', async () => {
|
||||
mermaidMock.initialize.mockImplementationOnce(() => {
|
||||
throw new Error('init failed')
|
||||
})
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
|
||||
})
|
||||
|
||||
it('falls back without initializing mermaid when every pending diagram is oversized', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: `\`\`\`mermaid\n${'A'.repeat(20_001)}\n\`\`\``,
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).not.toHaveBeenCalled()
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
})
|
||||
|
||||
it('falls back without initializing mermaid when every pending diagram is empty', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).not.toHaveBeenCalled()
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
})
|
||||
|
||||
it('falls back to copyable code when mermaid rendering never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
mermaidMock.render.mockImplementationOnce(() => new Promise(() => {}))
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
|
||||
})
|
||||
|
||||
it('does not load or render mermaid when the message has no mermaid block', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```ts\nconst answer = 42\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).not.toHaveBeenCalled()
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.code-lang').text()).toBe('ts')
|
||||
})
|
||||
|
||||
it('does not let stale async mermaid renders mutate newer message content', async () => {
|
||||
let resolveRender: ((value: { svg: string }) => void) | undefined
|
||||
mermaidMock.render.mockImplementationOnce((id: string) => new Promise(resolve => {
|
||||
resolveRender = resolve
|
||||
}))
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await wrapper.setProps({ content: 'No diagram now.' })
|
||||
resolveRender?.({ svg: '<svg data-testid="stale-mermaid-svg"></svg>' })
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('[data-testid="stale-mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.')
|
||||
})
|
||||
|
||||
it('renders inline latex math with katex', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: 'Pythagoras: $x^2 + y^2 = z^2$.',
|
||||
},
|
||||
})
|
||||
|
||||
const body = wrapper.find('.markdown-body')
|
||||
expect(body.find('.katex').exists()).toBe(true)
|
||||
expect(body.html()).toContain('x')
|
||||
expect(body.html()).toContain('z')
|
||||
expect(body.text()).not.toContain('$x^2 + y^2 = z^2$')
|
||||
})
|
||||
|
||||
it('renders display latex math with katex', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$',
|
||||
},
|
||||
})
|
||||
|
||||
const body = wrapper.find('.markdown-body')
|
||||
expect(body.find('.katex-display').exists()).toBe(true)
|
||||
expect(body.find('.katex').exists()).toBe(true)
|
||||
expect(body.text()).not.toContain('$$')
|
||||
})
|
||||
|
||||
it('renders explicit latex fenced blocks with katex', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```latex\n\\[\\text{Итог} = \\operatorname{Округление}\\!\\left(0.5\\,O_1 + 0.5\\,O_2\\right)\\]\n```',
|
||||
},
|
||||
})
|
||||
|
||||
const body = wrapper.find('.markdown-body')
|
||||
expect(body.find('.katex-display').exists()).toBe(true)
|
||||
expect(body.find('.katex').exists()).toBe(true)
|
||||
expect(body.text()).not.toContain('```latex')
|
||||
expect(body.text()).toContain('Округление')
|
||||
})
|
||||
|
||||
it('does not render latex inside ordinary fenced code blocks', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```ts\nconst formula = "$x^2 + y^2 = z^2$"\n```',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-body').find('.katex').exists()).toBe(false)
|
||||
expect(wrapper.find('code.hljs').text()).toContain('$x^2 + y^2 = z^2$')
|
||||
})
|
||||
|
||||
it('does not treat currency-like dollar text as latex math', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: 'Price is $5 and $6 today.',
|
||||
},
|
||||
})
|
||||
|
||||
const body = wrapper.find('.markdown-body')
|
||||
expect(body.find('.katex').exists()).toBe(false)
|
||||
expect(body.text()).toContain('Price is $5 and $6 today.')
|
||||
})
|
||||
|
||||
it('does not render escaped dollar-delimited text as latex math', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: 'Escaped: \\$x^2$',
|
||||
},
|
||||
})
|
||||
|
||||
const body = wrapper.find('.markdown-body')
|
||||
expect(body.find('.katex').exists()).toBe(false)
|
||||
expect(body.text()).toContain('Escaped: $x^2$')
|
||||
})
|
||||
|
||||
it('keeps rendering when latex syntax is invalid', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: 'Before $\\notacommand{ after',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Before')
|
||||
})
|
||||
|
||||
it('copies code through the delegated click handler', async () => {
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```ts\nconst answer = 42\n```',
|
||||
},
|
||||
})
|
||||
|
||||
const expected = wrapper.find('code.hljs').element.textContent ?? ''
|
||||
await wrapper.find('[data-copy-code="true"]').trigger('click')
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('falls back to legacy clipboard copy when the Clipboard API is unavailable', async () => {
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const execCommand = vi.fn(() => true)
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
})
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```ts\nconst answer = 42\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('[data-copy-code="true"]').trigger('click')
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('naive-ui')>()
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/hermes/download', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
getDownloadUrl: vi.fn((path: string) => `/download?path=${encodeURIComponent(path)}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/mermaidRenderer', () => ({
|
||||
renderMermaidDiagram: vi.fn(),
|
||||
}))
|
||||
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
describe('MarkdownRenderer special mentions', () => {
|
||||
it('highlights @all as a mention when provided by group chat', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '@all, please compare options',
|
||||
mentionNames: ['all', 'Alice'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mention-highlight').text()).toBe('@all')
|
||||
})
|
||||
|
||||
it('highlights @all at the end of rendered paragraphs and after opening punctuation', () => {
|
||||
for (const content of ['@all', 'please compare @all', '(@all)']) {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content,
|
||||
mentionNames: ['all', 'Alice'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mention-highlight').text()).toBe('@all')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not highlight @alligator as @all', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '@alligator should stay plain',
|
||||
mentionNames: ['all'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.mention-highlight').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import MessageItem from '@/components/hermes/chat/MessageItem.vue'
|
||||
import type { Message } from '@/stores/hermes/chat'
|
||||
|
||||
describe('MessageItem tool details', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
})
|
||||
Object.defineProperty(window, 'speechSynthesis', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getVoices: vi.fn(() => []),
|
||||
speak: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders highlighted code blocks for tool arguments and tool results', async () => {
|
||||
const wrapper = mount(MessageItem, {
|
||||
props: {
|
||||
message: {
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'web_search',
|
||||
toolArgs: '{"query":"syntax highlighting"}',
|
||||
toolResult: '{"results":[{"title":"Done"}]}',
|
||||
toolStatus: 'done',
|
||||
} satisfies Message,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.tool-line').trigger('click')
|
||||
|
||||
const blocks = wrapper.findAll('.tool-details .hljs-code-block')
|
||||
expect(blocks).toHaveLength(2)
|
||||
expect(blocks[0].find('.code-lang').text()).toBe('json')
|
||||
expect(blocks[1].find('.code-lang').text()).toBe('json')
|
||||
})
|
||||
|
||||
it('copies tool detail code through the delegated click handler', async () => {
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
const wrapper = mount(MessageItem, {
|
||||
props: {
|
||||
message: {
|
||||
id: 'tool-copy',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'web_search',
|
||||
toolArgs: '{"query":"syntax highlighting"}',
|
||||
toolStatus: 'done',
|
||||
} satisfies Message,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.tool-line').trigger('click')
|
||||
|
||||
const expected = wrapper.find('.tool-details code.hljs').text()
|
||||
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('truncates large tool arguments for display but copies the full formatted payload', async () => {
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
const message = {
|
||||
content: 'x'.repeat(4000),
|
||||
ok: true,
|
||||
}
|
||||
const wrapper = mount(MessageItem, {
|
||||
props: {
|
||||
message: {
|
||||
id: 'tool-args-large',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'write_file',
|
||||
toolArgs: JSON.stringify(message),
|
||||
toolStatus: 'done',
|
||||
} satisfies Message,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.tool-line').trigger('click')
|
||||
|
||||
const expected = JSON.stringify(message, null, 2)
|
||||
const code = wrapper.find('.tool-details code.hljs')
|
||||
const displayed = JSON.parse(code.text())
|
||||
expect(wrapper.find('.tool-details .code-lang').text()).toBe('json')
|
||||
expect(wrapper.html()).toContain('chat.truncated')
|
||||
expect(displayed.content).toContain('chat.truncated')
|
||||
expect(code.findAll('span').length).toBeGreaterThan(0)
|
||||
|
||||
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||
expect(writeText).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('copies the full large JSON tool result even when the display is truncated', async () => {
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
const fullResult = {
|
||||
content: 'x'.repeat(4000),
|
||||
ok: true,
|
||||
}
|
||||
const wrapper = mount(MessageItem, {
|
||||
props: {
|
||||
message: {
|
||||
id: 'tool-2',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'read_file',
|
||||
toolResult: JSON.stringify(fullResult),
|
||||
toolStatus: 'done',
|
||||
} satisfies Message,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.tool-line').trigger('click')
|
||||
|
||||
const code = wrapper.find('.tool-details code.hljs')
|
||||
const displayed = JSON.parse(code.text())
|
||||
expect(wrapper.find('.tool-details .code-lang').text()).toBe('json')
|
||||
expect(wrapper.html()).toContain('chat.truncated')
|
||||
expect(displayed.content).toContain('chat.truncated')
|
||||
expect(code.findAll('span').length).toBeGreaterThan(0)
|
||||
|
||||
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||
expect(writeText).toHaveBeenCalledWith(JSON.stringify(fullResult, null, 2))
|
||||
})
|
||||
|
||||
it('truncates large JSON arrays at item boundaries so display remains parseable JSON', async () => {
|
||||
const fullResult = Array.from({ length: 100 }, (_, index) => ({
|
||||
index,
|
||||
value: `item-${index}-${'x'.repeat(80)}`,
|
||||
}))
|
||||
const wrapper = mount(MessageItem, {
|
||||
props: {
|
||||
message: {
|
||||
id: 'tool-array',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'browser_snapshot',
|
||||
toolResult: JSON.stringify(fullResult),
|
||||
toolStatus: 'done',
|
||||
} satisfies Message,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.tool-line').trigger('click')
|
||||
|
||||
const code = wrapper.find('.tool-details code.hljs')
|
||||
const displayed = JSON.parse(code.text())
|
||||
expect(Array.isArray(displayed)).toBe(true)
|
||||
expect(displayed.at(-1)).toContain('chat.truncated')
|
||||
expect(code.text().length).toBeLessThanOrEqual(1000)
|
||||
})
|
||||
|
||||
it('copies the full large raw tool result even when the display is truncated', async () => {
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
const fullResult = 'line\n'.repeat(1200)
|
||||
const wrapper = mount(MessageItem, {
|
||||
props: {
|
||||
message: {
|
||||
id: 'tool-raw',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'read_file',
|
||||
toolResult: fullResult,
|
||||
toolStatus: 'done',
|
||||
} satisfies Message,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.tool-line').trigger('click')
|
||||
|
||||
const displayedResult = fullResult.slice(0, 1000) + '\nchat.truncated'
|
||||
const code = wrapper.find('.tool-details code.hljs')
|
||||
expect(wrapper.find('.tool-details .code-lang').text()).toBe('text')
|
||||
expect(code.text()).toBe(displayedResult)
|
||||
expect(code.findAll('span')).toHaveLength(0)
|
||||
|
||||
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||
expect(writeText).toHaveBeenCalledWith(fullResult)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
const mockScrollToBottom = vi.hoisted(() => vi.fn())
|
||||
const mockScrollToMessage = vi.hoisted(() => vi.fn())
|
||||
const mockScrollToAnchor = vi.hoisted(() => vi.fn())
|
||||
const mockCaptureViewportPosition = vi.hoisted(() => vi.fn())
|
||||
const mockRestoreViewportPosition = vi.hoisted(() => vi.fn())
|
||||
const mockCaptureScrollPosition = vi.hoisted(() => vi.fn())
|
||||
const mockRestoreScrollPosition = vi.hoisted(() => vi.fn())
|
||||
const mockIsNearBottom = vi.hoisted(() => vi.fn(() => true))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTheme', () => ({
|
||||
useTheme: () => ({ isDark: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/VirtualMessageList.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'VirtualMessageList',
|
||||
props: {
|
||||
messages: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['top-reach'],
|
||||
setup(_props, { expose }) {
|
||||
expose({
|
||||
isNearBottom: mockIsNearBottom,
|
||||
scrollToBottom: mockScrollToBottom,
|
||||
scrollToMessage: mockScrollToMessage,
|
||||
scrollToAnchor: mockScrollToAnchor,
|
||||
captureScrollPosition: mockCaptureScrollPosition,
|
||||
restoreScrollPosition: mockRestoreScrollPosition,
|
||||
captureViewportPosition: mockCaptureViewportPosition,
|
||||
restoreViewportPosition: mockRestoreViewportPosition,
|
||||
})
|
||||
},
|
||||
template: `
|
||||
<div class="virtual-message-list-stub">
|
||||
<slot name="item" v-for="message in messages" :key="message.id" :message="message" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/MessageItem.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MessageItem',
|
||||
props: { message: { type: Object, required: true } },
|
||||
template: '<div class="stub-message" :data-id="message.id">{{ message.content }}</div>',
|
||||
}),
|
||||
}))
|
||||
|
||||
import MessageList from '@/components/hermes/chat/MessageList.vue'
|
||||
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
|
||||
|
||||
function makeMessage(id: string): Message {
|
||||
return { id, role: 'user', content: id, timestamp: Date.now() }
|
||||
}
|
||||
|
||||
function makeSession(id: string): Session {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
messages: [makeMessage(`${id}-message`)],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
async function flushSessionScroll() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('MessageList session scroll position', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockIsNearBottom.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('restores a previous session scroll position instead of forcing the bottom', async () => {
|
||||
const chatStore = useChatStore()
|
||||
chatStore.activeSessionId = 'scroll-session-a'
|
||||
chatStore.activeSession = makeSession('scroll-session-a')
|
||||
|
||||
mount(MessageList, {
|
||||
global: {
|
||||
stubs: { Transition: false },
|
||||
},
|
||||
})
|
||||
await flushSessionScroll()
|
||||
vi.clearAllMocks()
|
||||
|
||||
const sessionASnapshot = {
|
||||
scrollTop: 320,
|
||||
scrollHeight: 1200,
|
||||
clientHeight: 500,
|
||||
wasNearBottom: false,
|
||||
}
|
||||
mockCaptureViewportPosition.mockReturnValue(sessionASnapshot)
|
||||
|
||||
chatStore.activeSessionId = 'scroll-session-b'
|
||||
chatStore.activeSession = makeSession('scroll-session-b')
|
||||
await flushSessionScroll()
|
||||
expect(mockCaptureViewportPosition).toHaveBeenCalled()
|
||||
|
||||
vi.clearAllMocks()
|
||||
mockCaptureViewportPosition.mockReturnValue({
|
||||
scrollTop: 40,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 500,
|
||||
wasNearBottom: false,
|
||||
})
|
||||
|
||||
chatStore.activeSessionId = 'scroll-session-a'
|
||||
chatStore.activeSession = makeSession('scroll-session-a')
|
||||
await flushSessionScroll()
|
||||
|
||||
expect(mockRestoreViewportPosition).toHaveBeenCalledWith(sessionASnapshot)
|
||||
expect(mockScrollToBottom).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockSystemApi = vi.hoisted(() => ({
|
||||
fetchAvailableModels: vi.fn(),
|
||||
fetchAvailableModelsForProfile: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
addCustomProvider: vi.fn(),
|
||||
removeCustomProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/system', () => mockSystemApi)
|
||||
vi.mock('@/api/client', () => ({ hasApiKey: () => true }))
|
||||
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
|
||||
describe('Models Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps the sidebar model picker in sync after provider model visibility changes', async () => {
|
||||
const visibleGroups = [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
model_meta: {
|
||||
'deepseek-v4-pro': { preview: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const availableModelsResponse = {
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: visibleGroups,
|
||||
allProviders: visibleGroups,
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
profile: 'default',
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: visibleGroups,
|
||||
},
|
||||
],
|
||||
}
|
||||
mockSystemApi.fetchAvailableModelsForProfile.mockResolvedValue(availableModelsResponse)
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue(availableModelsResponse)
|
||||
mockSystemApi.addCustomProvider.mockResolvedValue(undefined)
|
||||
|
||||
const appStore = useAppStore()
|
||||
appStore.modelGroups = [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-v4-flash'],
|
||||
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
},
|
||||
]
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
await modelsStore.addProvider({
|
||||
name: 'deepseek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
model: 'deepseek-v4-flash',
|
||||
})
|
||||
|
||||
expect(mockSystemApi.fetchAvailableModelsForProfile).toHaveBeenCalledWith('default')
|
||||
expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalled()
|
||||
expect(modelsStore.providers[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(appStore.modelGroups[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(appStore.modelGroups[0].available_models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(appStore.modelGroups[0].model_meta).toEqual({
|
||||
'deepseek-v4-pro': { preview: true },
|
||||
})
|
||||
expect(appStore.modelVisibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
|
||||
})
|
||||
expect(appStore.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(appStore.selectedProvider).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
const mockProfilesApi = vi.hoisted(() => ({
|
||||
fetchProfiles: vi.fn(),
|
||||
fetchProfileDetail: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
switchProfile: vi.fn(),
|
||||
exportProfile: vi.fn(),
|
||||
importProfile: vi.fn(),
|
||||
updateProfileAvatar: vi.fn(),
|
||||
deleteProfileAvatar: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/profiles', () => mockProfilesApi)
|
||||
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
|
||||
describe('Profiles Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchProfiles loads profiles and sets active', async () => {
|
||||
const profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
]
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue(profiles)
|
||||
|
||||
const store = useProfilesStore()
|
||||
await store.fetchProfiles()
|
||||
|
||||
expect(store.profiles).toEqual(profiles)
|
||||
expect(store.activeProfile?.name).toBe('default')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchProfiles sets loading state', async () => {
|
||||
mockProfilesApi.fetchProfiles.mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve([]), 10))
|
||||
)
|
||||
|
||||
const store = useProfilesStore()
|
||||
const fetchPromise = store.fetchProfiles()
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
await fetchPromise
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('createProfile calls API and refreshes list', async () => {
|
||||
mockProfilesApi.createProfile.mockResolvedValue({ success: true })
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'new-profile', active: false, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
const result = await store.createProfile('new-profile', false)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockProfilesApi.createProfile).toHaveBeenCalledWith('new-profile', false)
|
||||
expect(store.profiles).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deleteProfile clears detail cache', async () => {
|
||||
mockProfilesApi.deleteProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
await store.deleteProfile('test')
|
||||
|
||||
expect(store.detailMap['test']).toBeUndefined()
|
||||
expect(mockProfilesApi.deleteProfile).toHaveBeenCalledWith('test')
|
||||
})
|
||||
|
||||
it('fetchProfileDetail uses cache', async () => {
|
||||
const detail = { name: 'cached', path: '/tmp/cached', model: 'gpt-4', provider: 'openai', skills: 5, hasEnv: true, hasSoulMd: false }
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['cached'] = detail
|
||||
|
||||
const result = await store.fetchProfileDetail('cached')
|
||||
|
||||
expect(result).toEqual(detail)
|
||||
expect(mockProfilesApi.fetchProfileDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateAvatar updates profile, detail cache, and active profile', async () => {
|
||||
const savedAvatar = { type: 'image', dataUrl: 'data:image/png;base64,YQ==' }
|
||||
mockProfilesApi.updateProfileAvatar.mockResolvedValue(savedAvatar)
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
]
|
||||
store.activeProfile = store.profiles[0]
|
||||
store.detailMap.default = { name: 'default', path: '/tmp/default', model: '', provider: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
const result = await store.updateAvatar('default', { type: 'image', dataUrl: savedAvatar.dataUrl })
|
||||
|
||||
expect(result).toEqual(savedAvatar)
|
||||
expect(mockProfilesApi.updateProfileAvatar).toHaveBeenCalledWith('default', { type: 'image', dataUrl: savedAvatar.dataUrl })
|
||||
expect(store.profiles[0].avatar).toEqual(savedAvatar)
|
||||
expect(store.activeProfile?.avatar).toEqual(savedAvatar)
|
||||
expect(store.detailMap.default.avatar).toEqual(savedAvatar)
|
||||
})
|
||||
|
||||
it('deleteAvatar clears avatar state', async () => {
|
||||
mockProfilesApi.deleteProfileAvatar.mockResolvedValue(undefined)
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '', avatar: { type: 'generated', seed: 'old' } },
|
||||
]
|
||||
store.activeProfile = store.profiles[0]
|
||||
store.detailMap.default = {
|
||||
name: 'default',
|
||||
path: '/tmp/default',
|
||||
model: '',
|
||||
provider: '',
|
||||
skills: 0,
|
||||
hasEnv: false,
|
||||
hasSoulMd: false,
|
||||
avatar: { type: 'generated', seed: 'old' },
|
||||
}
|
||||
|
||||
await store.deleteAvatar('default')
|
||||
|
||||
expect(mockProfilesApi.deleteProfileAvatar).toHaveBeenCalledWith('default')
|
||||
expect(store.profiles[0].avatar).toBeNull()
|
||||
expect(store.activeProfile?.avatar).toBeNull()
|
||||
expect(store.detailMap.default.avatar).toBeNull()
|
||||
})
|
||||
|
||||
it('switchProfile sets switching state', async () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([])
|
||||
|
||||
const store = useProfilesStore()
|
||||
const switchPromise = store.switchProfile('dev')
|
||||
|
||||
expect(store.switching).toBe(true)
|
||||
await switchPromise
|
||||
expect(store.switching).toBe(false)
|
||||
})
|
||||
|
||||
it('switchProfile updates activeProfileName immediately', async () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: false, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: true, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
await store.switchProfile('dev')
|
||||
|
||||
// activeProfileName should be updated immediately
|
||||
expect(store.activeProfileName).toBe('dev')
|
||||
// localStorage should also be updated
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
|
||||
it('switchProfile does not update state when API fails', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(false) // API failed
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
// Should return false
|
||||
expect(result).toBe(false)
|
||||
// activeProfileName should NOT change
|
||||
expect(store.activeProfileName).toBe(initialName)
|
||||
// localStorage should NOT change
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe(initialName)
|
||||
})
|
||||
|
||||
it('switchProfile keeps activeProfileName even if fetchProfiles fails', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
// Should return true (API succeeded)
|
||||
expect(result).toBe(true)
|
||||
// activeProfileName should be updated even though fetchProfiles failed
|
||||
expect(store.activeProfileName).toBe('dev')
|
||||
// localStorage should be updated
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
|
||||
it('switchProfile keeps the local selected profile independent of backend active flags', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.activeProfileName).toBe('dev')
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCustomProviderBaseUrl } from '@/utils/providerBaseUrl'
|
||||
|
||||
describe('normalizeCustomProviderBaseUrl', () => {
|
||||
it('normalizes api.apikey.fun custom provider URLs to the OpenAI-compatible v1 endpoint', () => {
|
||||
expect(normalizeCustomProviderBaseUrl('https://api.apikey.fun')).toBe('https://api.apikey.fun/v1')
|
||||
expect(normalizeCustomProviderBaseUrl('https://api.apikey.fun/')).toBe('https://api.apikey.fun/v1')
|
||||
expect(normalizeCustomProviderBaseUrl('https://api.apikey.fun/anything')).toBe('https://api.apikey.fun/v1')
|
||||
expect(normalizeCustomProviderBaseUrl(' https://api.apikey.fun/v2/chat ')).toBe('https://api.apikey.fun/v1')
|
||||
})
|
||||
|
||||
it('leaves unrelated provider URLs unchanged apart from trimming', () => {
|
||||
expect(normalizeCustomProviderBaseUrl(' https://api.example.com/v1 ')).toBe('https://api.example.com/v1')
|
||||
expect(normalizeCustomProviderBaseUrl('https://not-api.apikey.fun/v1')).toBe('https://not-api.apikey.fun/v1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import RouteLinkItem from '@/components/common/RouteLinkItem.vue'
|
||||
|
||||
describe('RouteLinkItem', () => {
|
||||
it('renders a real anchor with href from RouterLink custom slot', () => {
|
||||
const wrapper = mount(RouteLinkItem, {
|
||||
props: {
|
||||
to: { name: 'hermes.session', params: { id: 's1' } },
|
||||
active: true,
|
||||
},
|
||||
slots: {
|
||||
default: 'Session S1',
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
RouterLink: defineComponent({
|
||||
props: ['to', 'custom'],
|
||||
template: '<slot href="/session/s1" :navigate="() => {}" :is-active="true" :is-exact-active="true" />',
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const link = wrapper.get('a')
|
||||
expect(link.attributes('href')).toBe('/session/s1')
|
||||
expect(link.classes()).toContain('route-link-item')
|
||||
expect(link.classes()).toContain('active')
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
expect(link.text()).toContain('Session S1')
|
||||
})
|
||||
})
|
||||
@@ -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,138 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
|
||||
|
||||
vi.mock('@/stores/hermes/app', () => ({
|
||||
useAppStore: () => ({
|
||||
profileModelGroups: [],
|
||||
displayModelName: (model: string) => model,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/profiles', () => ({
|
||||
useProfilesStore: () => ({ profiles: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/session-display', () => ({
|
||||
formatTimestampMs: () => 'now',
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NPopconfirm: defineComponent({
|
||||
name: 'NPopconfirm',
|
||||
emits: ['positive-click'],
|
||||
template: '<span><slot name="trigger" /><slot /></span>',
|
||||
}),
|
||||
NCheckbox: defineComponent({
|
||||
name: 'NCheckbox',
|
||||
props: ['checked'],
|
||||
emits: ['click'],
|
||||
template: '<input type="checkbox" :checked="checked" @click="$emit(\'click\')" />',
|
||||
}),
|
||||
NTooltip: defineComponent({
|
||||
name: 'NTooltip',
|
||||
template: '<span><slot name="trigger" /><slot /></span>',
|
||||
}),
|
||||
}))
|
||||
|
||||
const session = {
|
||||
id: 's1',
|
||||
title: 'Session One',
|
||||
model: 'gpt-test',
|
||||
provider: 'openai',
|
||||
createdAt: Date.now(),
|
||||
profile: 'kira',
|
||||
}
|
||||
|
||||
describe('SessionListItem', () => {
|
||||
it('renders normal mode as a link to the session route', () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const link = wrapper.get('a.session-item')
|
||||
expect(link.attributes('href')).toBe('/session/s1')
|
||||
expect(wrapper.find('button.session-item').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders selectable mode as a button and does not expose row href', () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
selectable: true,
|
||||
selected: false,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('button.session-item').exists()).toBe(true)
|
||||
expect(wrapper.find('a.session-item').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not select the row when clicking nested action controls', async () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('button.session-item-delete').trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not hijack modified clicks on normal links', async () => {
|
||||
const wrapper = mount(SessionListItem, {
|
||||
props: {
|
||||
session,
|
||||
active: false,
|
||||
pinned: false,
|
||||
canDelete: true,
|
||||
to: '/session/s1',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileAvatar: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const link = wrapper.get('a.session-item')
|
||||
link.element.addEventListener('click', event => event.preventDefault())
|
||||
await link.trigger('click', { ctrlKey: true })
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment jsdom
|
||||
import { nextTick, defineComponent, h } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchSessionsMock: vi.fn(),
|
||||
searchSessionsMock: vi.fn(),
|
||||
routerPushMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
fetchSessions: apiMocks.fetchSessionsMock,
|
||||
searchSessions: apiMocks.searchSessionsMock,
|
||||
}))
|
||||
|
||||
const chatStoreMock = vi.hoisted(() => ({
|
||||
sessions: [] as Array<Record<string, any>>,
|
||||
loadSessions: vi.fn(),
|
||||
switchSession: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/chat', () => ({
|
||||
useChatStore: () => chatStoreMock,
|
||||
}))
|
||||
|
||||
const routerCurrentRoute = { value: { name: 'hermes.logs' } }
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
currentRoute: routerCurrentRoute,
|
||||
push: apiMocks.routerPushMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
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: () => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
NModal: {
|
||||
props: ['show'],
|
||||
emits: ['update:show'],
|
||||
template: '<div v-if="show" class="n-modal-stub"><slot /></div>',
|
||||
},
|
||||
NInput: {
|
||||
props: ['value', 'size'],
|
||||
emits: ['update:value', 'keydown'],
|
||||
template: '<input class="n-input-stub" :value="value" @input="$emit(\'update:value\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
|
||||
},
|
||||
NSpin: {
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
},
|
||||
NButton: {
|
||||
template: '<button class="n-button-stub"><slot /></button>',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
||||
import { useSessionSearch } from '@/composables/useSessionSearch'
|
||||
import { useKeyboard } from '@/composables/useKeyboard'
|
||||
|
||||
function flushPromises() {
|
||||
return Promise.resolve().then(() => Promise.resolve())
|
||||
}
|
||||
|
||||
describe('session search modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
chatStoreMock.sessions = []
|
||||
chatStoreMock.loadSessions.mockResolvedValue(undefined)
|
||||
chatStoreMock.switchSession.mockResolvedValue(undefined)
|
||||
apiMocks.fetchSessionsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'recent-1',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Recent Docker fix',
|
||||
preview: 'recent preview',
|
||||
started_at: 1710000000,
|
||||
ended_at: 1710000001,
|
||||
last_active: 1710000002,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openrouter',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
},
|
||||
])
|
||||
apiMocks.searchSessionsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'match-1',
|
||||
source: 'telegram',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Debugging session',
|
||||
preview: 'search preview',
|
||||
started_at: 1710001000,
|
||||
ended_at: null,
|
||||
last_active: 1710001005,
|
||||
message_count: 4,
|
||||
tool_call_count: 1,
|
||||
input_tokens: 3,
|
||||
output_tokens: 4,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openrouter',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
matched_message_id: 17,
|
||||
snippet: 'docker compose up',
|
||||
rank: 0.1,
|
||||
},
|
||||
])
|
||||
routerCurrentRoute.value = { name: 'hermes.logs' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens from Cmd/Ctrl+K and loads recent sessions', async () => {
|
||||
const { openSessionSearch, sessionSearchOpen } = useSessionSearch()
|
||||
const wrapper = mount(SessionSearchModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
NModal: false,
|
||||
NInput: false,
|
||||
NSpin: false,
|
||||
NButton: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
openSessionSearch()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(sessionSearchOpen.value).toBe(true)
|
||||
expect(apiMocks.fetchSessionsMock).toHaveBeenCalledWith(undefined, 8)
|
||||
expect(wrapper.text()).toContain('Recent Docker fix')
|
||||
})
|
||||
|
||||
it('searches by content and opens the matched session', async () => {
|
||||
const { openSessionSearch } = useSessionSearch()
|
||||
const wrapper = mount(SessionSearchModal)
|
||||
|
||||
openSessionSearch()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const input = wrapper.find('input.n-input-stub')
|
||||
await input.setValue('docker')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(apiMocks.searchSessionsMock).toHaveBeenCalledWith('docker', undefined, 10)
|
||||
expect(wrapper.text()).toContain('Debugging session')
|
||||
|
||||
await wrapper.find('button.result-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(chatStoreMock.loadSessions).toHaveBeenCalled()
|
||||
expect(chatStoreMock.switchSession).toHaveBeenCalledWith('match-1', '17')
|
||||
expect(apiMocks.routerPushMock).toHaveBeenCalledWith({ name: 'hermes.chat' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyboard shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const { closeSessionSearch } = useSessionSearch()
|
||||
closeSessionSearch()
|
||||
chatStoreMock.newChat.mockReset()
|
||||
})
|
||||
|
||||
it('opens session search on Cmd/Ctrl+K', async () => {
|
||||
const Dummy = defineComponent({
|
||||
setup() {
|
||||
useKeyboard()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
mount(Dummy)
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(useSessionSearch().sessionSearchOpen.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// @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 },
|
||||
approvals: { mode: 'manual' },
|
||||
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 () => {
|
||||
let emittedValue: boolean | undefined
|
||||
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,
|
||||
NSwitch: {
|
||||
props: ['value'],
|
||||
emits: ['update:value'],
|
||||
template: '<div class="n-switch" @click="$emit(\'update:value\', !value)"></div>',
|
||||
setup(props: any, { emit }: any) {
|
||||
return {
|
||||
onClick: () => {
|
||||
emittedValue = !props.value
|
||||
emit('update:value', emittedValue)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('settings.session.liveMonitorHumanOnly')
|
||||
|
||||
const toggles = wrapper.findAll('.n-switch')
|
||||
expect(toggles.length).toBe(2)
|
||||
const humanOnlyToggle = toggles[1]
|
||||
|
||||
await humanOnlyToggle.trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockPrefsStore.setHumanOnly).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const openSessionSearchMock = vi.hoisted(() => vi.fn())
|
||||
const mockAppStore = vi.hoisted(() => ({
|
||||
sidebarOpen: true,
|
||||
sidebarCollapsed: false,
|
||||
connected: true,
|
||||
serverVersion: 'test',
|
||||
latestVersion: '',
|
||||
updateAvailable: false,
|
||||
clientOutdated: false,
|
||||
updating: false,
|
||||
toggleSidebar: vi.fn(),
|
||||
toggleSidebarCollapsed: vi.fn(),
|
||||
closeSidebar: vi.fn(),
|
||||
doUpdate: vi.fn(),
|
||||
reloadClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSessionSearch', () => ({
|
||||
useSessionSearch: () => ({
|
||||
openSessionSearch: openSessionSearchMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/app', () => ({
|
||||
useAppStore: () => mockAppStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>()
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ name: 'hermes.chat' }),
|
||||
useRouter: () => ({ push: vi.fn(), hasRoute: () => true }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
createI18n: () => ({
|
||||
global: { locale: { value: 'en' }, setLocaleMessage: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTheme', () => ({
|
||||
useTheme: () => ({ isDark: false }),
|
||||
}))
|
||||
|
||||
vi.mock('/logo.png', () => ({
|
||||
default: 'logo.png',
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/ProfileSelector.vue', () => ({
|
||||
default: { name: 'ProfileSelector', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/ModelSelector.vue', () => ({
|
||||
default: { name: 'ModelSelector', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/LanguageSwitch.vue', () => ({
|
||||
default: { name: 'LanguageSwitch', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/ThemeSwitch.vue', () => ({
|
||||
default: { name: 'ThemeSwitch', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/RouteLinkItem.vue', () => ({
|
||||
default: {
|
||||
name: 'RouteLinkItem',
|
||||
props: ['to', 'active'],
|
||||
template: '<a class="route-link-item" :class="{ active }" href="#"><slot /></a>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
NButton: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>',
|
||||
},
|
||||
NSelect: {
|
||||
template: '<div />',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
|
||||
describe('AppSidebar search entry', () => {
|
||||
beforeEach(() => {
|
||||
openSessionSearchMock.mockClear()
|
||||
mockAppStore.serverVersion = 'test'
|
||||
mockAppStore.latestVersion = ''
|
||||
mockAppStore.updateAvailable = false
|
||||
mockAppStore.clientOutdated = false
|
||||
mockAppStore.updating = false
|
||||
mockAppStore.sidebarCollapsed = false
|
||||
mockAppStore.reloadClient.mockClear()
|
||||
})
|
||||
|
||||
it('opens the session search modal from the sidebar button', async () => {
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileSelector: true,
|
||||
ModelSelector: true,
|
||||
LanguageSwitch: true,
|
||||
ThemeSwitch: true,
|
||||
NButton: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const searchButton = buttons.find(node => node.text().includes('sidebar.search'))
|
||||
expect(searchButton).toBeTruthy()
|
||||
|
||||
await searchButton!.trigger('click')
|
||||
expect(openSessionSearchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers a client reload when the server version differs from the loaded bundle', async () => {
|
||||
mockAppStore.clientOutdated = true
|
||||
mockAppStore.serverVersion = '0.5.17'
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileSelector: true,
|
||||
ModelSelector: true,
|
||||
LanguageSwitch: true,
|
||||
ThemeSwitch: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const reloadButton = wrapper.findAll('button')
|
||||
.find(node => node.text().includes('sidebar.reloadClientVersion'))
|
||||
expect(reloadButton).toBeTruthy()
|
||||
|
||||
await reloadButton!.trigger('click')
|
||||
expect(mockAppStore.reloadClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses short group labels and keeps group folding active when collapsed', async () => {
|
||||
mockAppStore.sidebarCollapsed = true
|
||||
const wrapper = mount(AppSidebar, {
|
||||
global: {
|
||||
stubs: {
|
||||
ProfileSelector: true,
|
||||
ModelSelector: true,
|
||||
LanguageSwitch: true,
|
||||
ThemeSwitch: true,
|
||||
NButton: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.classes()).toContain('collapsed')
|
||||
expect(wrapper.findAll('.nav-group-label span').map(node => node.text())).toEqual([
|
||||
'sidebar.groupConversationShort',
|
||||
'sidebar.groupAgentShort',
|
||||
'sidebar.groupMonitoringShort',
|
||||
'sidebar.groupToolsShort',
|
||||
'sidebar.groupSystemShort',
|
||||
])
|
||||
|
||||
const agentGroup = wrapper.findAll('.nav-group')[1]
|
||||
expect(agentGroup.find('.nav-group-items').attributes('style')).toBeUndefined()
|
||||
|
||||
await agentGroup.find('.nav-group-label').trigger('click')
|
||||
expect(agentGroup.find('.nav-group-items').attributes('style')).toContain('display: none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import SkillList from '@/components/hermes/skills/SkillList.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/skills', () => ({
|
||||
toggleSkill: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NSwitch: defineComponent({
|
||||
name: 'NSwitch',
|
||||
props: ['value', 'loading'],
|
||||
emits: ['update:value', 'click'],
|
||||
template: '<button type="button" @click="$emit(\'click\')"></button>',
|
||||
}),
|
||||
useMessage: () => ({ error: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('SkillList', () => {
|
||||
it('supports filtering skills from external sources', () => {
|
||||
const wrapper = mount(SkillList, {
|
||||
props: {
|
||||
categories: [
|
||||
{
|
||||
name: 'tools',
|
||||
description: '',
|
||||
skills: [
|
||||
{ name: 'local-skill', description: 'Local skill', enabled: true, source: 'local' },
|
||||
{ name: 'external-skill', description: 'External skill', enabled: true, source: 'external' },
|
||||
],
|
||||
},
|
||||
],
|
||||
archived: [],
|
||||
selectedSkill: null,
|
||||
searchQuery: '',
|
||||
sourceFilter: 'external',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('external-skill')
|
||||
expect(wrapper.text()).not.toContain('local-skill')
|
||||
expect(wrapper.get('.source-dot').classes()).toContain('dot-external')
|
||||
expect(wrapper.get('.source-dot').attributes('title')).toBe('skills.source.external')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const fetchSkillUsageStatsMock = vi.hoisted(() => vi.fn())
|
||||
const mockProfilesStore = vi.hoisted(() => ({
|
||||
activeProfileName: 'default',
|
||||
profiles: [{ name: 'default' }],
|
||||
fetchProfiles: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/skills', () => ({
|
||||
fetchSkillUsageStats: fetchSkillUsageStatsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/profiles', () => ({
|
||||
useProfilesStore: () => mockProfilesStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'skillsUsage.periodLabel') return `${params?.days}d`
|
||||
if (key === 'skillsUsage.periodSummary') return `Last ${params?.days} days`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
NButton: {
|
||||
props: ['loading', 'type', 'size', 'quaternary', 'secondary'],
|
||||
inheritAttrs: false,
|
||||
template: '<button :data-type="type" :aria-pressed="$attrs[\'aria-pressed\']" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import SkillsUsageView from '@/views/hermes/SkillsUsageView.vue'
|
||||
|
||||
const sevenDayStats = {
|
||||
period_days: 7,
|
||||
summary: {
|
||||
total_skill_loads: 3,
|
||||
total_skill_edits: 1,
|
||||
total_skill_actions: 4,
|
||||
distinct_skills_used: 2,
|
||||
},
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-10',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
skills: [
|
||||
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2026-05-11',
|
||||
view_count: 2,
|
||||
manage_count: 1,
|
||||
total_count: 3,
|
||||
skills: [
|
||||
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 75, last_used_at: 1_700_000_000 },
|
||||
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 25, last_used_at: null },
|
||||
],
|
||||
}
|
||||
|
||||
describe('SkillsUsageView', () => {
|
||||
beforeEach(() => {
|
||||
fetchSkillUsageStatsMock.mockReset()
|
||||
fetchSkillUsageStatsMock.mockResolvedValue(sevenDayStats)
|
||||
mockProfilesStore.activeProfileName = 'default'
|
||||
mockProfilesStore.profiles = [{ name: 'default' }]
|
||||
mockProfilesStore.fetchProfiles.mockReset()
|
||||
})
|
||||
|
||||
it('loads rolling 7 day skill usage and renders statistics beside a skill-colored visual trend', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(7)
|
||||
expect(wrapper.text()).toContain('skillsUsage.title')
|
||||
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.skill-bar-col')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.skill-bar-segment[data-skill="hermes-agent"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.skill-bar-segment[data-skill="github-pr-workflow"]')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-testid="skills-usage-legend"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="skills-usage-stats"]').text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('hermes-agent')
|
||||
expect(wrapper.text()).toContain('github-pr-workflow')
|
||||
expect(wrapper.text()).toContain('75.0%')
|
||||
})
|
||||
|
||||
it('reloads the selected period when the period button changes', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
fetchSkillUsageStatsMock.mockClear()
|
||||
|
||||
const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d')
|
||||
expect(thirtyDayButton).toBeTruthy()
|
||||
|
||||
await thirtyDayButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(30)
|
||||
expect(thirtyDayButton!.attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('flips the chart tooltip away from the hovered side of the bars', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
const bars = wrapper.findAll('.skill-bar-col')
|
||||
expect(bars).toHaveLength(2)
|
||||
|
||||
await bars[1].trigger('mouseenter')
|
||||
expect(wrapper.find('.floating-tooltip.align-left').exists()).toBe(true)
|
||||
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-11')
|
||||
|
||||
await bars[0].trigger('mouseenter')
|
||||
expect(wrapper.find('.floating-tooltip.align-right').exists()).toBe(true)
|
||||
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-10')
|
||||
})
|
||||
|
||||
it('keeps stale data visible while refreshing an already loaded period', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
let resolveRefresh!: (value: unknown) => void
|
||||
fetchSkillUsageStatsMock.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveRefresh = resolve
|
||||
}))
|
||||
|
||||
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
|
||||
expect(refreshButton).toBeTruthy()
|
||||
|
||||
await refreshButton!.trigger('click')
|
||||
|
||||
expect(fetchSkillUsageStatsMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.usage-panel.is-refreshing').exists()).toBe(true)
|
||||
|
||||
resolveRefresh({
|
||||
period_days: 7,
|
||||
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
skills: [{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1 }],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('test-driven-development')
|
||||
})
|
||||
|
||||
it('does not let an older refresh overwrite newer stats for the same period', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
let resolveOlder!: (value: unknown) => void
|
||||
let resolveNewer!: (value: unknown) => void
|
||||
fetchSkillUsageStatsMock
|
||||
.mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve }))
|
||||
.mockReturnValueOnce(new Promise(resolve => { resolveNewer = resolve }))
|
||||
|
||||
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
|
||||
expect(refreshButton).toBeTruthy()
|
||||
|
||||
await refreshButton!.trigger('click')
|
||||
await refreshButton!.trigger('click')
|
||||
|
||||
resolveNewer({
|
||||
period_days: 7,
|
||||
summary: { total_skill_loads: 2, total_skill_edits: 0, total_skill_actions: 2, distinct_skills_used: 1 },
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-13',
|
||||
view_count: 2,
|
||||
manage_count: 0,
|
||||
total_count: 2,
|
||||
skills: [{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2 }],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2, percentage: 100, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('newer-skill')
|
||||
|
||||
resolveOlder({
|
||||
period_days: 7,
|
||||
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
skills: [{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1 }],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('newer-skill')
|
||||
expect(wrapper.text()).not.toContain('older-skill')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseThinking, countThinkingChars, detectThinkingBoundary } from '@/utils/thinking-parser'
|
||||
|
||||
describe('parseThinking', () => {
|
||||
it('splits a single closed <think> block from body', () => {
|
||||
const r = parseThinking('<think>inner</think>body', { streaming: false })
|
||||
expect(r.segments).toEqual(['inner'])
|
||||
expect(r.body).toBe('body')
|
||||
expect(r.pending).toBeNull()
|
||||
expect(r.hasThinking).toBe(true)
|
||||
})
|
||||
|
||||
it('collects multiple closed blocks in order', () => {
|
||||
const r = parseThinking('<think>a</think>mid<thinking>b</thinking>end', { streaming: false })
|
||||
expect(r.segments).toEqual(['a', 'b'])
|
||||
expect(r.body).toBe('midend')
|
||||
})
|
||||
|
||||
it('supports <thinking> and <reasoning> variants', () => {
|
||||
const r = parseThinking('<reasoning>r</reasoning>body', { streaming: false })
|
||||
expect(r.segments).toEqual(['r'])
|
||||
expect(r.body).toBe('body')
|
||||
})
|
||||
|
||||
it('is case-insensitive on tag names', () => {
|
||||
const r = parseThinking('<Think>x</Think><REASONING>y</REASONING>z', { streaming: false })
|
||||
expect(r.segments).toEqual(['x', 'y'])
|
||||
expect(r.body).toBe('z')
|
||||
})
|
||||
|
||||
it('returns hasThinking=false and body unchanged for plain text', () => {
|
||||
const r = parseThinking('hello world', { streaming: false })
|
||||
expect(r.hasThinking).toBe(false)
|
||||
expect(r.body).toBe('hello world')
|
||||
expect(r.segments).toEqual([])
|
||||
})
|
||||
|
||||
it('returns hasThinking=false for empty content', () => {
|
||||
const r = parseThinking('', { streaming: false })
|
||||
expect(r.hasThinking).toBe(false)
|
||||
expect(r.body).toBe('')
|
||||
})
|
||||
|
||||
it('treats trailing unclosed tag as pending when streaming', () => {
|
||||
const r = parseThinking('body<think>in-progress', { streaming: true })
|
||||
expect(r.pending).toBe('in-progress')
|
||||
expect(r.body).toBe('body')
|
||||
expect(r.segments).toEqual([])
|
||||
expect(r.hasThinking).toBe(true)
|
||||
})
|
||||
|
||||
it('degrades trailing unclosed tag to body when NOT streaming (terminal state)', () => {
|
||||
const r = parseThinking('body<think>orphan', { streaming: false })
|
||||
expect(r.pending).toBeNull()
|
||||
expect(r.body).toBe('body<think>orphan')
|
||||
expect(r.segments).toEqual([])
|
||||
expect(r.hasThinking).toBe(false)
|
||||
})
|
||||
|
||||
it('combines closed segments with trailing pending (streaming)', () => {
|
||||
const r = parseThinking('<think>done</think>mid<thinking>now', { streaming: true })
|
||||
expect(r.segments).toEqual(['done'])
|
||||
expect(r.pending).toBe('now')
|
||||
expect(r.body).toBe('mid')
|
||||
})
|
||||
|
||||
it('does NOT recognize <think> inside fenced code block', () => {
|
||||
const src = 'before\n```\n<think>fake</think>\n```\nafter'
|
||||
const r = parseThinking(src, { streaming: false })
|
||||
expect(r.hasThinking).toBe(false)
|
||||
expect(r.body).toBe(src)
|
||||
})
|
||||
|
||||
it('does NOT recognize <think> inside tilde-fenced code block', () => {
|
||||
const src = '~~~\n<think>fake</think>\n~~~'
|
||||
const r = parseThinking(src, { streaming: false })
|
||||
expect(r.hasThinking).toBe(false)
|
||||
expect(r.body).toBe(src)
|
||||
})
|
||||
|
||||
it('does NOT recognize <think> inside inline code', () => {
|
||||
const src = 'the tag `<think>x</think>` is a literal'
|
||||
const r = parseThinking(src, { streaming: false })
|
||||
expect(r.hasThinking).toBe(false)
|
||||
expect(r.body).toBe(src)
|
||||
})
|
||||
|
||||
it('parses real <think> outside code blocks even when code blocks contain fake ones', () => {
|
||||
const src = '<think>real</think>text\n```\n<think>fake</think>\n```'
|
||||
const r = parseThinking(src, { streaming: false })
|
||||
expect(r.segments).toEqual(['real'])
|
||||
expect(r.body).toBe('text\n```\n<think>fake</think>\n```')
|
||||
})
|
||||
|
||||
it('does not leak code-protection placeholders for inline mentions of markdown fences', () => {
|
||||
const src = [
|
||||
'Previous fix kept the outer ` ```md ` block as code.',
|
||||
'',
|
||||
'````md',
|
||||
'下面是可直接手动编辑的 PR draft。',
|
||||
'```md',
|
||||
'标题',
|
||||
'```',
|
||||
'````',
|
||||
].join('\n')
|
||||
const r = parseThinking(src, { streaming: false })
|
||||
expect(r.hasThinking).toBe(false)
|
||||
expect(r.body).toBe(src)
|
||||
expect(r.body).not.toContain('THKCODE')
|
||||
expect(r.body).not.toContain('\u0000')
|
||||
})
|
||||
|
||||
it('same-name nesting: inner tag absorbed into first segment (documented limitation)', () => {
|
||||
const r = parseThinking('<think>a<think>b</think>c</think>', { streaming: false })
|
||||
expect(r.segments).toEqual(['a<think>b'])
|
||||
expect(r.body).toBe('c</think>')
|
||||
})
|
||||
|
||||
it('handles chunk boundary: partial opening tag not yet identified', () => {
|
||||
const mid = parseThinking('<thin', { streaming: true })
|
||||
expect(mid.hasThinking).toBe(false)
|
||||
expect(mid.body).toBe('<thin')
|
||||
|
||||
const after = parseThinking('<think>hi</think>done', { streaming: true })
|
||||
expect(after.segments).toEqual(['hi'])
|
||||
expect(after.body).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countThinkingChars', () => {
|
||||
it('counts all segments + pending as Unicode chars', () => {
|
||||
const n = countThinkingChars({
|
||||
segments: ['abc', '你好'],
|
||||
pending: '🎉!',
|
||||
body: '',
|
||||
hasThinking: true,
|
||||
})
|
||||
expect(n).toBe(7)
|
||||
})
|
||||
|
||||
it('returns 0 when no thinking', () => {
|
||||
expect(countThinkingChars({ segments: [], pending: null, body: 'x', hasThinking: false })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectThinkingBoundary', () => {
|
||||
it('detects first appearance of opening tag', () => {
|
||||
const r = detectThinkingBoundary('', '<think>x')
|
||||
expect(r.startedAtBoundary).toBe(true)
|
||||
expect(r.endedAtBoundary).toBe(false)
|
||||
})
|
||||
|
||||
it('detects first appearance of closing tag', () => {
|
||||
const r = detectThinkingBoundary('<think>hi', '<think>hi</think>')
|
||||
expect(r.startedAtBoundary).toBe(false)
|
||||
expect(r.endedAtBoundary).toBe(true)
|
||||
})
|
||||
|
||||
it('detects both when both emerge in one delta', () => {
|
||||
const r = detectThinkingBoundary('', '<think>x</think>')
|
||||
expect(r.startedAtBoundary).toBe(true)
|
||||
expect(r.endedAtBoundary).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no boundary when neither crossed', () => {
|
||||
const r = detectThinkingBoundary('abc', 'abcdef')
|
||||
expect(r.startedAtBoundary).toBe(false)
|
||||
expect(r.endedAtBoundary).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores fake tags inside code blocks', () => {
|
||||
const r = detectThinkingBoundary('', '```\n<think>fake</think>\n```')
|
||||
expect(r.startedAtBoundary).toBe(false)
|
||||
expect(r.endedAtBoundary).toBe(false)
|
||||
})
|
||||
|
||||
it('is idempotent for repeated open/close after initial', () => {
|
||||
const r = detectThinkingBoundary(
|
||||
'<think>a</think><think>b',
|
||||
'<think>a</think><think>b</think>',
|
||||
)
|
||||
expect(r.startedAtBoundary).toBe(false)
|
||||
expect(r.endedAtBoundary).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTheme', () => ({
|
||||
useTheme: () => ({ isDark: false }),
|
||||
}))
|
||||
|
||||
import MessageList from '@/components/hermes/chat/MessageList.vue'
|
||||
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
||||
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
|
||||
const MessageItemStub = defineComponent({
|
||||
name: 'MessageItem',
|
||||
props: {
|
||||
message: { type: Object, required: true },
|
||||
highlight: { type: Boolean, default: false },
|
||||
},
|
||||
template: '<div class="stub-message" :data-role="message.role" :data-id="message.id">{{ message.toolName || message.content }}</div>',
|
||||
})
|
||||
|
||||
function makeSession(messages: Message[]): Session {
|
||||
return {
|
||||
id: 'session-1',
|
||||
title: 'Tool trace visibility',
|
||||
messages,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
const sampleMessages: Message[] = [
|
||||
{ id: 'user-1', role: 'user', content: 'inspect repo', timestamp: 1 },
|
||||
{ id: 'tool-named', role: 'tool', content: '', timestamp: 2, toolName: 'read_file', toolResult: 'ok', toolStatus: 'done' },
|
||||
{ id: 'tool-internal', role: 'tool', content: '', timestamp: 3, toolResult: 'internal', toolStatus: 'done' },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'done', timestamp: 4 },
|
||||
]
|
||||
|
||||
describe('tool trace visibility', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.removeItem('hermes_show_tool_calls')
|
||||
useToolTraceVisibility().setToolTraceVisible(true)
|
||||
})
|
||||
|
||||
function mountLiveList() {
|
||||
const chatStore = useChatStore()
|
||||
chatStore.activeSessionId = 'session-1'
|
||||
chatStore.activeSession = makeSession(sampleMessages)
|
||||
chatStore.abortState = { aborting: true, synced: false }
|
||||
|
||||
return mount(MessageList, {
|
||||
global: {
|
||||
stubs: {
|
||||
MessageItem: MessageItemStub,
|
||||
Transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('shows named transcript and live tool traces by default while keeping unnamed internal tools hidden', () => {
|
||||
const wrapper = mountLiveList()
|
||||
|
||||
expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
|
||||
'user-1',
|
||||
'tool-named',
|
||||
'assistant-1',
|
||||
])
|
||||
expect(wrapper.findAll('.tool-call-name').map(node => node.text())).toContain('read_file')
|
||||
})
|
||||
|
||||
it('applies the same default-visible rule to history sessions', () => {
|
||||
const wrapper = mount(HistoryMessageList, {
|
||||
props: { session: makeSession(sampleMessages) },
|
||||
global: {
|
||||
stubs: { MessageItem: MessageItemStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
|
||||
'user-1',
|
||||
'tool-named',
|
||||
'assistant-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fall back to the live chat session while history session data is loading', () => {
|
||||
const chatStore = useChatStore()
|
||||
chatStore.activeSessionId = 'session-1'
|
||||
chatStore.activeSession = makeSession(sampleMessages)
|
||||
|
||||
const wrapper = mount(HistoryMessageList, {
|
||||
global: {
|
||||
stubs: { MessageItem: MessageItemStub },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.stub-message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hides named transcript traces when the toggle is off while keeping live tool stream visible', () => {
|
||||
useToolTraceVisibility().setToolTraceVisible(false)
|
||||
|
||||
const liveWrapper = mountLiveList()
|
||||
expect(liveWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
|
||||
'user-1',
|
||||
'assistant-1',
|
||||
])
|
||||
expect(liveWrapper.findAll('.tool-call-name').map(node => node.text())).toContain('read_file')
|
||||
|
||||
const historyWrapper = mount(HistoryMessageList, {
|
||||
props: { session: makeSession(sampleMessages) },
|
||||
global: {
|
||||
stubs: { MessageItem: MessageItemStub },
|
||||
},
|
||||
})
|
||||
expect(historyWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
|
||||
'user-1',
|
||||
'assistant-1',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockUsageStore = vi.hoisted(() => ({
|
||||
dailyUsage: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_tokens: 75,
|
||||
cache_write_tokens: 10,
|
||||
sessions: 2,
|
||||
errors: 0,
|
||||
cost: 0.02,
|
||||
visualTokens: 225,
|
||||
inputPercent: 44.444,
|
||||
outputPercent: 22.222,
|
||||
cachePercent: 33.333,
|
||||
},
|
||||
],
|
||||
modelUsage: [
|
||||
{
|
||||
model: 'gpt-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheTokens: 75,
|
||||
cacheWriteTokens: 10,
|
||||
totalTokens: 150,
|
||||
visualTokens: 225,
|
||||
sessions: 2,
|
||||
color: '#4fd1c5',
|
||||
inputPercent: 44.444,
|
||||
outputPercent: 22.222,
|
||||
cachePercent: 33.333,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/usage', () => ({
|
||||
useUsageStore: () => mockUsageStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
import DailyTrend from '@/components/hermes/usage/DailyTrend.vue'
|
||||
import ModelBreakdown from '@/components/hermes/usage/ModelBreakdown.vue'
|
||||
|
||||
describe('usage cache visualizations', () => {
|
||||
it('renders cache-read as a visible segment in the daily usage bars', () => {
|
||||
const wrapper = mount(DailyTrend)
|
||||
|
||||
const cacheSegment = wrapper.find('.bar-segment.cache')
|
||||
expect(cacheSegment.exists()).toBe(true)
|
||||
expect(cacheSegment.attributes('style')).toContain('height: 33.333%')
|
||||
expect(wrapper.text()).toContain('usage.cacheRead')
|
||||
})
|
||||
|
||||
it('renders model breakdown as input/output/cache stacked segments', () => {
|
||||
const wrapper = mount(ModelBreakdown)
|
||||
|
||||
expect(wrapper.find('.model-swatch').attributes('style')).toContain('background: rgb(79, 209, 197)')
|
||||
expect(wrapper.find('.model-bar-segment.input').exists()).toBe(true)
|
||||
expect(wrapper.find('.model-bar-segment.output').exists()).toBe(true)
|
||||
const cacheSegment = wrapper.find('.model-bar-segment.cache')
|
||||
expect(cacheSegment.exists()).toBe(true)
|
||||
expect(cacheSegment.attributes('style')).toContain('width: 33.333%')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const usageApiMock = vi.hoisted(() => ({
|
||||
fetchUsageStats: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
fetchUsageStats: usageApiMock.fetchUsageStats,
|
||||
}))
|
||||
|
||||
function emptyStats(totalSessions = 0, periodDays = 30) {
|
||||
return {
|
||||
total_input_tokens: totalSessions,
|
||||
total_output_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_reasoning_tokens: 0,
|
||||
total_cost: 0,
|
||||
total_sessions: totalSessions,
|
||||
period_days: periodDays,
|
||||
model_usage: [],
|
||||
daily_usage: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('usage store analytics adapter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
usageApiMock.fetchUsageStats.mockReset()
|
||||
})
|
||||
|
||||
it('loads 30-day usage stats and derives chart metrics from the native-style payload', async () => {
|
||||
usageApiMock.fetchUsageStats.mockResolvedValue({
|
||||
total_input_tokens: 100,
|
||||
total_output_tokens: 50,
|
||||
total_cache_read_tokens: 25,
|
||||
total_cache_write_tokens: 5,
|
||||
total_reasoning_tokens: 10,
|
||||
total_cost: 0.0123,
|
||||
total_sessions: 2,
|
||||
period_days: 30,
|
||||
model_usage: [
|
||||
{ model: 'gpt-5', input_tokens: 80, output_tokens: 40, cache_read_tokens: 20, cache_write_tokens: 3, reasoning_tokens: 7, sessions: 1 },
|
||||
{ model: '', input_tokens: 20, output_tokens: 10, cache_read_tokens: 5, cache_write_tokens: 2, reasoning_tokens: 3, sessions: 1 },
|
||||
],
|
||||
daily_usage: [
|
||||
{ date: '2026-04-29', input_tokens: 80, output_tokens: 20, cache_read_tokens: 40, cache_write_tokens: 4, sessions: 1, errors: 0, cost: 0.01 },
|
||||
{ date: '2026-04-30', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, sessions: 1, errors: 0, cost: 0.0023 },
|
||||
],
|
||||
})
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
await store.loadSessions()
|
||||
|
||||
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(30)
|
||||
expect(store.totalTokens).toBe(150)
|
||||
expect(store.cacheHitRate).toBeCloseTo(25 / 125 * 100)
|
||||
expect(store.hasData).toBe(true)
|
||||
expect(store.modelUsage).toHaveLength(2)
|
||||
expect(store.modelUsage[0]).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
totalTokens: 120,
|
||||
inputTokens: 80,
|
||||
outputTokens: 40,
|
||||
cacheTokens: 20,
|
||||
cacheWriteTokens: 3,
|
||||
visualTokens: 140,
|
||||
sessions: 1,
|
||||
})
|
||||
expect(store.modelUsage[0].color).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(store.modelUsage[0].inputPercent).toBeCloseTo(80 / 140 * 100)
|
||||
expect(store.modelUsage[0].outputPercent).toBeCloseTo(40 / 140 * 100)
|
||||
expect(store.modelUsage[0].cachePercent).toBeCloseTo(20 / 140 * 100)
|
||||
expect(store.modelUsage[1]).toMatchObject({
|
||||
model: 'unknown',
|
||||
totalTokens: 30,
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
cacheTokens: 5,
|
||||
cacheWriteTokens: 2,
|
||||
visualTokens: 35,
|
||||
sessions: 1,
|
||||
})
|
||||
expect(store.modelUsage[1].color).toBe(store.getModelColor('unknown'))
|
||||
expect(store.modelLegend.map(m => m.model)).toEqual(['gpt-5', 'unknown'])
|
||||
expect(store.dailyUsage).toHaveLength(2)
|
||||
expect(store.dailyUsage[0]).toMatchObject({
|
||||
date: '2026-04-29',
|
||||
input_tokens: 80,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 40,
|
||||
cache_write_tokens: 4,
|
||||
visualTokens: 140,
|
||||
sessions: 1,
|
||||
cost: 0.01,
|
||||
})
|
||||
expect(store.dailyUsage[0].inputPercent).toBeCloseTo(80 / 140 * 100)
|
||||
expect(store.dailyUsage[0].outputPercent).toBeCloseTo(20 / 140 * 100)
|
||||
expect(store.dailyUsage[0].cachePercent).toBeCloseTo(40 / 140 * 100)
|
||||
})
|
||||
|
||||
it('allows callers to request a different period', async () => {
|
||||
usageApiMock.fetchUsageStats.mockResolvedValue(emptyStats())
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
await store.loadSessions(7)
|
||||
|
||||
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(7)
|
||||
expect(store.hasData).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps loading true when an older overlapping request resolves first', async () => {
|
||||
let resolve30: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
let resolve7: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
usageApiMock.fetchUsageStats.mockImplementation((days: number) => new Promise(resolve => {
|
||||
if (days === 30) resolve30 = resolve
|
||||
if (days === 7) resolve7 = resolve
|
||||
}))
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
const firstLoad = store.loadSessions(30)
|
||||
const secondLoad = store.loadSessions(7)
|
||||
|
||||
expect(store.isLoading).toBe(true)
|
||||
resolve30(emptyStats(30, 30))
|
||||
await firstLoad
|
||||
|
||||
expect(store.isLoading).toBe(true)
|
||||
expect(store.stats).toBeNull()
|
||||
|
||||
resolve7(emptyStats(7, 7))
|
||||
await secondLoad
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.stats?.period_days).toBe(7)
|
||||
expect(store.totalSessions).toBe(7)
|
||||
})
|
||||
|
||||
it('ignores stale overlapping responses that resolve after the selected period', async () => {
|
||||
let resolve30: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
let resolve7: (value: ReturnType<typeof emptyStats>) => void = () => {}
|
||||
usageApiMock.fetchUsageStats.mockImplementation((days: number) => new Promise(resolve => {
|
||||
if (days === 30) resolve30 = resolve
|
||||
if (days === 7) resolve7 = resolve
|
||||
}))
|
||||
|
||||
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||
const store = useUsageStore()
|
||||
const firstLoad = store.loadSessions(30)
|
||||
const secondLoad = store.loadSessions(7)
|
||||
|
||||
resolve7(emptyStats(7, 7))
|
||||
await secondLoad
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.stats?.period_days).toBe(7)
|
||||
|
||||
resolve30(emptyStats(30, 30))
|
||||
await firstLoad
|
||||
|
||||
expect(store.stats?.period_days).toBe(7)
|
||||
expect(store.totalSessions).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const mockUsageStore = vi.hoisted(() => ({
|
||||
isLoading: false,
|
||||
hasData: true,
|
||||
loadSessions: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockProfilesStore = vi.hoisted(() => ({
|
||||
activeProfileName: 'default',
|
||||
profiles: [{ name: 'default' }],
|
||||
fetchProfiles: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/usage', () => ({
|
||||
useUsageStore: () => mockUsageStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/profiles', () => ({
|
||||
useProfilesStore: () => mockProfilesStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
props: {
|
||||
loading: Boolean,
|
||||
type: String,
|
||||
secondary: Boolean,
|
||||
quaternary: Boolean,
|
||||
size: String,
|
||||
ariaPressed: [Boolean, String],
|
||||
},
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" :data-type="type" :aria-pressed="ariaPressed" @click="$emit(\'click\')"><slot /></button>',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/usage/StatCards.vue', () => ({
|
||||
default: defineComponent({ name: 'StatCards', template: '<section class="stat-cards-stub" />' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/usage/ModelBreakdown.vue', () => ({
|
||||
default: defineComponent({ name: 'ModelBreakdown', template: '<section class="model-breakdown-stub" />' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/usage/DailyTrend.vue', () => ({
|
||||
default: defineComponent({ name: 'DailyTrend', template: '<section class="daily-trend-stub" />' }),
|
||||
}))
|
||||
|
||||
import UsageView from '@/views/hermes/UsageView.vue'
|
||||
|
||||
describe('UsageView period selector', () => {
|
||||
beforeEach(() => {
|
||||
mockUsageStore.isLoading = false
|
||||
mockUsageStore.hasData = true
|
||||
mockUsageStore.loadSessions.mockReset()
|
||||
mockProfilesStore.activeProfileName = 'default'
|
||||
mockProfilesStore.profiles = [{ name: 'default' }]
|
||||
mockProfilesStore.fetchProfiles.mockReset()
|
||||
})
|
||||
|
||||
it('loads the default 30-day period on mount', async () => {
|
||||
mount(UsageView)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockUsageStore.loadSessions).toHaveBeenCalledWith(30)
|
||||
})
|
||||
|
||||
it('lets users switch usage statistics between common dashboard periods', async () => {
|
||||
const wrapper = mount(UsageView)
|
||||
|
||||
const periodButtons = wrapper.findAll('.period-option')
|
||||
expect(periodButtons.map(button => button.text())).toEqual(['7d', '30d', '90d', '365d'])
|
||||
expect(wrapper.find('.period-selector').attributes('role')).toBe('group')
|
||||
|
||||
await periodButtons[0].trigger('click')
|
||||
expect(mockUsageStore.loadSessions).toHaveBeenLastCalledWith(7)
|
||||
expect(periodButtons[0].attributes('data-type')).toBe('primary')
|
||||
expect(periodButtons[0].attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await wrapper.find('.refresh-button').trigger('click')
|
||||
expect(mockUsageStore.loadSessions).toHaveBeenLastCalledWith(7)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { usePersistentRecord } from '@/composables/usePersistentRecord'
|
||||
|
||||
describe('usePersistentRecord', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('loads saved record and persists updates', () => {
|
||||
localStorage.setItem('hermes.sidebar.collapsedGroups', JSON.stringify({ agent: true }))
|
||||
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
|
||||
|
||||
expect(state.record.agent).toBe(true)
|
||||
state.record.system = true
|
||||
state.persist()
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('hermes.sidebar.collapsedGroups') || '{}')).toEqual({
|
||||
agent: true,
|
||||
system: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores invalid stored values', () => {
|
||||
localStorage.setItem('hermes.sidebar.collapsedGroups', 'not-json')
|
||||
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
|
||||
|
||||
expect({ ...state.record }).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import { useSpeech } from '@/composables/useSpeech'
|
||||
|
||||
class MockSpeechSynthesisUtterance {
|
||||
text: string
|
||||
rate = 1
|
||||
pitch = 1
|
||||
volume = 1
|
||||
voice: SpeechSynthesisVoice | null = null
|
||||
lang = ''
|
||||
onboundary: ((event: SpeechSynthesisEvent) => void) | null = null
|
||||
onend: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSpeech WebSpeech playback', () => {
|
||||
beforeEach(() => {
|
||||
const voice = {
|
||||
name: 'Google US English',
|
||||
lang: 'en-US',
|
||||
default: false,
|
||||
localService: false,
|
||||
voiceURI: 'Google US English',
|
||||
} satisfies SpeechSynthesisVoice
|
||||
|
||||
const synth = {
|
||||
speaking: false,
|
||||
pending: false,
|
||||
paused: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getVoices: vi.fn(() => [voice]),
|
||||
speak: vi.fn(function (this: SpeechSynthesis) {
|
||||
this.speaking = true
|
||||
this.paused = false
|
||||
}),
|
||||
cancel: vi.fn(function (this: SpeechSynthesis) {
|
||||
this.speaking = false
|
||||
this.pending = false
|
||||
this.paused = false
|
||||
}),
|
||||
pause: vi.fn(function (this: SpeechSynthesis) {
|
||||
this.speaking = false
|
||||
this.paused = true
|
||||
}),
|
||||
resume: vi.fn(function (this: SpeechSynthesis) {
|
||||
this.speaking = true
|
||||
this.paused = false
|
||||
}),
|
||||
} as unknown as SpeechSynthesis
|
||||
|
||||
Object.defineProperty(window, 'speechSynthesis', {
|
||||
configurable: true,
|
||||
value: synth,
|
||||
})
|
||||
Object.defineProperty(window, 'SpeechSynthesisUtterance', {
|
||||
configurable: true,
|
||||
value: MockSpeechSynthesisUtterance,
|
||||
})
|
||||
Object.defineProperty(globalThis, 'SpeechSynthesisUtterance', {
|
||||
configurable: true,
|
||||
value: MockSpeechSynthesisUtterance,
|
||||
})
|
||||
})
|
||||
|
||||
it('pauses and resumes the current browser voice instead of restarting it', () => {
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
speech: useSpeech(),
|
||||
}
|
||||
},
|
||||
template: '<div />',
|
||||
}))
|
||||
const speech = wrapper.vm.speech
|
||||
const synth = vi.mocked(window.speechSynthesis)
|
||||
|
||||
speech.toggleBrowser('message-1', 'Hello world', {
|
||||
voiceName: 'Google US English',
|
||||
})
|
||||
|
||||
expect(synth.speak).toHaveBeenCalledTimes(1)
|
||||
expect(speech.isPlaying.value).toBe(true)
|
||||
expect(speech.isPaused.value).toBe(false)
|
||||
|
||||
speech.toggleBrowser('message-1', 'Hello world', {
|
||||
voiceName: 'Google US English',
|
||||
})
|
||||
|
||||
expect(synth.pause).toHaveBeenCalledTimes(1)
|
||||
expect(synth.speak).toHaveBeenCalledTimes(1)
|
||||
expect(speech.isPlaying.value).toBe(true)
|
||||
expect(speech.isPaused.value).toBe(true)
|
||||
|
||||
speech.toggleBrowser('message-1', 'Hello world', {
|
||||
voiceName: 'Google US English',
|
||||
})
|
||||
|
||||
expect(synth.resume).toHaveBeenCalledTimes(1)
|
||||
expect(synth.speak).toHaveBeenCalledTimes(1)
|
||||
expect(speech.isPaused.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createShimContent,
|
||||
installHermesStudioCliShim,
|
||||
pathContainsDir,
|
||||
shimPathForPlatform,
|
||||
} from '../../packages/desktop/src/main/cli-shim'
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
function tempHome(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-studio-shim-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('Hermes Studio CLI shim', () => {
|
||||
it('quotes Unix app paths and forwards args through --hermes-cli', () => {
|
||||
const content = createShimContent("/Applications/Hermes Studio's.app/Contents/MacOS/Hermes Studio", 'darwin')
|
||||
|
||||
expect(content).toContain("--hermes-cli")
|
||||
expect(content).toContain("APP='/Applications/Hermes Studio'\\''s.app/Contents/MacOS/Hermes Studio'")
|
||||
expect(content).toContain('unset ELECTRON_RUN_AS_NODE')
|
||||
expect(content).toContain('exec "$APP" -- --hermes-cli "$@"')
|
||||
})
|
||||
|
||||
it('clears Electron Node mode in Windows shims before launching the app', () => {
|
||||
const content = createShimContent('C:\\Users\\Example\\AppData\\Local\\Programs\\Hermes Studio\\Hermes Studio.exe', 'win32')
|
||||
|
||||
expect(content).toContain('set ELECTRON_RUN_AS_NODE=')
|
||||
expect(content).toContain('"%APP%" -- --hermes-cli %*')
|
||||
})
|
||||
|
||||
it('detects user bin paths with platform-specific separators', () => {
|
||||
expect(pathContainsDir('/usr/bin:/Users/example/bin', '/Users/example/bin', 'darwin')).toBe(true)
|
||||
expect(pathContainsDir('C:\\Windows;C:\\Users\\Example\\bin', 'C:\\Users\\Example\\bin', 'win32')).toBe(true)
|
||||
})
|
||||
|
||||
it('installs a managed Unix shim and adds ~/bin to a shell profile', async () => {
|
||||
const homeDir = tempHome()
|
||||
const result = await installHermesStudioCliShim({
|
||||
homeDir,
|
||||
platform: 'darwin',
|
||||
executablePath: '/Applications/Hermes Studio.app/Contents/MacOS/Hermes Studio',
|
||||
env: { PATH: '/usr/bin', SHELL: '/bin/zsh' },
|
||||
})
|
||||
|
||||
expect(result.status).toBe('installed')
|
||||
expect(result.pathUpdated).toBe(true)
|
||||
expect(result.shimPath).toBe(shimPathForPlatform(join(homeDir, 'bin'), 'darwin'))
|
||||
expect(readFileSync(result.shimPath, 'utf-8')).toContain('exec "$APP" -- --hermes-cli "$@"')
|
||||
expect(readFileSync(join(homeDir, '.zprofile'), 'utf-8')).toContain('export PATH="$HOME/bin:$PATH"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('redirects protected routes to the login screen without a token', async ({ page }) => {
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
await page.goto('/#/hermes/jobs')
|
||||
|
||||
await expect(page).toHaveURL(/#\/$/)
|
||||
await expect(page.getByRole('heading', { name: 'Hermes Web UI' })).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Username')).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Password')).toBeVisible()
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('rejects invalid credentials without persisting a token', async ({ page }) => {
|
||||
const api = await mockHermesApi(page, { tokenValidationStatus: 401 })
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('Username').fill('playwright')
|
||||
await page.getByPlaceholder('Password').fill('bad-password')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
|
||||
await expect(page.getByText('Invalid username or password')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/$/)
|
||||
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBeNull()
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('logs in with password through the BFF before entering the app', async ({ page }) => {
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('Username').fill('playwright')
|
||||
await page.getByPlaceholder('Password').fill('correct-password')
|
||||
await page.getByRole('button', { name: 'Login' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/chat$/)
|
||||
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBe(TEST_ACCESS_KEY)
|
||||
|
||||
const loginRequest = api.requests.find((request) => request.pathname === '/api/auth/login')
|
||||
expect(loginRequest?.method).toBe('POST')
|
||||
expect(loginRequest?.postData).toBe(JSON.stringify({ username: 'playwright', password: 'correct-password' }))
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('renders authenticated shell and navigates between key product routes', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
await page.goto('/#/hermes/jobs')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Scheduled Jobs' })).toBeVisible()
|
||||
await expect(page.getByText('Nightly Smoke')).toBeVisible()
|
||||
|
||||
const jobsRequest = api.requests.find((request) => request.pathname === '/api/hermes/jobs')
|
||||
expect(jobsRequest?.headers.authorization).toBe(`Bearer ${TEST_ACCESS_KEY}`)
|
||||
expect(jobsRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
const cronHistoryRequest = api.requests.find((request) => request.pathname === '/api/cron-history')
|
||||
expect(cronHistoryRequest?.headers['x-hermes-profile']).toBe('research')
|
||||
|
||||
const modelsLink = page.locator('aside.sidebar').getByRole('link', { name: /^Models$/ })
|
||||
await expect(modelsLink).toHaveAttribute('href', '#/hermes/models')
|
||||
await modelsLink.click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/models$/)
|
||||
await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible()
|
||||
await expect(page.getByText('test-model').first()).toBeVisible()
|
||||
|
||||
const settingsLink = page.locator('aside.sidebar').getByRole('link', { name: /^Settings$/ })
|
||||
await expect(settingsLink).toHaveAttribute('href', '#/hermes/settings')
|
||||
await settingsLink.click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/settings$/)
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
|
||||
|
||||
type SessionSeed = {
|
||||
id: string
|
||||
title: string
|
||||
lastActive: number
|
||||
}
|
||||
|
||||
function sessionSummary({ id, title, lastActive }: SessionSeed) {
|
||||
return {
|
||||
id,
|
||||
profile: 'research',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title,
|
||||
preview: title,
|
||||
started_at: lastActive - 10,
|
||||
ended_at: null,
|
||||
last_active: lastActive,
|
||||
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: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'estimated',
|
||||
}
|
||||
}
|
||||
|
||||
function resumePayload(sessionId: string, content: string) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sessionId,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now() / 1000,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
isWorking: false,
|
||||
events: [],
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
sessionSummary({ id: 'session-a', title: 'Alpha chat', lastActive: 100 }),
|
||||
sessionSummary({ id: 'session-b', title: 'Beta chat', lastActive: 200 }),
|
||||
]
|
||||
|
||||
const resumes = {
|
||||
'session-a': resumePayload('session-a', 'Alpha route content'),
|
||||
'session-b': resumePayload('session-b', 'Beta route content'),
|
||||
}
|
||||
|
||||
async function setupChatPage(page: Page) {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((payload) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = payload
|
||||
window.localStorage.setItem('hermes_active_session_research', 'session-b')
|
||||
}, resumes)
|
||||
const api = await mockHermesApi(page, { sessions })
|
||||
await mockChatSocket(page)
|
||||
return api
|
||||
}
|
||||
|
||||
async function sendChatMessage(page: Page, message: string) {
|
||||
const input = page.getByPlaceholder(inputPlaceholder)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(message)
|
||||
await page.getByRole('button', { name: 'Send' }).click()
|
||||
}
|
||||
|
||||
async function waitForRun(page: Page, index = 0) {
|
||||
const handle = await page.waitForFunction((runIndex) => {
|
||||
const state = (window as any).__PW_CHAT_SOCKET__
|
||||
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
|
||||
const run = runs[runIndex]
|
||||
return run ? run.payload : null
|
||||
}, index)
|
||||
return handle.jsonValue() as Promise<any>
|
||||
}
|
||||
|
||||
test('route session id wins over shared active-session localStorage', async ({ page }) => {
|
||||
const api = await setupChatPage(page)
|
||||
|
||||
await page.goto('/#/hermes/session/session-a')
|
||||
|
||||
await expect(page.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(page.getByText('Beta route content')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('two tabs can show different sessions and keep them after reload', async ({ context }) => {
|
||||
const pageA = await context.newPage()
|
||||
const pageB = await context.newPage()
|
||||
const apiA = await setupChatPage(pageA)
|
||||
const apiB = await setupChatPage(pageB)
|
||||
|
||||
await pageA.goto('/#/hermes/session/session-a')
|
||||
await pageB.goto('/#/hermes/session/session-b')
|
||||
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
|
||||
await pageA.reload()
|
||||
await pageB.reload()
|
||||
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
await expect(pageA).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||
await expect(pageB).toHaveURL(/#\/hermes\/session\/session-b$/)
|
||||
expect(apiA.unexpectedRequests).toEqual([])
|
||||
expect(apiB.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('parallel tabs send runs and render progress only for their own session', async ({ context }) => {
|
||||
const pageA = await context.newPage()
|
||||
const pageB = await context.newPage()
|
||||
const apiA = await setupChatPage(pageA)
|
||||
const apiB = await setupChatPage(pageB)
|
||||
|
||||
await pageA.goto('/#/hermes/session/session-a')
|
||||
await pageB.goto('/#/hermes/session/session-b')
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
|
||||
await sendChatMessage(pageA, 'Question for Alpha')
|
||||
await sendChatMessage(pageB, 'Question for Beta')
|
||||
|
||||
const runA = await waitForRun(pageA)
|
||||
const runB = await waitForRun(pageB)
|
||||
expect(runA.session_id).toBe('session-a')
|
||||
expect(runB.session_id).toBe('session-b')
|
||||
|
||||
await pageA.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-a' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-a', delta: 'Alpha progress' })
|
||||
}, runA.session_id)
|
||||
await pageB.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-b' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-b', delta: 'Beta progress' })
|
||||
}, runB.session_id)
|
||||
|
||||
await expect(pageA.getByText('Alpha progress')).toBeVisible()
|
||||
await expect(pageA.getByText('Beta progress')).toHaveCount(0)
|
||||
await expect(pageB.getByText('Beta progress')).toBeVisible()
|
||||
await expect(pageB.getByText('Alpha progress')).toHaveCount(0)
|
||||
expect(apiA.unexpectedRequests).toEqual([])
|
||||
expect(apiB.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,858 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
|
||||
|
||||
async function sendChatMessage(page: Page, message: string) {
|
||||
const input = page.getByPlaceholder(inputPlaceholder)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(message)
|
||||
await page.getByRole('button', { name: 'Send' }).click()
|
||||
}
|
||||
|
||||
async function waitForRun(page: Page, index = 0) {
|
||||
const handle = await page.waitForFunction((runIndex) => {
|
||||
const state = (window as any).__PW_CHAT_SOCKET__
|
||||
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
|
||||
const run = runs[runIndex]
|
||||
return run
|
||||
? {
|
||||
socket: {
|
||||
url: state.latest.url,
|
||||
options: state.latest.options,
|
||||
},
|
||||
run: run.payload,
|
||||
runCount: runs.length,
|
||||
socketCount: state.sockets.length,
|
||||
}
|
||||
: null
|
||||
}, index)
|
||||
return handle.jsonValue() as Promise<any>
|
||||
}
|
||||
|
||||
test('sends a chat run and renders streamed Socket.IO response events', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'Summarize the queue')
|
||||
|
||||
await expect(page.locator('p').filter({ hasText: /^Summarize the queue$/ })).toBeVisible()
|
||||
|
||||
const { socket, run } = await waitForRun(page)
|
||||
|
||||
expect(socket.url).toBe('/chat-run')
|
||||
expect(socket.options.auth).toEqual({ token: TEST_ACCESS_KEY })
|
||||
expect(socket.options.query).toEqual({ profile: 'research' })
|
||||
expect(run).toMatchObject({
|
||||
input: 'Summarize the queue',
|
||||
queue_id: expect.any(String),
|
||||
session_id: expect.any(String),
|
||||
source: 'cli',
|
||||
})
|
||||
expect(run.model).toBe('test-model')
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-1' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'Streaming ' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'answer from Hermes' })
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-1',
|
||||
output: 'Streaming answer from Hermes',
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.getByText('Streaming answer from Hermes')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('uses the newly selected profile for the next chat-run socket after profile switch reload', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'default')
|
||||
const api = await mockHermesApi(page, { initialProfileName: 'default' })
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'default' })).toBeVisible()
|
||||
|
||||
await sendChatMessage(page, 'Warm up default socket')
|
||||
const defaultRun = await waitForRun(page)
|
||||
expect(defaultRun.socket.options.query).toEqual({ profile: 'default' })
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-default' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-default', delta: 'Default profile reply' })
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-default',
|
||||
output: 'Default profile reply',
|
||||
})
|
||||
}, defaultRun.run.session_id)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
|
||||
await page.getByTestId('profile-selector-select').click()
|
||||
await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible()
|
||||
const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
|
||||
await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Frontend Profile' }).click()
|
||||
await reloadPromise
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible()
|
||||
|
||||
await sendChatMessage(page, 'Use the active research profile')
|
||||
const { socket, run } = await waitForRun(page)
|
||||
|
||||
expect(socket.url).toBe('/chat-run')
|
||||
expect(socket.options.auth).toEqual({ token: TEST_ACCESS_KEY })
|
||||
expect(socket.options.query).toEqual({ profile: 'research' })
|
||||
expect(run.input).toBe('Use the active research profile')
|
||||
expect(await page.evaluate(() => window.localStorage.getItem('hermes_active_profile_name'))).toBe('research')
|
||||
|
||||
expect(api.requests.some((request) => request.pathname === '/api/hermes/profiles/active')).toBe(false)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps queued runs on one socket and does not duplicate streamed handlers', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'First queued contract')
|
||||
const first = await waitForRun(page)
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-1', queue_length: 1 })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'First answer' })
|
||||
}, first.run.session_id)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible()
|
||||
|
||||
await sendChatMessage(page, 'Second queued contract')
|
||||
const second = await waitForRun(page, 1)
|
||||
|
||||
expect(second.socketCount).toBe(1)
|
||||
expect(second.runCount).toBe(2)
|
||||
expect(second.run.session_id).toBe(first.run.session_id)
|
||||
expect(second.run.input).toBe('Second queued contract')
|
||||
await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(0)
|
||||
|
||||
await page.evaluate(({ sid, queueId }) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.peer_user_message', {
|
||||
event: 'run.peer_user_message',
|
||||
session_id: sid,
|
||||
message: {
|
||||
id: queueId,
|
||||
role: 'user',
|
||||
content: 'Second queued contract',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
})
|
||||
}, { sid: first.run.session_id, queueId: second.run.queue_id })
|
||||
await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(0)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-1',
|
||||
output: 'First answer',
|
||||
queue_remaining: 1,
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(0)
|
||||
|
||||
await page.evaluate(({ sid, queueId }) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.queued', {
|
||||
event: 'run.queued',
|
||||
session_id: sid,
|
||||
queue_length: 0,
|
||||
dequeued_queue_id: queueId,
|
||||
queued_messages: [],
|
||||
})
|
||||
socket.__trigger('run.peer_user_message', {
|
||||
event: 'run.peer_user_message',
|
||||
session_id: sid,
|
||||
message: {
|
||||
id: queueId,
|
||||
role: 'user',
|
||||
content: 'Second queued contract',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
})
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-2', queue_length: 0 })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-2', delta: 'Second answer' })
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-2',
|
||||
output: 'Second answer',
|
||||
queue_remaining: 0,
|
||||
})
|
||||
}, { sid: first.run.session_id, queueId: second.run.queue_id })
|
||||
|
||||
await expect(page.locator('p').filter({ hasText: /^First answer$/ })).toHaveCount(1)
|
||||
await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(1)
|
||||
await expect(page.locator('p').filter({ hasText: /^Second answer$/ })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('clears previous compression status when a new run starts', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'Trigger compression before answering')
|
||||
const first = await waitForRun(page)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-1' })
|
||||
socket.__trigger('compression.completed', {
|
||||
event: 'compression.completed',
|
||||
session_id: sid,
|
||||
totalMessages: 12,
|
||||
beforeTokens: 24000,
|
||||
afterTokens: 6000,
|
||||
compressed: true,
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
await expect(page.getByText(/Compressed 12 msgs/)).toBeVisible()
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-1',
|
||||
output: 'First answer',
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
await sendChatMessage(page, 'Start another turn')
|
||||
const second = await waitForRun(page, 1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-2' })
|
||||
}, second.run.session_id)
|
||||
|
||||
await expect(page.getByText(/Compressed 12 msgs/)).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('surfaces an empty completed run as an error instead of leaving chat stalled', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'Call a broken provider')
|
||||
const { run } = await waitForRun(page)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-empty' })
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-empty',
|
||||
output: '',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.getByText(/Agent returned no output/)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('renders tool trace and sends explicit approval decisions over the chat-run socket', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'Use write_file with approval')
|
||||
const { run } = await waitForRun(page)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-approval' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
tool_call_id: 'tool-call-1',
|
||||
tool: 'write_file',
|
||||
preview: 'Writing approved file',
|
||||
arguments: JSON.stringify({ path: '/tmp/approved.txt', content: 'hello' }),
|
||||
})
|
||||
socket.__trigger('approval.requested', {
|
||||
event: 'approval.requested',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
approval_id: 'approval-1',
|
||||
command: 'write_file /tmp/approved.txt',
|
||||
description: 'Allow write_file to create /tmp/approved.txt',
|
||||
choices: ['once', 'deny'],
|
||||
allow_permanent: false,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.getByText('write_file', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Writing approved file')).toBeVisible()
|
||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toBeVisible()
|
||||
await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toBeVisible()
|
||||
await expect(page.getByText('write_file /tmp/approved.txt')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Allow session' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Deny' })).toBeVisible()
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('approval.resolved', {
|
||||
event: 'approval.resolved',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
approval_id: 'approval-other',
|
||||
choice: 'deny',
|
||||
resolved: true,
|
||||
})
|
||||
}, run.session_id)
|
||||
await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Allow once' }).click()
|
||||
|
||||
await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Allow once' })).toHaveCount(0)
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const emitted = (window as any).__PW_CHAT_SOCKET__.emitted
|
||||
return emitted.filter((item: any) => item.event === 'approval.respond')
|
||||
})).toEqual([
|
||||
{
|
||||
event: 'approval.respond',
|
||||
payload: {
|
||||
session_id: run.session_id,
|
||||
approval_id: 'approval-1',
|
||||
choice: 'once',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('approval.resolved', {
|
||||
event: 'approval.resolved',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
approval_id: 'approval-1',
|
||||
choice: 'once',
|
||||
resolved: true,
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
tool_call_id: 'tool-call-1',
|
||||
tool: 'write_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/approved.txt' }),
|
||||
duration: 42,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
delta: 'Delta-only approved tool result.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-approval',
|
||||
output: 'Completion fallback should stay hidden.',
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
const persistedToolTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'write_file' })
|
||||
await expect(persistedToolTrace).toHaveCount(1)
|
||||
await persistedToolTrace.click()
|
||||
const toolDetails = page.locator('.message.tool .tool-details')
|
||||
await expect(toolDetails).toContainText('/tmp/approved.txt')
|
||||
await expect(toolDetails).toContainText('ok')
|
||||
await expect(page.getByText('Delta-only approved tool result.')).toBeVisible()
|
||||
await expect(page.getByText('Completion fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps prior tool trace visible while hiding only the active run tool trace', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'First tool trace')
|
||||
const first = await waitForRun(page)
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-history-1' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
tool_call_id: 'tool-history-1',
|
||||
tool: 'read_file',
|
||||
preview: 'Read historical file',
|
||||
arguments: JSON.stringify({ path: '/tmp/history.txt' }),
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
tool_call_id: 'tool-history-1',
|
||||
tool: 'read_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/history.txt' }),
|
||||
duration: 12,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
delta: 'First tool answer.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-1',
|
||||
output: 'First fallback should stay hidden.',
|
||||
})
|
||||
}, first.run.session_id)
|
||||
|
||||
const transcriptTools = page.locator('.message.tool .tool-line')
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
|
||||
|
||||
await sendChatMessage(page, 'Second tool trace')
|
||||
const second = await waitForRun(page, 1)
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-history-2' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
tool_call_id: 'tool-history-2',
|
||||
tool: 'write_file',
|
||||
preview: 'Write current file',
|
||||
arguments: JSON.stringify({ path: '/tmp/current.txt', content: 'now' }),
|
||||
})
|
||||
}, second.run.session_id)
|
||||
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(transcriptTools.filter({ hasText: 'write_file' })).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toHaveCount(1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
tool_call_id: 'tool-history-2',
|
||||
tool: 'write_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/current.txt' }),
|
||||
duration: 15,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
delta: 'Second tool answer.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-history-2',
|
||||
output: 'Second fallback should stay hidden.',
|
||||
})
|
||||
}, second.run.session_id)
|
||||
|
||||
await expect(transcriptTools).toHaveCount(2)
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(transcriptTools.filter({ hasText: 'write_file' })).toHaveCount(1)
|
||||
await expect(page.getByText('First fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByText('Second fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps completed same-run tool traces hidden until the run finishes', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'Run multiple tools')
|
||||
const { run } = await waitForRun(page)
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-multi-tool' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-1',
|
||||
tool: 'read_file',
|
||||
preview: 'Read config',
|
||||
arguments: JSON.stringify({ path: '/tmp/config.json' }),
|
||||
})
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-2',
|
||||
tool: 'shell_exec',
|
||||
preview: 'Run command',
|
||||
arguments: JSON.stringify({ command: 'false' }),
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
const transcriptTools = page.locator('.message.tool .tool-line')
|
||||
await expect(transcriptTools).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-1',
|
||||
tool: 'read_file',
|
||||
output: JSON.stringify({ ok: true, path: '/tmp/config.json' }),
|
||||
duration: 11,
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
tool_call_id: 'tool-multi-2',
|
||||
tool: 'shell_exec',
|
||||
output: 'exit status 1',
|
||||
error: true,
|
||||
duration: 13,
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(transcriptTools).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(1)
|
||||
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
delta: 'Multiple tools finished.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-multi-tool',
|
||||
output: 'Multi-tool fallback should stay hidden.',
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(transcriptTools).toHaveCount(2)
|
||||
await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1)
|
||||
await expect(transcriptTools.filter({ hasText: 'shell_exec' })).toHaveCount(1)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
|
||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(0)
|
||||
await expect(page.locator('.message.tool .tool-error-badge')).toHaveCount(1)
|
||||
await transcriptTools.filter({ hasText: 'shell_exec' }).click()
|
||||
await expect(page.locator('.message.tool .tool-details')).toContainText('exit status 1')
|
||||
await expect(page.getByText('Multi-tool fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps unnamed tool trace messages out of the transcript after completion', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await sendChatMessage(page, 'Run internal unnamed tool')
|
||||
const { run } = await waitForRun(page)
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-unnamed-tool' })
|
||||
socket.__trigger('tool.started', {
|
||||
event: 'tool.started',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
tool_call_id: 'tool-unnamed-1',
|
||||
preview: 'Internal unnamed work',
|
||||
arguments: JSON.stringify({ internal: true }),
|
||||
})
|
||||
socket.__trigger('tool.completed', {
|
||||
event: 'tool.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
tool_call_id: 'tool-unnamed-1',
|
||||
output: JSON.stringify({ internal: true, ok: true }),
|
||||
duration: 9,
|
||||
})
|
||||
socket.__trigger('message.delta', {
|
||||
event: 'message.delta',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
delta: 'Unnamed internal tool finished.',
|
||||
})
|
||||
socket.__trigger('run.completed', {
|
||||
event: 'run.completed',
|
||||
session_id: sid,
|
||||
run_id: 'run-unnamed-tool',
|
||||
output: 'Unnamed fallback should stay hidden.',
|
||||
})
|
||||
}, run.session_id)
|
||||
|
||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
||||
await expect(page.getByText('Unnamed internal tool finished.')).toBeVisible()
|
||||
await expect(page.getByText('Unnamed fallback should stay hidden.')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps unnamed resumed tool traces hidden after session reload', async ({ page }) => {
|
||||
const sessionId = 'session-history-unnamed-tool'
|
||||
const sessionSummary = {
|
||||
id: sessionId,
|
||||
source: 'api_server',
|
||||
model: 'test-model',
|
||||
title: 'Unnamed tool history',
|
||||
preview: 'History answer visible.',
|
||||
started_at: 1,
|
||||
ended_at: 4,
|
||||
last_active: 4,
|
||||
message_count: 4,
|
||||
tool_call_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'test-provider',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'none',
|
||||
workspace: null,
|
||||
}
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((sid) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = {
|
||||
[sid]: {
|
||||
session_id: sid,
|
||||
isWorking: false,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sid,
|
||||
role: 'user',
|
||||
content: 'Resume unnamed internal tool',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_call_id: null,
|
||||
tool_calls: [{ id: 'tool-resume-unnamed-1', type: 'function', function: { arguments: JSON.stringify({ internal: true }) } }],
|
||||
tool_name: null,
|
||||
timestamp: 2,
|
||||
token_count: null,
|
||||
finish_reason: 'tool_calls',
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
session_id: sid,
|
||||
role: 'tool',
|
||||
content: JSON.stringify({ internal: true, ok: true }),
|
||||
tool_call_id: 'tool-resume-unnamed-1',
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 3,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: 'History answer visible.',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 4,
|
||||
token_count: null,
|
||||
finish_reason: 'stop',
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}, sessionId)
|
||||
const api = await mockHermesApi(page, { sessions: [sessionSummary] })
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await expect(page.getByText('History answer visible.')).toBeVisible()
|
||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
||||
await expect(page.locator('.message.tool')).toHaveCount(0)
|
||||
const resumeRequest = await page.waitForFunction((sid) => {
|
||||
const state = (window as any).__PW_CHAT_SOCKET__
|
||||
return state?.emitted?.some((item: any) => item.event === 'resume' && item.payload?.session_id === sid)
|
||||
}, sessionId)
|
||||
expect(await resumeRequest.jsonValue()).toBe(true)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('restores named resumed tool traces from assistant tool calls after session reload', async ({ page }) => {
|
||||
const sessionId = 'session-history-named-tool'
|
||||
const sessionSummary = {
|
||||
id: sessionId,
|
||||
source: 'api_server',
|
||||
model: 'test-model',
|
||||
title: 'Named tool history',
|
||||
preview: 'Named history answer visible.',
|
||||
started_at: 1,
|
||||
ended_at: 4,
|
||||
last_active: 4,
|
||||
message_count: 4,
|
||||
tool_call_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'test-provider',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'none',
|
||||
workspace: null,
|
||||
}
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((sid) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = {
|
||||
[sid]: {
|
||||
session_id: sid,
|
||||
isWorking: false,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sid,
|
||||
role: 'user',
|
||||
content: 'Resume named tool',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_call_id: null,
|
||||
tool_calls: [{ id: 'tool-resume-named-1', type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/history.txt' }) } }],
|
||||
tool_name: null,
|
||||
timestamp: 2,
|
||||
token_count: null,
|
||||
finish_reason: 'tool_calls',
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
session_id: sid,
|
||||
role: 'tool',
|
||||
content: JSON.stringify({ ok: true, path: '/tmp/history.txt' }),
|
||||
tool_call_id: 'tool-resume-named-1',
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 3,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
session_id: sid,
|
||||
role: 'assistant',
|
||||
content: 'Named history answer visible.',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 4,
|
||||
token_count: null,
|
||||
finish_reason: 'stop',
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}, sessionId)
|
||||
const api = await mockHermesApi(page, { sessions: [sessionSummary] })
|
||||
await mockChatSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
await expect(page.getByText('Named history answer visible.')).toBeVisible()
|
||||
const restoredTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'read_file' })
|
||||
await expect(restoredTrace).toHaveCount(1)
|
||||
await restoredTrace.click()
|
||||
await expect(page.locator('.message.tool .tool-details')).toContainText('/tmp/history.txt')
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { Page, Request, Route } from '@playwright/test'
|
||||
|
||||
export const TEST_ACCESS_KEY = 'playwright-access-key'
|
||||
|
||||
export interface MockedRequest {
|
||||
method: string
|
||||
pathname: string
|
||||
search: string
|
||||
headers: Record<string, string>
|
||||
postData: string | null
|
||||
}
|
||||
|
||||
interface MockHermesApiOptions {
|
||||
tokenValidationStatus?: number
|
||||
initialProfileName?: 'default' | 'research'
|
||||
sessions?: unknown[]
|
||||
}
|
||||
|
||||
const sampleModelGroup = {
|
||||
provider: 'test-provider',
|
||||
label: 'Test Provider',
|
||||
base_url: 'https://example.invalid/v1',
|
||||
models: ['test-model'],
|
||||
available_models: ['test-model'],
|
||||
api_key: '',
|
||||
builtin: true,
|
||||
}
|
||||
|
||||
const sampleJob = {
|
||||
job_id: 'job-smoke',
|
||||
id: 'job-smoke',
|
||||
name: 'Nightly Smoke',
|
||||
prompt: 'Run the smoke check',
|
||||
prompt_preview: 'Run the smoke check',
|
||||
skills: [],
|
||||
skill: null,
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
base_url: null,
|
||||
script: null,
|
||||
schedule: '0 9 * * *',
|
||||
schedule_display: '0 9 * * *',
|
||||
repeat: { times: null, completed: 0 },
|
||||
enabled: true,
|
||||
state: 'scheduled',
|
||||
paused_at: null,
|
||||
paused_reason: null,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
next_run_at: '2026-01-02T09:00:00.000Z',
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
last_error: null,
|
||||
deliver: 'origin',
|
||||
origin: null,
|
||||
last_delivery_error: null,
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
function recordRequest(request: Request): MockedRequest {
|
||||
const url = new URL(request.url())
|
||||
return {
|
||||
method: request.method(),
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
headers: request.headers(),
|
||||
postData: request.postData(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockHermesApi(page: Page, options: MockHermesApiOptions = {}) {
|
||||
const requests: MockedRequest[] = []
|
||||
const unexpectedRequests: MockedRequest[] = []
|
||||
const tokenValidationStatus = options.tokenValidationStatus ?? 200
|
||||
let activeProfileName = options.initialProfileName ?? 'research'
|
||||
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/') || pathname.startsWith('/v1/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
requests.push(recordRequest(request))
|
||||
|
||||
if (pathname === '/health') {
|
||||
await route.fulfill(jsonResponse({ status: 'ok', webui_version: '0.5.23', node_version: '23.0.0' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/status') {
|
||||
await route.fulfill(jsonResponse({ hasPasswordLogin: true, username: 'playwright' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/login') {
|
||||
if (request.method() !== 'POST') {
|
||||
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||
return
|
||||
}
|
||||
if (tokenValidationStatus !== 200) {
|
||||
await route.fulfill(jsonResponse({ error: 'Invalid username or password' }, tokenValidationStatus))
|
||||
return
|
||||
}
|
||||
await route.fulfill(jsonResponse({ token: TEST_ACCESS_KEY }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/me') {
|
||||
await route.fulfill(jsonResponse({
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'playwright',
|
||||
role: 'super_admin',
|
||||
status: 'active',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
last_login_at: 0,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/sessions') {
|
||||
await route.fulfill(jsonResponse({ sessions: options.sessions ?? [] }, tokenValidationStatus))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/sessions/hermes') {
|
||||
await route.fulfill(jsonResponse({ sessions: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/sessions/context-length') {
|
||||
await route.fulfill(jsonResponse({ context_length: 256000 }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/files/list') {
|
||||
await route.fulfill(jsonResponse({ entries: [], path: '' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/auth/copilot/check-token') {
|
||||
await route.fulfill(jsonResponse({ has_token: false, source: null, enabled: false }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/locked-ips') {
|
||||
await route.fulfill(jsonResponse({ locks: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/available-models') {
|
||||
await route.fulfill(jsonResponse({
|
||||
default: 'test-model',
|
||||
default_provider: 'test-provider',
|
||||
groups: [sampleModelGroup],
|
||||
allProviders: [sampleModelGroup],
|
||||
model_aliases: {},
|
||||
model_visibility: {},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/provider-models') {
|
||||
await route.fulfill(jsonResponse({ models: ['proxy-model-a', 'proxy-model-b'] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/profiles') {
|
||||
await route.fulfill(jsonResponse({
|
||||
profiles: [
|
||||
{ name: 'default', active: activeProfileName === 'default', model: 'test-model', gateway: 'test', alias: 'Default' },
|
||||
{ name: 'research', active: activeProfileName === 'research', model: 'test-model', gateway: 'test', alias: 'Research' },
|
||||
],
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/profiles/runtime-statuses') {
|
||||
await route.fulfill(jsonResponse({
|
||||
profiles: [
|
||||
{
|
||||
profile: 'default',
|
||||
bridge: { running: activeProfileName === 'default', profile: 'default', reachable: true },
|
||||
gateway: { running: true, profile: 'default' },
|
||||
},
|
||||
{
|
||||
profile: 'research',
|
||||
bridge: { running: activeProfileName === 'research', profile: 'research', reachable: true },
|
||||
gateway: { running: true, profile: 'research' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/profiles/active') {
|
||||
if (request.method() !== 'PUT') {
|
||||
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||
return
|
||||
}
|
||||
|
||||
let body: { name?: unknown }
|
||||
try {
|
||||
body = JSON.parse(request.postData() || '{}')
|
||||
} catch {
|
||||
await route.fulfill(jsonResponse({ error: 'Invalid JSON body' }, 400))
|
||||
return
|
||||
}
|
||||
|
||||
if (body.name !== 'default' && body.name !== 'research') {
|
||||
await route.fulfill(jsonResponse({ error: 'Unknown profile' }, 400))
|
||||
return
|
||||
}
|
||||
|
||||
activeProfileName = body.name
|
||||
await route.fulfill(jsonResponse({ success: true, active: activeProfileName }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/config') {
|
||||
await route.fulfill(jsonResponse({
|
||||
display: { streaming: true, show_reasoning: true, show_cost: true },
|
||||
agent: {},
|
||||
memory: {},
|
||||
session_reset: {},
|
||||
privacy: {},
|
||||
approvals: {},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/jobs') {
|
||||
await route.fulfill(jsonResponse({ jobs: [sampleJob] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/cron-history') {
|
||||
await route.fulfill(jsonResponse({ runs: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
unexpectedRequests.push(recordRequest(request))
|
||||
await route.fulfill(jsonResponse({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404))
|
||||
})
|
||||
|
||||
return { requests, unexpectedRequests }
|
||||
}
|
||||
|
||||
export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, profileName?: string) {
|
||||
await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => {
|
||||
const { storedToken, storedProfileName } = state
|
||||
window.localStorage.setItem('hermes_api_key', storedToken)
|
||||
if (storedProfileName && !window.localStorage.getItem('hermes_active_profile_name')) {
|
||||
window.localStorage.setItem('hermes_active_profile_name', storedProfileName)
|
||||
}
|
||||
}, { storedToken: accessKey, storedProfileName: profileName })
|
||||
}
|
||||
|
||||
export async function mockChatSocket(page: Page) {
|
||||
await page.route('**/node_modules/.vite/deps/socket__io-client.js*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
const state = window.__PW_CHAT_SOCKET__ || (window.__PW_CHAT_SOCKET__ = { sockets: [], emitted: [] })
|
||||
function makeSocket(url, options) {
|
||||
const listeners = new Map()
|
||||
const onceListeners = new Map()
|
||||
const socket = {
|
||||
connected: true,
|
||||
url,
|
||||
options,
|
||||
on(event, handler) {
|
||||
const handlers = listeners.get(event) || []
|
||||
handlers.push(handler)
|
||||
listeners.set(event, handlers)
|
||||
return this
|
||||
},
|
||||
once(event, handler) {
|
||||
const handlers = onceListeners.get(event) || []
|
||||
handlers.push(handler)
|
||||
onceListeners.set(event, handlers)
|
||||
return this
|
||||
},
|
||||
emit(event, payload) {
|
||||
state.emitted.push({ event, payload })
|
||||
if (event === 'resume') {
|
||||
const sessionId = payload && payload.session_id
|
||||
const resumes = window.__PW_CHAT_SOCKET_RESUMES__ || {}
|
||||
const response = sessionId ? resumes[sessionId] : null
|
||||
if (response) {
|
||||
setTimeout(() => this.__trigger('resumed', response), 0)
|
||||
}
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear()
|
||||
onceListeners.clear()
|
||||
return this
|
||||
},
|
||||
disconnect() {
|
||||
this.connected = false
|
||||
return this
|
||||
},
|
||||
__trigger(event, payload) {
|
||||
for (const handler of listeners.get(event) || []) handler(payload)
|
||||
const handlers = onceListeners.get(event) || []
|
||||
onceListeners.delete(event)
|
||||
for (const handler of handlers) handler(payload)
|
||||
},
|
||||
}
|
||||
state.sockets.push(socket)
|
||||
state.latest = socket
|
||||
return socket
|
||||
}
|
||||
export function io(url, options) {
|
||||
return makeSocket(url, options)
|
||||
}
|
||||
export default { io }
|
||||
`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function mockTerminalWebSocket(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const state = (window as any).__PW_TERMINAL_WS__ = {
|
||||
sockets: [] as any[],
|
||||
sent: [] as any[],
|
||||
createdCount: 0,
|
||||
latest: null as any,
|
||||
}
|
||||
const RealEvent = window.Event
|
||||
const RealMessageEvent = window.MessageEvent
|
||||
|
||||
class MockTerminalWebSocket extends EventTarget {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = 0
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
binaryType: BinaryType = 'blob'
|
||||
bufferedAmount = 0
|
||||
extensions = ''
|
||||
protocol = ''
|
||||
readyState = MockTerminalWebSocket.CONNECTING
|
||||
onopen: ((event: Event) => void) | null = null
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: ((event: Event) => void) | null = null
|
||||
onclose: ((event: CloseEvent) => void) | null = null
|
||||
|
||||
constructor(readonly url: string | URL) {
|
||||
super()
|
||||
state.sockets.push(this)
|
||||
state.latest = this
|
||||
setTimeout(() => {
|
||||
this.readyState = MockTerminalWebSocket.OPEN
|
||||
const openEvent = new RealEvent('open')
|
||||
this.onopen?.(openEvent)
|
||||
this.dispatchEvent(openEvent)
|
||||
this.__createSession('term-1', 'zsh', 101)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||
const normalized = typeof data === 'string' ? data : String(data)
|
||||
state.sent.push({ socket: this.url.toString(), data: normalized })
|
||||
if (normalized.charCodeAt(0) !== 0x7B) return
|
||||
try {
|
||||
const message = JSON.parse(normalized)
|
||||
if (message.type === 'create') {
|
||||
this.__createSession(`term-${state.createdCount + 1}`, 'bash', 200 + state.createdCount)
|
||||
}
|
||||
if (message.type === 'switch') {
|
||||
this.__emitMessage(JSON.stringify({ type: 'switched', id: message.sessionId }))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockTerminalWebSocket.CLOSED
|
||||
}
|
||||
|
||||
__createSession(id: string, shell: string, pid: number) {
|
||||
state.createdCount += 1
|
||||
this.__emitMessage(JSON.stringify({ type: 'created', id, shell, pid }))
|
||||
}
|
||||
|
||||
__emitMessage(data: string) {
|
||||
const event = new RealMessageEvent('message', { data })
|
||||
this.onmessage?.(event)
|
||||
this.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
;(window as any).WebSocket = MockTerminalWebSocket
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||
import { authenticate } from './fixtures'
|
||||
|
||||
const rooms = [
|
||||
{ id: 'room-alpha', name: 'Alpha Room', inviteCode: 'ALPHA1', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 123 },
|
||||
{ id: 'room-beta', name: 'Beta Room', inviteCode: 'BETA22', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 456 },
|
||||
]
|
||||
|
||||
const messagesByRoom: Record<string, unknown[]> = {
|
||||
'room-alpha': [
|
||||
{ id: 'alpha-msg', roomId: 'room-alpha', senderId: 'user-1', senderName: 'Alice', content: 'Alpha room message', timestamp: 1_790_000_000, role: 'user' },
|
||||
],
|
||||
'room-beta': [
|
||||
{ id: 'beta-msg', roomId: 'room-beta', senderId: 'user-1', senderName: 'Bob', content: 'Beta room message', timestamp: 1_790_000_100, role: 'user' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mockGroupChatApi(page: Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (pathname === '/health') return json({ status: 'ok' })
|
||||
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||
if (pathname === '/api/hermes/group-chat/rooms') return json({ rooms })
|
||||
|
||||
const detailMatch = pathname.match(/^\/api\/hermes\/group-chat\/rooms\/([^/]+)$/)
|
||||
if (detailMatch) {
|
||||
const roomId = decodeURIComponent(detailMatch[1])
|
||||
const room = rooms.find(r => r.id === roomId)
|
||||
return room
|
||||
? json({ room, messages: messagesByRoom[roomId] || [], agents: [], members: [{ id: 'member-1', userId: 'user-1', name: 'User One', description: '', joinedAt: 1_790_000_000 }] })
|
||||
: json({ error: 'Room not found' }, 404)
|
||||
}
|
||||
|
||||
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
async function mockGroupChatSocket(page: Page) {
|
||||
await page.route('**/node_modules/.vite/deps/socket__io-client.js*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
const state = window.__PW_GROUP_SOCKET__ || (window.__PW_GROUP_SOCKET__ = { sockets: [], emitted: [] })
|
||||
const roomMessages = ${JSON.stringify(messagesByRoom)}
|
||||
function makeSocket(url, options) {
|
||||
const listeners = new Map()
|
||||
const socket = {
|
||||
connected: true,
|
||||
url,
|
||||
options,
|
||||
on(event, handler) {
|
||||
const handlers = listeners.get(event) || []
|
||||
handlers.push(handler)
|
||||
listeners.set(event, handlers)
|
||||
return this
|
||||
},
|
||||
emit(event, payload, ack) {
|
||||
state.emitted.push({ event, payload })
|
||||
if (event === 'join' && typeof ack === 'function') {
|
||||
const roomId = payload && payload.roomId
|
||||
setTimeout(() => ack({ roomId, roomName: roomId, members: [], messages: roomMessages[roomId] || [], agents: [], rooms: [], typingUsers: [], contextStatuses: [] }), 0)
|
||||
}
|
||||
if (event === 'message' && typeof ack === 'function') {
|
||||
setTimeout(() => ack({ id: payload && payload.id }), 0)
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear()
|
||||
return this
|
||||
},
|
||||
disconnect() {
|
||||
this.connected = false
|
||||
return this
|
||||
},
|
||||
__trigger(event, payload) {
|
||||
for (const handler of listeners.get(event) || []) handler(payload)
|
||||
},
|
||||
}
|
||||
state.sockets.push(socket)
|
||||
state.latest = socket
|
||||
return socket
|
||||
}
|
||||
export function io(url, options) {
|
||||
return makeSocket(url, options)
|
||||
}
|
||||
export default { io }
|
||||
`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function setup(page: Page, path: string) {
|
||||
await authenticate(page)
|
||||
await mockGroupChatSocket(page)
|
||||
await mockGroupChatApi(page)
|
||||
await page.goto(path)
|
||||
}
|
||||
|
||||
test.describe('group chat room deep links', () => {
|
||||
test('route room id opens selected room', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/room-beta')
|
||||
|
||||
await expect(page.locator('.room-title-text', { hasText: 'Beta Room' })).toBeVisible()
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
})
|
||||
|
||||
test('clicking another room updates URL and reload preserves it', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/room-alpha')
|
||||
await expect(page.getByText('Alpha room message')).toBeVisible()
|
||||
|
||||
await page.getByText('Beta Room').click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
})
|
||||
|
||||
test('two tabs can show different rooms', async ({ context }) => {
|
||||
const first = await context.newPage()
|
||||
const second = await context.newPage()
|
||||
|
||||
await setup(first, '/#/hermes/group-chat/room/room-alpha')
|
||||
await setup(second, '/#/hermes/group-chat/room/room-beta')
|
||||
|
||||
await expect(first.getByText('Alpha room message')).toBeVisible()
|
||||
await expect(first.getByText('Beta room message')).toHaveCount(0)
|
||||
await expect(second.getByText('Beta room message')).toBeVisible()
|
||||
await expect(second.getByText('Alpha room message')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('unknown route room id falls back to base group chat route', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/missing-room')
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat$/)
|
||||
await expect(page.getByText('Alpha Room')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||
import { authenticate } from './fixtures'
|
||||
|
||||
const historySessions = [
|
||||
{
|
||||
id: 'hist-alpha',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title: 'Alpha History Session',
|
||||
preview: 'Alpha preview',
|
||||
started_at: 1_790_000_000,
|
||||
ended_at: null,
|
||||
last_active: 1_790_000_100,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
workspace: null,
|
||||
},
|
||||
{
|
||||
id: 'hist-beta',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title: 'Beta History Session',
|
||||
preview: 'Beta preview',
|
||||
started_at: 1_790_000_200,
|
||||
ended_at: null,
|
||||
last_active: 1_790_000_300,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 30,
|
||||
output_tokens: 40,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
workspace: null,
|
||||
},
|
||||
]
|
||||
|
||||
function detailFor(id: string) {
|
||||
const session = historySessions.find(s => s.id === id)
|
||||
if (!session) return null
|
||||
return {
|
||||
...session,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: id,
|
||||
role: 'user',
|
||||
content: `Question for ${session.title}`,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: session.started_at,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: id,
|
||||
role: 'assistant',
|
||||
content: `Answer from ${session.title}`,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: session.started_at + 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function mockHistoryApi(page: Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (pathname === '/health') return json({ status: 'ok' })
|
||||
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||
if (pathname === '/api/hermes/available-models') return json({ default: 'test-model', default_provider: 'test-provider', groups: [], allProviders: [], model_aliases: {}, model_visibility: {} })
|
||||
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||
if (pathname === '/api/hermes/sessions/hermes') return json({ sessions: historySessions })
|
||||
|
||||
const detailMatch = pathname.match(/^\/api\/hermes\/sessions\/hermes\/([^/]+)$/)
|
||||
if (detailMatch) {
|
||||
const detail = detailFor(decodeURIComponent(detailMatch[1]))
|
||||
return detail ? json({ session: detail }) : json({ error: 'Session not found' }, 404)
|
||||
}
|
||||
|
||||
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('history session deep links', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await authenticate(page)
|
||||
await mockHistoryApi(page)
|
||||
})
|
||||
|
||||
test('route session id opens selected history session', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/hist-beta')
|
||||
|
||||
await expect(page.getByText('Beta History Session').first()).toBeVisible()
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta$/)
|
||||
})
|
||||
|
||||
test('clicking another history session updates URL and reload preserves it', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/hist-alpha')
|
||||
await expect(page.getByText('Answer from Alpha History Session')).toBeVisible()
|
||||
|
||||
await page.getByText('Beta History Session').first().click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
})
|
||||
|
||||
test('unknown route session id falls back to base history route', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/missing-session')
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/history$/)
|
||||
await expect(page.getByText('Alpha History Session').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const sampleSession = {
|
||||
id: 'session-native-1',
|
||||
title: 'Native Link Session',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
profile: 'research',
|
||||
started_at: 1_700_000_000,
|
||||
ended_at: null,
|
||||
last_active: 1_700_000_100,
|
||||
message_count: 2,
|
||||
}
|
||||
|
||||
test('sidebar navigation exposes native links', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page)
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
const models = page.locator('aside.sidebar').getByRole('link', { name: /^Models$/ })
|
||||
await expect(models).toHaveAttribute('href', '#/hermes/models')
|
||||
|
||||
const history = page.locator('aside.sidebar').getByRole('link', { name: /^History$/ })
|
||||
await expect(history).toHaveAttribute('href', '#/hermes/history')
|
||||
})
|
||||
|
||||
test('session rows expose native session links', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page, { sessions: [sampleSession] })
|
||||
await page.goto('/#/hermes/chat')
|
||||
|
||||
const sessionLink = page.locator('.session-items a.session-item').first()
|
||||
await expect(sessionLink).toHaveAttribute('href', '#/hermes/session/session-native-1')
|
||||
await expect(sessionLink).toContainText('Native Link Session')
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('fetches custom provider models through the backend proxy', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY)
|
||||
const api = await mockHermesApi(page)
|
||||
|
||||
const thirdPartyRequests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
const url = request.url()
|
||||
if (url.startsWith('https://provider.example.test')) {
|
||||
thirdPartyRequests.push(url)
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto('/#/hermes/models')
|
||||
|
||||
await page.getByRole('button', { name: 'Add Provider' }).click()
|
||||
await page.getByRole('button', { name: 'Custom' }).click()
|
||||
await page.getByPlaceholder('e.g. https://api.example.com/v1').fill('https://provider.example.test/v1')
|
||||
await page.getByPlaceholder('sk-...').fill('test-provider-key')
|
||||
await page.getByRole('button', { name: 'Fetch' }).click()
|
||||
|
||||
await expect(page.getByText('Found 2 models')).toBeVisible()
|
||||
await expect(page.getByText('proxy-model-a')).toBeVisible()
|
||||
|
||||
const proxyRequest = api.requests.find((request) => request.pathname === '/api/hermes/provider-models')
|
||||
expect(proxyRequest).toBeTruthy()
|
||||
expect(proxyRequest?.method).toBe('POST')
|
||||
expect(proxyRequest?.headers.authorization).toBe(`Bearer ${TEST_ACCESS_KEY}`)
|
||||
expect(JSON.parse(proxyRequest?.postData || '{}')).toMatchObject({
|
||||
base_url: 'https://provider.example.test/v1',
|
||||
api_key: 'test-provider-key',
|
||||
})
|
||||
expect(thirdPartyRequests).toEqual([])
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockHermesApi, mockTerminalWebSocket, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('opens terminal websocket session and forwards user input', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
const api = await mockHermesApi(page)
|
||||
await mockTerminalWebSocket(page)
|
||||
|
||||
await page.goto('/#/hermes/terminal')
|
||||
|
||||
await expect(page.getByText('Sessions')).toBeVisible()
|
||||
await expect(page.locator('.session-item-title', { hasText: 'zsh #1' })).toBeVisible()
|
||||
|
||||
const terminalState = await page.waitForFunction(() => {
|
||||
const state = (window as any).__PW_TERMINAL_WS__
|
||||
return state?.sockets?.length
|
||||
? {
|
||||
url: state.latest.url,
|
||||
sent: state.sent,
|
||||
}
|
||||
: null
|
||||
})
|
||||
const initialState = await terminalState.jsonValue() as any
|
||||
const terminalUrl = new URL(initialState.url)
|
||||
expect(terminalUrl.pathname).toBe('/api/hermes/terminal')
|
||||
expect(terminalUrl.searchParams.get('token')).toBe(TEST_ACCESS_KEY)
|
||||
|
||||
await page.locator('.terminal-header .header-actions button').last().click()
|
||||
await expect(page.locator('.session-item-title', { hasText: 'bash #2' })).toBeVisible()
|
||||
|
||||
await page.locator('.terminal-xterm').click()
|
||||
await page.keyboard.type('pwd')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const state = (window as any).__PW_TERMINAL_WS__
|
||||
return state.sent
|
||||
.map((item: any) => item.data)
|
||||
.filter((data: string) => !data.startsWith('{'))
|
||||
.join('')
|
||||
})).toContain('pwd')
|
||||
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const state = (window as any).__PW_TERMINAL_WS__
|
||||
return state.sent
|
||||
.map((item: any) => item.data)
|
||||
.filter((data: string) => data.startsWith('{'))
|
||||
.map((data: string) => JSON.parse(data))
|
||||
.some((message: any) => message.type === 'resize' && message.cols > 0 && message.rows > 0)
|
||||
})).toBe(true)
|
||||
|
||||
const finalState = await page.evaluate(() => (window as any).__PW_TERMINAL_WS__)
|
||||
const controlMessages = finalState.sent
|
||||
.map((item: any) => item.data)
|
||||
.filter((data: string) => data.startsWith('{'))
|
||||
.map((data: string) => JSON.parse(data))
|
||||
|
||||
expect(controlMessages.some((message: any) => message.type === 'create')).toBe(true)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('AgentBridgeClient clarify responses', () => {
|
||||
it('sends clarify_respond requests to the bridge', async () => {
|
||||
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
||||
const client = new AgentBridgeClient({ endpoint: 'tcp://127.0.0.1:1', connectRetryMs: 0, timeoutMs: 1 })
|
||||
const request = vi.spyOn(client, 'request').mockResolvedValue({ ok: true, resolved: true })
|
||||
|
||||
await expect(client.clarifyRespond('clarify-1', 'Use the first option')).resolves.toEqual({
|
||||
ok: true,
|
||||
resolved: true,
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
action: 'clarify_respond',
|
||||
clarify_id: 'clarify-1',
|
||||
response: 'Use the first option',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { createServer, type Server } from 'net'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function uvToolHermesPythonPath(): string | undefined {
|
||||
if (process.platform !== 'win32') return undefined
|
||||
const candidate = join(process.env.APPDATA || '', 'uv', 'tools', 'hermes-agent', 'Scripts', 'python.exe')
|
||||
return existsSync(candidate) ? candidate : undefined
|
||||
}
|
||||
|
||||
describe('agent bridge manager command resolution', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'hermes-agent-bridge-manager-'))
|
||||
process.env = { ...originalEnv }
|
||||
delete process.env.HERMES_AGENT_ROOT
|
||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
delete process.env.HERMES_AGENT_BRIDGE_UV
|
||||
delete process.env.UV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('uses the installed hermes command Python when no source root exists', async () => {
|
||||
if (uvToolHermesPythonPath()) return
|
||||
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const homeDir = join(tempDir, 'home')
|
||||
const fakePython = join(binDir, 'python')
|
||||
const fakeHermes = join(binDir, 'hermes')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
mkdirSync(homeDir, { recursive: true })
|
||||
writeFileSync(fakePython, '#!/bin/sh\n')
|
||||
chmodSync(fakePython, 0o755)
|
||||
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||
chmodSync(fakeHermes, 0o755)
|
||||
process.env.HERMES_HOME = homeDir
|
||||
process.env.HERMES_BIN = fakeHermes
|
||||
|
||||
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||
const command = resolveAgentBridgeCommand()
|
||||
|
||||
expect(command).toEqual({
|
||||
command: fakePython,
|
||||
argsPrefix: [],
|
||||
agentRoot: undefined,
|
||||
hermesHome: homeDir,
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers hermes-agent from a global lib install next to the hermes command', async () => {
|
||||
const installDir = join(tempDir, 'usr', 'local')
|
||||
const binDir = join(installDir, 'bin')
|
||||
const agentRoot = join(installDir, 'lib', 'hermes-agent')
|
||||
const fakePython = join(binDir, 'python')
|
||||
const fakeHermes = join(binDir, 'hermes')
|
||||
const homeDir = join(tempDir, 'home')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
mkdirSync(agentRoot, { recursive: true })
|
||||
mkdirSync(homeDir, { recursive: true })
|
||||
writeFileSync(join(agentRoot, 'run_agent.py'), '')
|
||||
writeFileSync(fakePython, '#!/bin/sh\n')
|
||||
chmodSync(fakePython, 0o755)
|
||||
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||
chmodSync(fakeHermes, 0o755)
|
||||
process.env.HERMES_HOME = homeDir
|
||||
process.env.HERMES_BIN = fakeHermes
|
||||
|
||||
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||
const command = resolveAgentBridgeCommand()
|
||||
|
||||
expect(command.agentRoot).toBe(agentRoot)
|
||||
})
|
||||
|
||||
it('falls back to system Python instead of uv when no source root exists', async () => {
|
||||
if (uvToolHermesPythonPath()) return
|
||||
|
||||
const homeDir = join(tempDir, 'home')
|
||||
const fakePython = join(tempDir, 'python3')
|
||||
mkdirSync(homeDir, { recursive: true })
|
||||
writeFileSync(fakePython, '#!/bin/sh\n')
|
||||
chmodSync(fakePython, 0o755)
|
||||
process.env.HERMES_HOME = homeDir
|
||||
process.env.HERMES_BIN = join(tempDir, 'missing-hermes')
|
||||
process.env.PYTHON = fakePython
|
||||
|
||||
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||
const command = resolveAgentBridgeCommand()
|
||||
|
||||
expect(command).toEqual({
|
||||
command: fakePython,
|
||||
argsPrefix: [],
|
||||
agentRoot: undefined,
|
||||
hermesHome: homeDir,
|
||||
})
|
||||
})
|
||||
|
||||
it('injects Web UI OpenRouter attribution into the bridge process env by default', async () => {
|
||||
const { buildAgentBridgeProcessEnv } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||
const env = buildAgentBridgeProcessEnv('ipc:///tmp/test.sock', '/tmp/hermes-home', '/tmp/hermes-agent')
|
||||
|
||||
expect(env.HERMES_OPENROUTER_APP_REFERER).toBe('https://hermes-studio.ai')
|
||||
expect(env.HERMES_OPENROUTER_APP_TITLE).toBe('Hermes Web UI')
|
||||
expect(env.HERMES_OPENROUTER_APP_CATEGORIES).toBe('cli-agent,personal-agent')
|
||||
})
|
||||
|
||||
it('keeps explicit OpenRouter attribution env values when starting the bridge', async () => {
|
||||
process.env.HERMES_OPENROUTER_APP_REFERER = 'https://example.invalid/app'
|
||||
process.env.HERMES_OPENROUTER_APP_TITLE = 'Custom App'
|
||||
process.env.HERMES_OPENROUTER_APP_CATEGORIES = 'custom-category'
|
||||
|
||||
const { buildAgentBridgeProcessEnv } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||
const env = buildAgentBridgeProcessEnv('ipc:///tmp/test.sock', '/tmp/hermes-home', undefined)
|
||||
|
||||
expect(env.HERMES_OPENROUTER_APP_REFERER).toBe('https://example.invalid/app')
|
||||
expect(env.HERMES_OPENROUTER_APP_TITLE).toBe('Custom App')
|
||||
expect(env.HERMES_OPENROUTER_APP_CATEGORIES).toBe('custom-category')
|
||||
})
|
||||
|
||||
it('uses an isolated default bridge endpoint while running under Vitest', async () => {
|
||||
const { DEFAULT_AGENT_BRIDGE_ENDPOINT } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).toMatch(/^tcp:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).not.toBe('tcp://127.0.0.1:18765')
|
||||
} else {
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).toContain(`hermes-agent-bridge-test-${process.pid}`)
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).not.toBe('ipc:///tmp/hermes-agent-bridge.sock')
|
||||
}
|
||||
})
|
||||
|
||||
it('prefers uv tool install Python when available on Windows', async () => {
|
||||
const uvPython = uvToolHermesPythonPath()
|
||||
if (!uvPython) return
|
||||
|
||||
const homeDir = join(tempDir, 'home')
|
||||
mkdirSync(homeDir, { recursive: true })
|
||||
process.env.HERMES_HOME = homeDir
|
||||
|
||||
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||
const command = resolveAgentBridgeCommand()
|
||||
|
||||
expect(command.command).toBe(uvPython)
|
||||
})
|
||||
|
||||
it('honors the bridge connect retry environment override', async () => {
|
||||
process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS = '120000'
|
||||
|
||||
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
||||
const client = new AgentBridgeClient({ endpoint: 'tcp://127.0.0.1:1' })
|
||||
|
||||
expect(client.connectRetryMs).toBe(120000)
|
||||
})
|
||||
|
||||
it('waits briefly for a restarting bridge socket before failing', async () => {
|
||||
const endpoint = process.platform === 'win32'
|
||||
? `tcp://127.0.0.1:${32000 + (process.pid % 10000)}`
|
||||
: `ipc://${join(tempDir, 'late-bridge.sock')}`
|
||||
let server: Server | undefined
|
||||
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
server = createServer((socket) => {
|
||||
socket.once('data', () => {
|
||||
socket.end(`${JSON.stringify({ ok: true, pong: true })}\n`)
|
||||
})
|
||||
})
|
||||
if (endpoint.startsWith('ipc://')) {
|
||||
server.listen(endpoint.slice('ipc://'.length), resolve)
|
||||
} else {
|
||||
const url = new URL(endpoint)
|
||||
server.listen(Number(url.port), url.hostname, resolve)
|
||||
}
|
||||
}, 150)
|
||||
})
|
||||
|
||||
try {
|
||||
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
||||
const client = new AgentBridgeClient({ endpoint, connectRetryMs: 1000, timeoutMs: 1000 })
|
||||
await expect(client.ping()).resolves.toMatchObject({ ok: true, pong: true })
|
||||
await ready
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()) ?? resolve())
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function runPython(script: string): any {
|
||||
try {
|
||||
const output = execFileSync('python3', ['-c', script], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return JSON.parse(output)
|
||||
} catch (error) {
|
||||
const err = error as { stdout?: string; stderr?: string; message?: string }
|
||||
throw new Error([
|
||||
err.message || 'Python bridge MCP filter script failed',
|
||||
err.stdout ? `stdout:\n${err.stdout}` : '',
|
||||
err.stderr ? `stderr:\n${err.stderr}` : '',
|
||||
].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent bridge MCP tools filtering', () => {
|
||||
it('treats an empty include list as an active filter and keeps raw listing unfiltered', () => {
|
||||
const result = runPython(String.raw`
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("packages/server/src/services/hermes/agent-bridge/hermes_bridge.py")
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", path)
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
class Tool:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.description = f"{name} description"
|
||||
self.inputSchema = {"type": "object"}
|
||||
|
||||
class Task:
|
||||
_task = None
|
||||
_error = None
|
||||
|
||||
def __init__(self):
|
||||
self._tools = [Tool("read_file"), Tool("write_file"), Tool("delete_file")]
|
||||
self._registered_tool_names = ["read_file", "write_file", "delete_file"]
|
||||
self._config = {"command": "mcp-server"}
|
||||
|
||||
server = bridge.BridgeServer("tcp://127.0.0.1:0")
|
||||
servers = {"fs": Task()}
|
||||
lock = threading.RLock()
|
||||
|
||||
def names(response):
|
||||
return [tool["name"] for tool in response["results"][0]["tools"]]
|
||||
|
||||
server._read_mcp_config = lambda profile: {
|
||||
"mcp_servers": {
|
||||
"fs": {
|
||||
"command": "mcp-server",
|
||||
"tools": {"include": []},
|
||||
},
|
||||
},
|
||||
}
|
||||
include_empty = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
|
||||
include_empty_list = server._mcp_list("default", servers, lock)
|
||||
include_empty_raw = server._mcp_tools_list({"server": "fs", "raw": True}, "default", servers, lock)
|
||||
|
||||
server._read_mcp_config = lambda profile: {
|
||||
"mcp_servers": {
|
||||
"fs": {
|
||||
"command": "mcp-server",
|
||||
"tools": {"include": ["read_file"]},
|
||||
},
|
||||
},
|
||||
}
|
||||
include_one = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
|
||||
|
||||
server._read_mcp_config = lambda profile: {
|
||||
"mcp_servers": {
|
||||
"fs": {
|
||||
"command": "mcp-server",
|
||||
"tools": {"exclude": ["delete_file"]},
|
||||
},
|
||||
},
|
||||
}
|
||||
exclude_one = server._mcp_tools_list({"server": "fs"}, "default", servers, lock)
|
||||
|
||||
print(json.dumps({
|
||||
"include_empty": names(include_empty),
|
||||
"include_empty_details": include_empty_list["servers"][0]["tool_details"],
|
||||
"include_empty_raw": names(include_empty_raw),
|
||||
"include_one": names(include_one),
|
||||
"exclude_one": names(exclude_one),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
include_empty: [],
|
||||
include_empty_details: [],
|
||||
include_empty_raw: ['read_file', 'write_file', 'delete_file'],
|
||||
include_one: ['read_file'],
|
||||
exclude_one: ['read_file', 'write_file'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,572 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'hermes-bridge-profile-env-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = ''
|
||||
})
|
||||
|
||||
async function runBridgeProbe(script: string): Promise<any> {
|
||||
const bridgePath = resolve('packages/server/src/services/hermes/agent-bridge/hermes_bridge.py')
|
||||
const { stdout } = await execFileAsync('python3', ['-c', script], {
|
||||
cwd: resolve('.'),
|
||||
env: {
|
||||
...process.env,
|
||||
BRIDGE_PATH: bridgePath,
|
||||
TEST_HERMES_HOME: tempDir,
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
}
|
||||
|
||||
describe('agent bridge JSON encoding', () => {
|
||||
it('replaces lone surrogate characters before bridge socket writes', async () => {
|
||||
const result = await runBridgeProbe(String.raw`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
self._read = False
|
||||
|
||||
def sendall(self, payload):
|
||||
self.sent.append(payload)
|
||||
|
||||
def recv(self, size):
|
||||
if self._read:
|
||||
return b""
|
||||
self._read = True
|
||||
return b'{"ok":true}\n'
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.sent = b""
|
||||
|
||||
def sendall(self, payload):
|
||||
self.sent += payload
|
||||
|
||||
fake_socket = FakeSocket()
|
||||
bridge._connect_bridge_socket = lambda endpoint, timeout: fake_socket
|
||||
bridge._send_bridge_request("tcp://127.0.0.1:1", {
|
||||
"message": "request-\ud800",
|
||||
"items": ["nested-\udfff"],
|
||||
}, 1)
|
||||
|
||||
fake_conn = FakeConn()
|
||||
bridge._write_json_response(fake_conn, {
|
||||
"ok": True,
|
||||
"message": "response-\udc00",
|
||||
"nested": {"key-\ud800": "value-\udfff"},
|
||||
})
|
||||
|
||||
print(json.dumps({
|
||||
"request": json.loads(fake_socket.sent[0].decode("utf-8")),
|
||||
"response": json.loads(fake_conn.sent.decode("utf-8")),
|
||||
"closed": fake_socket.closed,
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
request: {
|
||||
message: 'request-\uFFFD',
|
||||
items: ['nested-\uFFFD'],
|
||||
},
|
||||
response: {
|
||||
ok: true,
|
||||
message: 'response-\uFFFD',
|
||||
nested: { 'key-\uFFFD': 'value-\uFFFD' },
|
||||
},
|
||||
closed: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent bridge Windows desktop subprocess defaults', () => {
|
||||
it('adds CREATE_NO_WINDOW to sync and async nested subprocesses without replacing existing flags', async () => {
|
||||
const result = await runBridgeProbe(String.raw`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
original_os_name = bridge.os.name
|
||||
original_popen = bridge.subprocess.Popen
|
||||
original_async_exec = bridge.asyncio.create_subprocess_exec
|
||||
original_async_shell = bridge.asyncio.create_subprocess_shell
|
||||
original_create_no_window = getattr(bridge.subprocess, "CREATE_NO_WINDOW", None)
|
||||
original_startupinfo = getattr(bridge.subprocess, "STARTUPINFO", None)
|
||||
original_startf = getattr(bridge.subprocess, "STARTF_USESHOWWINDOW", None)
|
||||
original_sw_hide = getattr(bridge.subprocess, "SW_HIDE", None)
|
||||
original_installed = getattr(bridge.subprocess, "_hermes_hidden_defaults_installed", None)
|
||||
|
||||
class FakePopen:
|
||||
calls = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
FakePopen.calls.append({"args": args, "kwargs": kwargs})
|
||||
|
||||
async_calls = []
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
async_calls.append({"kind": "exec", "args": args, "kwargs": kwargs})
|
||||
return {"kind": "exec"}
|
||||
|
||||
async def fake_create_subprocess_shell(*args, **kwargs):
|
||||
async_calls.append({"kind": "shell", "args": args, "kwargs": kwargs})
|
||||
return {"kind": "shell"}
|
||||
|
||||
class FakeStartupInfo:
|
||||
def __init__(self):
|
||||
self.dwFlags = 0
|
||||
self.wShowWindow = None
|
||||
|
||||
try:
|
||||
bridge.os.name = "nt"
|
||||
bridge.os.environ["HERMES_DESKTOP"] = "true"
|
||||
bridge.subprocess.Popen = FakePopen
|
||||
bridge.asyncio.create_subprocess_exec = fake_create_subprocess_exec
|
||||
bridge.asyncio.create_subprocess_shell = fake_create_subprocess_shell
|
||||
bridge.subprocess.CREATE_NO_WINDOW = 0x08000000
|
||||
bridge.subprocess.STARTUPINFO = FakeStartupInfo
|
||||
bridge.subprocess.STARTF_USESHOWWINDOW = 0x00000001
|
||||
bridge.subprocess.SW_HIDE = 0
|
||||
if hasattr(bridge.subprocess, "_hermes_hidden_defaults_installed"):
|
||||
delattr(bridge.subprocess, "_hermes_hidden_defaults_installed")
|
||||
|
||||
bridge._install_windows_hidden_subprocess_defaults()
|
||||
bridge.subprocess.Popen(["git", "status"], creationflags=0x00000200)
|
||||
flags = FakePopen.calls[0]["kwargs"]["creationflags"]
|
||||
startupinfo = FakePopen.calls[0]["kwargs"]["startupinfo"]
|
||||
bridge.asyncio.run(bridge.asyncio.create_subprocess_exec("git", "status", creationflags=0x00000400))
|
||||
bridge.asyncio.run(bridge.asyncio.create_subprocess_shell("git status"))
|
||||
async_exec_flags = async_calls[0]["kwargs"]["creationflags"]
|
||||
async_exec_startupinfo = async_calls[0]["kwargs"]["startupinfo"]
|
||||
async_shell_flags = async_calls[1]["kwargs"]["creationflags"]
|
||||
async_shell_startupinfo = async_calls[1]["kwargs"]["startupinfo"]
|
||||
finally:
|
||||
bridge.os.name = original_os_name
|
||||
bridge.subprocess.Popen = original_popen
|
||||
bridge.asyncio.create_subprocess_exec = original_async_exec
|
||||
bridge.asyncio.create_subprocess_shell = original_async_shell
|
||||
if original_create_no_window is None:
|
||||
try:
|
||||
delattr(bridge.subprocess, "CREATE_NO_WINDOW")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
bridge.subprocess.CREATE_NO_WINDOW = original_create_no_window
|
||||
for name, original in [
|
||||
("STARTUPINFO", original_startupinfo),
|
||||
("STARTF_USESHOWWINDOW", original_startf),
|
||||
("SW_HIDE", original_sw_hide),
|
||||
]:
|
||||
if original is None:
|
||||
try:
|
||||
delattr(bridge.subprocess, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
setattr(bridge.subprocess, name, original)
|
||||
if original_installed is None:
|
||||
try:
|
||||
delattr(bridge.subprocess, "_hermes_hidden_defaults_installed")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
bridge.subprocess._hermes_hidden_defaults_installed = original_installed
|
||||
|
||||
print(json.dumps({
|
||||
"flags": flags,
|
||||
"has_create_no_window": bool(flags & 0x08000000),
|
||||
"kept_existing_flag": bool(flags & 0x00000200),
|
||||
"startupinfo_hidden": bool(startupinfo.dwFlags & 0x00000001) and startupinfo.wShowWindow == 0,
|
||||
"async_exec_flags": async_exec_flags,
|
||||
"async_exec_has_create_no_window": bool(async_exec_flags & 0x08000000),
|
||||
"async_exec_kept_existing_flag": bool(async_exec_flags & 0x00000400),
|
||||
"async_exec_startupinfo_hidden": bool(async_exec_startupinfo.dwFlags & 0x00000001) and async_exec_startupinfo.wShowWindow == 0,
|
||||
"async_shell_flags": async_shell_flags,
|
||||
"async_shell_has_create_no_window": bool(async_shell_flags & 0x08000000),
|
||||
"async_shell_startupinfo_hidden": bool(async_shell_startupinfo.dwFlags & 0x00000001) and async_shell_startupinfo.wShowWindow == 0,
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
flags: 0x08000200,
|
||||
has_create_no_window: true,
|
||||
kept_existing_flag: true,
|
||||
startupinfo_hidden: true,
|
||||
async_exec_flags: 0x08000400,
|
||||
async_exec_has_create_no_window: true,
|
||||
async_exec_kept_existing_flag: true,
|
||||
async_exec_startupinfo_hidden: true,
|
||||
async_shell_flags: 0x08000000,
|
||||
async_shell_has_create_no_window: true,
|
||||
async_shell_startupinfo_hidden: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent bridge profile environment', () => {
|
||||
it('runs agent calls with the requested profile HERMES_HOME and restores the bridge home', async () => {
|
||||
const profileHome = join(tempDir, 'profiles', 'work')
|
||||
await mkdir(profileHome, { recursive: true })
|
||||
await writeFile(join(tempDir, 'config.yaml'), 'model:\n default: default-model\n', 'utf-8')
|
||||
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\nBASE_ONLY_TOKEN=base-token\n', 'utf-8')
|
||||
await writeFile(join(profileHome, 'config.yaml'), 'model:\n default: work-model\n', 'utf-8')
|
||||
await writeFile(join(profileHome, '.env'), 'GLM_API_KEY=work-glm\n', 'utf-8')
|
||||
const expectedProfileHome = await realpath(profileHome)
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
profile_home = os.path.join(root, "profiles", "work")
|
||||
os.environ["HERMES_HOME"] = root
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = root
|
||||
os.environ["OPENAI_API_KEY"] = "shell-openai"
|
||||
os.environ["GLM_API_KEY"] = "shell-glm"
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self):
|
||||
self.seen_home = None
|
||||
self.seen_openai = None
|
||||
self.seen_glm = None
|
||||
self.seen_base_only = None
|
||||
|
||||
def run_conversation(self, message, **kwargs):
|
||||
self.seen_home = os.environ.get("HERMES_HOME")
|
||||
self.seen_openai = os.environ.get("OPENAI_API_KEY")
|
||||
self.seen_glm = os.environ.get("GLM_API_KEY")
|
||||
self.seen_base_only = os.environ.get("BASE_ONLY_TOKEN")
|
||||
return {"messages": [{"role": "assistant", "content": "ok"}]}
|
||||
|
||||
agent = FakeAgent()
|
||||
with bridge._profile_env("work"):
|
||||
result = agent.run_conversation("hello")
|
||||
|
||||
print(json.dumps({
|
||||
"seen_home": agent.seen_home,
|
||||
"seen_openai": agent.seen_openai,
|
||||
"seen_glm": agent.seen_glm,
|
||||
"seen_base_only": agent.seen_base_only,
|
||||
"restored_home": os.environ.get("HERMES_HOME"),
|
||||
"restored_openai": os.environ.get("OPENAI_API_KEY"),
|
||||
"restored_glm": os.environ.get("GLM_API_KEY"),
|
||||
"restored_base_only": os.environ.get("BASE_ONLY_TOKEN"),
|
||||
"status": "complete" if result.get("messages") else "error",
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
seen_home: expectedProfileHome,
|
||||
seen_openai: null,
|
||||
seen_glm: 'work-glm',
|
||||
seen_base_only: null,
|
||||
restored_home: tempDir,
|
||||
restored_openai: 'shell-openai',
|
||||
restored_glm: 'shell-glm',
|
||||
restored_base_only: null,
|
||||
status: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a profile-scoped bridge home back to the Hermes root for profile lookup', async () => {
|
||||
const agentRoot = join(tempDir, 'hermes-agent')
|
||||
const profileHome = join(tempDir, 'profiles', 'work')
|
||||
await mkdir(agentRoot, { recursive: true })
|
||||
await mkdir(profileHome, { recursive: true })
|
||||
await writeFile(join(agentRoot, 'run_agent.py'), '', 'utf-8')
|
||||
await writeFile(join(profileHome, 'config.yaml'), 'model:\n default: work-model\n', 'utf-8')
|
||||
const expectedRoot = await realpath(tempDir)
|
||||
const expectedProfileHome = await realpath(profileHome)
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
agent_root = os.path.join(root, "hermes-agent")
|
||||
profile_home = os.path.join(root, "profiles", "work")
|
||||
bridge._set_path_env(agent_root, profile_home)
|
||||
|
||||
print(json.dumps({
|
||||
"home": os.environ.get("HERMES_HOME"),
|
||||
"base": os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME"),
|
||||
"profile_home": str(bridge._profile_home("work")),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
home: expectedProfileHome,
|
||||
base: expectedRoot,
|
||||
profile_home: expectedProfileHome,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to package imports when no Hermes Agent source root exists', async () => {
|
||||
const packageDir = join(tempDir, 'site-packages')
|
||||
const hermesHome = join(tempDir, 'home')
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await mkdir(hermesHome, { recursive: true })
|
||||
await writeFile(join(packageDir, 'run_agent.py'), 'class AIAgent: pass\n', 'utf-8')
|
||||
const expectedHermesHome = await realpath(hermesHome)
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
package_dir = os.path.join(os.environ["TEST_HERMES_HOME"], "site-packages")
|
||||
hermes_home = os.path.join(os.environ["TEST_HERMES_HOME"], "home")
|
||||
sys.path.insert(0, package_dir)
|
||||
bridge._candidate_agent_roots = lambda raw=None: []
|
||||
os.environ.pop("HERMES_AGENT_ROOT", None)
|
||||
|
||||
bridge._set_path_env(None, hermes_home)
|
||||
bridge._ensure_agent_imports()
|
||||
from run_agent import AIAgent
|
||||
|
||||
print(json.dumps({
|
||||
"agent_root": os.environ.get("HERMES_AGENT_ROOT"),
|
||||
"home": os.environ.get("HERMES_HOME"),
|
||||
"base": os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME"),
|
||||
"agent_class": AIAgent.__name__,
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_root: null,
|
||||
home: expectedHermesHome,
|
||||
base: expectedHermesHome,
|
||||
agent_class: 'AIAgent',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps inherited profile env keys for default profile compatibility', async () => {
|
||||
await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true })
|
||||
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\n', 'utf-8')
|
||||
await writeFile(join(tempDir, 'profiles', 'work', '.env'), 'GLM_API_KEY=work-glm\n', 'utf-8')
|
||||
await writeFile(join(tempDir, 'config.yaml'), 'model:\n default: default-model\n', 'utf-8')
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
os.environ["HERMES_HOME"] = root
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = root
|
||||
os.environ["OPENAI_API_KEY"] = "shell-openai"
|
||||
os.environ["GLM_API_KEY"] = "shell-glm"
|
||||
|
||||
with bridge._profile_env("default"):
|
||||
inside = {
|
||||
"openai": os.environ.get("OPENAI_API_KEY"),
|
||||
"glm": os.environ.get("GLM_API_KEY"),
|
||||
}
|
||||
|
||||
print(json.dumps({
|
||||
"inside": inside,
|
||||
"restored_openai": os.environ.get("OPENAI_API_KEY"),
|
||||
"restored_glm": os.environ.get("GLM_API_KEY"),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
inside: {
|
||||
openai: 'default-openai',
|
||||
glm: 'shell-glm',
|
||||
},
|
||||
restored_openai: 'shell-openai',
|
||||
restored_glm: 'shell-glm',
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers MCP tools in the active profile before creating an agent', async () => {
|
||||
const profileHome = join(tempDir, 'profiles', 'work')
|
||||
await mkdir(profileHome, { recursive: true })
|
||||
await writeFile(join(profileHome, 'config.yaml'), 'model:\n default: work-model\n', 'utf-8')
|
||||
const expectedProfileHome = await realpath(profileHome)
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
os.environ["HERMES_HOME"] = root
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = root
|
||||
|
||||
events = []
|
||||
|
||||
tools_pkg = types.ModuleType("tools")
|
||||
tools_pkg.__path__ = []
|
||||
sys.modules["tools"] = tools_pkg
|
||||
|
||||
mcp_tool = types.ModuleType("tools.mcp_tool")
|
||||
def discover_mcp_tools():
|
||||
events.append({"event": "discover", "home": os.environ.get("HERMES_HOME")})
|
||||
return ["mcp_anysearch_search"]
|
||||
mcp_tool.discover_mcp_tools = discover_mcp_tools
|
||||
sys.modules["tools.mcp_tool"] = mcp_tool
|
||||
|
||||
run_agent = types.ModuleType("run_agent")
|
||||
class FakeAgent:
|
||||
def __init__(self, **kwargs):
|
||||
events.append({
|
||||
"event": "agent",
|
||||
"home": os.environ.get("HERMES_HOME"),
|
||||
"enabled_toolsets": kwargs.get("enabled_toolsets"),
|
||||
})
|
||||
self.tools = []
|
||||
run_agent.AIAgent = FakeAgent
|
||||
sys.modules["run_agent"] = run_agent
|
||||
|
||||
class FakeDbHolder:
|
||||
error = None
|
||||
def get_for_profile(self, profile):
|
||||
return None
|
||||
|
||||
bridge._ensure_agent_imports = lambda: None
|
||||
bridge._load_cfg = lambda: {"model": {"default": "work-model"}, "agent": {}}
|
||||
bridge._resolve_runtime = lambda model, provider=None: {"provider": "fake"}
|
||||
bridge._load_enabled_toolsets = lambda: ["mcp-anysearch"]
|
||||
bridge._load_reasoning_config = lambda: None
|
||||
bridge._load_service_tier = lambda: None
|
||||
|
||||
pool = bridge.AgentPool()
|
||||
pool._db = FakeDbHolder()
|
||||
session = pool.get_or_create("session-1", profile="work")
|
||||
|
||||
print(json.dumps({
|
||||
"events": events,
|
||||
"mcp_tool_count": session.config.get("mcp_tool_count"),
|
||||
"restored_home": os.environ.get("HERMES_HOME"),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
events: [
|
||||
{ event: 'discover', home: expectedProfileHome },
|
||||
{ event: 'agent', home: expectedProfileHome, enabled_toolsets: ['mcp-anysearch'] },
|
||||
],
|
||||
mcp_tool_count: 1,
|
||||
restored_home: tempDir,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles Windows netstat output decode failures without crashing', async () => {
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
class EmptyStdoutResult:
|
||||
stdout = None
|
||||
|
||||
def fake_run_empty(*args, **kwargs):
|
||||
return EmptyStdoutResult()
|
||||
|
||||
class NetstatResult:
|
||||
stdout = " TCP 127.0.0.1:18765 0.0.0.0:0 LISTENING 4321\\r\\n"
|
||||
|
||||
def fake_run_listener(*args, **kwargs):
|
||||
return NetstatResult()
|
||||
|
||||
original_name = bridge.os.name
|
||||
original_pid = bridge.os.getpid
|
||||
original_run = bridge.subprocess.run
|
||||
try:
|
||||
bridge.os.name = "nt"
|
||||
bridge.os.getpid = lambda: 1234
|
||||
bridge.subprocess.run = fake_run_empty
|
||||
empty = bridge._windows_listening_pids_on_port(18765)
|
||||
bridge.subprocess.run = fake_run_listener
|
||||
listener = bridge._windows_listening_pids_on_port(18765)
|
||||
finally:
|
||||
bridge.os.name = original_name
|
||||
bridge.os.getpid = original_pid
|
||||
bridge.subprocess.run = original_run
|
||||
|
||||
print(json.dumps({
|
||||
"empty": empty,
|
||||
"listener": listener,
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
empty: [],
|
||||
listener: [4321],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,765 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
function runPython(script: string): void {
|
||||
try {
|
||||
execFileSync('python3', ['-c', script], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
})
|
||||
} catch (error) {
|
||||
const err = error as { stdout?: string; stderr?: string; message?: string }
|
||||
throw new Error([
|
||||
err.message || 'Python bridge concurrency script failed',
|
||||
err.stdout ? `stdout:\n${err.stdout}` : '',
|
||||
err.stderr ? `stderr:\n${err.stderr}` : '',
|
||||
].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
}
|
||||
|
||||
const harness = String.raw`
|
||||
import contextvars
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = "default"
|
||||
|
||||
tools_pkg = types.ModuleType("tools")
|
||||
tools_pkg.__path__ = []
|
||||
sys.modules["tools"] = tools_pkg
|
||||
|
||||
terminal_tool = types.ModuleType("tools.terminal_tool")
|
||||
terminal_tool._callback_tls = threading.local()
|
||||
|
||||
def set_approval_callback(callback):
|
||||
terminal_tool._callback_tls.callback = callback
|
||||
|
||||
def _get_approval_callback():
|
||||
return getattr(terminal_tool._callback_tls, "callback", None)
|
||||
|
||||
terminal_tool.set_approval_callback = set_approval_callback
|
||||
terminal_tool._get_approval_callback = _get_approval_callback
|
||||
sys.modules["tools.terminal_tool"] = terminal_tool
|
||||
|
||||
approval = types.ModuleType("tools.approval")
|
||||
approval._session_key = contextvars.ContextVar("approval_session_key", default="")
|
||||
approval._notify = {}
|
||||
approval._resolved_gateway = []
|
||||
|
||||
def set_current_session_key(session_key):
|
||||
return approval._session_key.set(session_key or "")
|
||||
|
||||
def reset_current_session_key(token):
|
||||
approval._session_key.reset(token)
|
||||
|
||||
def get_current_session_key(default=""):
|
||||
return approval._session_key.get() or default
|
||||
|
||||
def register_gateway_notify(session_key, callback):
|
||||
approval._notify[session_key] = callback
|
||||
|
||||
def unregister_gateway_notify(session_key):
|
||||
approval._notify.pop(session_key, None)
|
||||
|
||||
def resolve_gateway_approval(session_key, choice):
|
||||
approval._resolved_gateway.append((session_key, choice))
|
||||
return 1
|
||||
|
||||
approval.set_current_session_key = set_current_session_key
|
||||
approval.reset_current_session_key = reset_current_session_key
|
||||
approval.get_current_session_key = get_current_session_key
|
||||
approval.register_gateway_notify = register_gateway_notify
|
||||
approval.unregister_gateway_notify = unregister_gateway_notify
|
||||
approval.resolve_gateway_approval = resolve_gateway_approval
|
||||
sys.modules["tools.approval"] = approval
|
||||
|
||||
path = Path("packages/server/src/services/hermes/agent-bridge/hermes_bridge.py")
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", path)
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.messages = {}
|
||||
self.sessions = set()
|
||||
|
||||
def create_session(self, session_id, **kwargs):
|
||||
with self.lock:
|
||||
self.sessions.add(session_id)
|
||||
self.messages.setdefault(session_id, [])
|
||||
|
||||
def get_messages(self, session_id):
|
||||
with self.lock:
|
||||
return list(self.messages.get(session_id, []))
|
||||
|
||||
def append_message(self, session_id, role, content=None, **kwargs):
|
||||
with self.lock:
|
||||
self.messages.setdefault(session_id, []).append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
**kwargs,
|
||||
})
|
||||
|
||||
class FakeDbHolder:
|
||||
error = None
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def get_for_profile(self, profile):
|
||||
return self.db
|
||||
|
||||
def make_pool():
|
||||
pool = bridge.AgentPool()
|
||||
fake_db = FakeDb()
|
||||
pool._db = FakeDbHolder(fake_db)
|
||||
return pool, fake_db
|
||||
|
||||
def start_manual_run(pool, session_id, agent, message=None):
|
||||
session = bridge.AgentSession(session_id=session_id, agent=agent)
|
||||
run_id = f"run-{session_id}"
|
||||
record = bridge.RunRecord(run_id=run_id, session_id=session_id)
|
||||
session.running = True
|
||||
session.current_run_id = run_id
|
||||
with pool._lock:
|
||||
pool._sessions[session_id] = session
|
||||
pool._runs[run_id] = record
|
||||
thread = threading.Thread(
|
||||
target=pool._run_chat,
|
||||
args=(session, record, message or f"message:{session_id}", None, None, [], "default", False, "api_server"),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return session, record, thread
|
||||
|
||||
def wait_for(condition, timeout=20):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
`
|
||||
|
||||
describe('agent bridge Python session concurrency', () => {
|
||||
it('routes terminal/gateway approvals and stream callbacks per concurrent session', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
os.environ["HERMES_EXEC_ASK"] = "preexisting-exec-ask"
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, session_id):
|
||||
self.session_id = session_id
|
||||
|
||||
def run_conversation(self, message, **kwargs):
|
||||
barrier.wait(timeout=20)
|
||||
notify = approval._notify.get(self.session_id)
|
||||
if notify is None:
|
||||
raise RuntimeError(f"missing gateway notify for {self.session_id}")
|
||||
notify({
|
||||
"command": f"gateway:{self.session_id}",
|
||||
"description": f"gateway-desc:{self.session_id}",
|
||||
})
|
||||
kwargs["stream_callback"](f"delta:{self.session_id}")
|
||||
callback = _get_approval_callback()
|
||||
if callback is None:
|
||||
raise RuntimeError(f"missing approval callback for {self.session_id}")
|
||||
assert get_current_session_key("") == self.session_id
|
||||
choice = callback(f"cmd:{self.session_id}", f"desc:{self.session_id}", allow_permanent=False)
|
||||
return {
|
||||
"messages": [{"role": "assistant", "content": f"done:{self.session_id}:{choice}"}],
|
||||
"choice": choice,
|
||||
"completed": True,
|
||||
}
|
||||
|
||||
pool, fake_db = make_pool()
|
||||
records = {}
|
||||
threads = []
|
||||
|
||||
for sid in ("session-a", "session-b"):
|
||||
_session, record, thread = start_manual_run(pool, sid, FakeAgent(sid))
|
||||
records[sid] = record
|
||||
threads.append(thread)
|
||||
|
||||
terminal_approval_ids = {}
|
||||
gateway_approval_ids = {}
|
||||
def approvals_ready():
|
||||
with pool._lock:
|
||||
for sid, record in records.items():
|
||||
for event in record.events:
|
||||
if event.get("event") != "approval.requested":
|
||||
continue
|
||||
command = event.get("command")
|
||||
if command == f"cmd:{sid}":
|
||||
terminal_approval_ids[sid] = event["approval_id"]
|
||||
if command == f"gateway:{sid}":
|
||||
gateway_approval_ids[sid] = event["approval_id"]
|
||||
return (
|
||||
set(terminal_approval_ids) == {"session-a", "session-b"} and
|
||||
set(gateway_approval_ids) == {"session-a", "session-b"}
|
||||
)
|
||||
|
||||
if not wait_for(approvals_ready):
|
||||
diagnostics = {
|
||||
sid: {
|
||||
"status": record.status,
|
||||
"error": record.error,
|
||||
"events": record.events,
|
||||
"result": record.result,
|
||||
}
|
||||
for sid, record in records.items()
|
||||
}
|
||||
raise AssertionError({
|
||||
"terminal_approval_ids": terminal_approval_ids,
|
||||
"gateway_approval_ids": gateway_approval_ids,
|
||||
"records": diagnostics,
|
||||
})
|
||||
|
||||
assert os.environ.get("HERMES_EXEC_ASK") == "1"
|
||||
assert pool._exec_ask_depth == 2
|
||||
|
||||
pool.respond_approval(gateway_approval_ids["session-b"], "always")
|
||||
pool.respond_approval(gateway_approval_ids["session-a"], "session")
|
||||
pool.respond_approval(terminal_approval_ids["session-b"], "deny")
|
||||
pool.respond_approval(terminal_approval_ids["session-a"], "once")
|
||||
|
||||
for thread in threads:
|
||||
thread.join(timeout=20)
|
||||
assert not thread.is_alive()
|
||||
|
||||
assert records["session-a"].status == "complete"
|
||||
assert records["session-b"].status == "complete"
|
||||
assert records["session-a"].result["choice"] == "once"
|
||||
assert records["session-b"].result["choice"] == "deny"
|
||||
assert records["session-a"].deltas == ["delta:session-a"]
|
||||
assert records["session-b"].deltas == ["delta:session-b"]
|
||||
assert fake_db.get_messages("session-a")[0]["content"] == "message:session-a"
|
||||
assert fake_db.get_messages("session-b")[0]["content"] == "message:session-b"
|
||||
assert os.environ.get("HERMES_EXEC_ASK") == "preexisting-exec-ask"
|
||||
assert pool._exec_ask_depth == 0
|
||||
assert pool._approval_handlers == {}
|
||||
assert approval._notify == {}
|
||||
assert sorted(approval._resolved_gateway) == [
|
||||
("session-a", "session"),
|
||||
("session-b", "always"),
|
||||
]
|
||||
|
||||
terminal_commands = {}
|
||||
gateway_commands = {}
|
||||
timeouts = {}
|
||||
for sid, record in records.items():
|
||||
for event in record.events:
|
||||
if event.get("event") != "approval.requested":
|
||||
continue
|
||||
command = event.get("command")
|
||||
if command == f"cmd:{sid}":
|
||||
terminal_commands[sid] = command
|
||||
timeouts[sid] = event.get("timeout_ms")
|
||||
if command == f"gateway:{sid}":
|
||||
gateway_commands[sid] = command
|
||||
|
||||
assert terminal_commands == {
|
||||
"session-a": "cmd:session-a",
|
||||
"session-b": "cmd:session-b",
|
||||
}
|
||||
assert gateway_commands == {
|
||||
"session-a": "gateway:session-a",
|
||||
"session-b": "gateway:session-b",
|
||||
}
|
||||
assert timeouts == {
|
||||
"session-a": 120000,
|
||||
"session-b": 120000,
|
||||
}
|
||||
|
||||
same_session = bridge.AgentSession(session_id="same-session", agent=FakeAgent("same-session"))
|
||||
same_session.running = True
|
||||
pool.get_or_create = lambda *args, **kwargs: same_session
|
||||
try:
|
||||
pool.start_chat("same-session", "second")
|
||||
raise AssertionError("same-session concurrent run was accepted")
|
||||
except RuntimeError as exc:
|
||||
assert "already running" in str(exc)
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(self, destroyed, profile="default", key="default"):
|
||||
self.running = True
|
||||
self.destroyed = destroyed
|
||||
self.profile = profile
|
||||
self.key = key
|
||||
self.requests = []
|
||||
self.stopped = False
|
||||
|
||||
def request(self, req):
|
||||
self.requests.append(req)
|
||||
return {"ok": True, "destroyed": self.destroyed}
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
self.stopped = True
|
||||
|
||||
broker = bridge.BridgeBroker("ipc:///tmp/unused.sock")
|
||||
profile_worker = FakeWorker(2)
|
||||
broker._workers["default"] = profile_worker
|
||||
broker._run_profile["run-session-a"] = "default"
|
||||
broker._run_worker_key["run-session-a"] = "default"
|
||||
broker._running_run_profile["run-session-a"] = "default"
|
||||
broker._running_run_worker_key["run-session-a"] = "default"
|
||||
broker._session_profile["session-a"] = "default"
|
||||
broker._session_worker_key["session-a"] = "default"
|
||||
broker._approval_profile["approval-a"] = "default"
|
||||
broker._approval_worker_key["approval-a"] = "default"
|
||||
broker._compression_profile["compression-a"] = "default"
|
||||
broker._compression_worker_key["compression-a"] = "default"
|
||||
|
||||
destroy_profile_result = broker.handle({"action": "destroy_profile", "profile": "default"})
|
||||
assert destroy_profile_result == {"profile": "default", "destroyed": 2}
|
||||
assert profile_worker.stopped
|
||||
assert "default" not in broker._workers
|
||||
assert broker._run_profile == {}
|
||||
assert broker._run_worker_key == {}
|
||||
assert broker._running_run_profile == {}
|
||||
assert broker._running_run_worker_key == {}
|
||||
assert broker._session_profile == {}
|
||||
assert broker._session_worker_key == {}
|
||||
assert broker._approval_profile == {}
|
||||
assert broker._approval_worker_key == {}
|
||||
assert broker._compression_profile == {}
|
||||
assert broker._compression_worker_key == {}
|
||||
|
||||
worker_a = FakeWorker(1, "default", "a")
|
||||
worker_b = FakeWorker(3, "work", "b")
|
||||
broker._workers["a"] = worker_a
|
||||
broker._workers["b"] = worker_b
|
||||
broker._run_profile["run-a"] = "default"
|
||||
broker._run_worker_key["run-a"] = "a"
|
||||
broker._running_run_profile["run-a"] = "default"
|
||||
broker._running_run_worker_key["run-a"] = "a"
|
||||
broker._session_profile["session-b"] = "work"
|
||||
broker._session_worker_key["session-b"] = "b"
|
||||
|
||||
destroy_all_result = broker.handle({"action": "destroy_all"})
|
||||
assert destroy_all_result == {"destroyed": 4}
|
||||
assert worker_a.stopped
|
||||
assert worker_b.stopped
|
||||
assert broker._workers == {}
|
||||
assert broker._run_profile == {}
|
||||
assert broker._run_worker_key == {}
|
||||
assert broker._running_run_profile == {}
|
||||
assert broker._running_run_worker_key == {}
|
||||
assert broker._session_profile == {}
|
||||
assert broker._session_worker_key == {}
|
||||
`)
|
||||
})
|
||||
|
||||
it('builds broker ping metrics without calling profile workers', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
class PingWorker:
|
||||
running = True
|
||||
pid = 12345
|
||||
endpoint = "ipc:///tmp/worker.sock"
|
||||
last_used_at = 12.5
|
||||
|
||||
def request(self, req):
|
||||
raise AssertionError("broker ping must not forward to worker")
|
||||
|
||||
broker = bridge.BridgeBroker("ipc:///tmp/broker.sock")
|
||||
broker._workers["default"] = PingWorker()
|
||||
broker._session_profile["session-a"] = "default"
|
||||
broker._running_run_profile["run-a"] = "default"
|
||||
|
||||
resp = broker.handle({"action": "ping"})
|
||||
assert resp["workers"] == {"default": True}
|
||||
assert resp["worker_details"]["default"]["pid"] == 12345
|
||||
assert resp["active_sessions"] == 1
|
||||
assert resp["running_sessions"] == 1
|
||||
assert resp["sessions_by_profile"] == {"default": 1}
|
||||
assert resp["running_sessions_by_profile"] == {"default": 1}
|
||||
`)
|
||||
})
|
||||
|
||||
it('routes worker-keyed broker requests without stopping the worker on session destroy', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
class RoutedWorker:
|
||||
running = True
|
||||
pid = 12345
|
||||
endpoint = "ipc:///tmp/worker.sock"
|
||||
last_used_at = 12.5
|
||||
|
||||
def __init__(self, profile, key):
|
||||
self.profile = profile
|
||||
self.key = key
|
||||
self.requests = []
|
||||
self.stopped = False
|
||||
|
||||
def request(self, req, timeout=None):
|
||||
self.requests.append(req)
|
||||
action = req.get("action")
|
||||
if action == "chat":
|
||||
return {"ok": True, "run_id": "run-compress", "session_id": req["session_id"], "status": "running"}
|
||||
if action == "get_output":
|
||||
return {"ok": True, "run_id": req["run_id"], "session_id": "compress-temp", "status": "complete", "done": True}
|
||||
if action == "destroy":
|
||||
return {"ok": True, "session_id": req["session_id"], "destroyed": True}
|
||||
raise AssertionError(f"unexpected action: {action}")
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
broker = bridge.BridgeBroker("ipc:///tmp/unused.sock")
|
||||
worker = RoutedWorker("default", "default:compression:session-a")
|
||||
broker._workers[worker.key] = worker
|
||||
|
||||
chat_resp = broker.handle({
|
||||
"action": "chat",
|
||||
"session_id": "compress-temp",
|
||||
"profile": "default",
|
||||
"worker_key": worker.key,
|
||||
"message": "summarize",
|
||||
})
|
||||
assert chat_resp["run_id"] == "run-compress"
|
||||
assert worker.requests[-1]["profile"] == "default"
|
||||
assert "worker_key" not in worker.requests[-1]
|
||||
|
||||
broker.handle({"action": "get_output", "run_id": "run-compress"})
|
||||
assert worker.requests[-1]["action"] == "get_output"
|
||||
|
||||
destroy_resp = broker.handle({
|
||||
"action": "destroy",
|
||||
"session_id": "compress-temp",
|
||||
"profile": "default",
|
||||
"worker_key": worker.key,
|
||||
})
|
||||
assert destroy_resp["destroyed"] is True
|
||||
assert worker.requests[-1]["action"] == "destroy"
|
||||
assert not worker.stopped
|
||||
assert worker.key in broker._workers
|
||||
assert "compress-temp" not in broker._session_profile
|
||||
assert "compress-temp" not in broker._session_worker_key
|
||||
`)
|
||||
})
|
||||
|
||||
it('namespaces profile worker endpoints by broker endpoint', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
prod_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
|
||||
preview_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-web-ui-preview/agent-bridge.sock")
|
||||
assert prod_endpoint != preview_endpoint
|
||||
assert prod_endpoint == bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
|
||||
|
||||
prod_broker = bridge.BridgeBroker("ipc:///tmp/hermes-agent-bridge.sock")
|
||||
preview_broker = bridge.BridgeBroker("ipc:///tmp/hermes-web-ui-preview/agent-bridge.sock")
|
||||
prod_worker = prod_broker._worker_for_profile("default")
|
||||
preview_worker = preview_broker._worker_for_profile("default")
|
||||
assert prod_worker.endpoint != preview_worker.endpoint
|
||||
`)
|
||||
})
|
||||
|
||||
it('allows worker transport to be selected with environment variables', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
os.environ.pop("HERMES_AGENT_BRIDGE_WORKER_TRANSPORT", None)
|
||||
os.environ.pop("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", None)
|
||||
|
||||
default_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
|
||||
if os.name == "nt":
|
||||
assert default_endpoint.startswith("tcp://127.0.0.1:")
|
||||
else:
|
||||
assert default_endpoint.startswith("ipc://")
|
||||
|
||||
os.environ["HERMES_AGENT_BRIDGE_WORKER_TRANSPORT"] = "tcp"
|
||||
os.environ["HERMES_AGENT_BRIDGE_WORKER_PORT_BASE"] = "19650"
|
||||
tcp_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
|
||||
assert tcp_endpoint.startswith("tcp://127.0.0.1:")
|
||||
assert int(tcp_endpoint.rsplit(":", 1)[1]) >= 19650
|
||||
assert int(tcp_endpoint.rsplit(":", 1)[1]) < 20650
|
||||
|
||||
os.environ["HERMES_AGENT_BRIDGE_WORKER_TRANSPORT"] = "ipc"
|
||||
ipc_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
|
||||
assert ipc_endpoint.startswith("ipc://")
|
||||
|
||||
os.environ.pop("HERMES_AGENT_BRIDGE_WORKER_TRANSPORT", None)
|
||||
os.environ.pop("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", None)
|
||||
`)
|
||||
})
|
||||
|
||||
it('restores approval env and clears handlers when a run fails', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
|
||||
class FailingAgent:
|
||||
def run_conversation(self, message, **kwargs):
|
||||
assert os.environ.get("HERMES_EXEC_ASK") == "1"
|
||||
assert _get_approval_callback() is not None
|
||||
raise RuntimeError("boom")
|
||||
|
||||
pool, fake_db = make_pool()
|
||||
session, record, thread = start_manual_run(pool, "error-session", FailingAgent())
|
||||
thread.join(timeout=20)
|
||||
assert not thread.is_alive()
|
||||
|
||||
assert record.status == "error"
|
||||
assert "boom" in (record.error or "")
|
||||
assert session.running is False
|
||||
assert session.current_run_id is None
|
||||
assert "HERMES_EXEC_ASK" not in os.environ
|
||||
assert pool._exec_ask_depth == 0
|
||||
assert pool._exec_ask_previous is None
|
||||
assert pool._approval_handlers == {}
|
||||
assert approval._notify == {}
|
||||
assert fake_db.get_messages("error-session")[0]["content"] == "message:error-session"
|
||||
`)
|
||||
})
|
||||
|
||||
it('fails closed when approval dispatch loses run thread context', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
pool, _fake_db = make_pool()
|
||||
calls = []
|
||||
|
||||
def handler(command, description, *, allow_permanent=True):
|
||||
calls.append((command, description, allow_permanent))
|
||||
return "once"
|
||||
|
||||
with pool._lock:
|
||||
pool._approval_handlers["session-a"] = handler
|
||||
|
||||
assert pool._approval_dispatcher("cmd", "desc") == "deny"
|
||||
assert calls == []
|
||||
|
||||
pool._run_context.session_id = "missing-session"
|
||||
assert pool._approval_dispatcher("cmd", "desc") == "deny"
|
||||
assert calls == []
|
||||
|
||||
pool._run_context.session_id = "session-a"
|
||||
assert pool._approval_dispatcher("cmd", "desc", allow_permanent=False) == "once"
|
||||
assert calls == [("cmd", "desc", False)]
|
||||
`)
|
||||
})
|
||||
|
||||
it('cleans broker workers and wires worker parent watchdog state', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(self):
|
||||
self.running = True
|
||||
self.stopped = False
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
self.stopped = True
|
||||
|
||||
broker = bridge.BridgeBroker("ipc:///tmp/unused.sock")
|
||||
worker = FakeWorker()
|
||||
broker._workers["default"] = worker
|
||||
broker._run_profile["run-a"] = "default"
|
||||
broker._running_run_profile["run-a"] = "default"
|
||||
broker._session_profile["session-a"] = "default"
|
||||
broker._approval_profile["approval-a"] = "default"
|
||||
broker._compression_profile["compression-a"] = "default"
|
||||
|
||||
broker.stop()
|
||||
assert broker._stop.is_set()
|
||||
assert worker.stopped
|
||||
assert broker._workers == {}
|
||||
assert broker._run_profile == {}
|
||||
assert broker._running_run_profile == {}
|
||||
assert broker._session_profile == {}
|
||||
assert broker._approval_profile == {}
|
||||
assert broker._compression_profile == {}
|
||||
|
||||
created = {}
|
||||
|
||||
class FakeProcess:
|
||||
stdout = None
|
||||
stderr = None
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def fake_popen(args, **kwargs):
|
||||
created["args"] = args
|
||||
created["env"] = kwargs["env"]
|
||||
created["encoding"] = kwargs.get("encoding")
|
||||
created["errors"] = kwargs.get("errors")
|
||||
return FakeProcess()
|
||||
|
||||
original_popen = bridge.subprocess.Popen
|
||||
original_getpid = bridge.os.getpid
|
||||
try:
|
||||
bridge.subprocess.Popen = fake_popen
|
||||
bridge.os.getpid = lambda: 4242
|
||||
proc_worker = bridge.WorkerProcess("default:compression:session-a", "default", "ipc:///tmp/worker.sock", "/agent", "/home")
|
||||
proc_worker._pipe_stderr = lambda: None
|
||||
proc_worker._wait_ready = lambda: None
|
||||
proc_worker.start()
|
||||
finally:
|
||||
bridge.subprocess.Popen = original_popen
|
||||
bridge.os.getpid = original_getpid
|
||||
|
||||
assert created["env"]["HERMES_AGENT_BRIDGE_BROKER_PID"] == "4242"
|
||||
assert created["env"]["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] == "default"
|
||||
assert created["encoding"] == "utf-8"
|
||||
assert created["errors"] == "replace"
|
||||
|
||||
stop_event = threading.Event()
|
||||
seen_pids = []
|
||||
original_process_exists = bridge._process_exists
|
||||
try:
|
||||
bridge._process_exists = lambda pid: seen_pids.append(pid) and False
|
||||
bridge._start_parent_process_watchdog(12345, stop_event, "test", interval=0.01)
|
||||
assert wait_for(stop_event.is_set, timeout=2)
|
||||
finally:
|
||||
bridge._process_exists = original_process_exists
|
||||
|
||||
assert seen_pids == [12345]
|
||||
`)
|
||||
})
|
||||
|
||||
it('handles broker ping while another broker request is blocked', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
class BlockingBroker(bridge.BridgeBroker):
|
||||
def handle(self, req):
|
||||
if req.get("action") == "block":
|
||||
time.sleep(0.4)
|
||||
return {"blocked": True}
|
||||
return super().handle(req)
|
||||
|
||||
class MemoryConn:
|
||||
def __init__(self, req):
|
||||
self.request = (json.dumps(req) + "\n").encode("utf-8")
|
||||
self.response = b""
|
||||
self.closed = False
|
||||
|
||||
def recv(self, size):
|
||||
if not self.request:
|
||||
return b""
|
||||
chunk = self.request[:size]
|
||||
self.request = self.request[size:]
|
||||
return chunk
|
||||
|
||||
def sendall(self, payload):
|
||||
self.response += payload
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
broker = BlockingBroker("ipc:///tmp/unused.sock")
|
||||
blocking_conn = MemoryConn({"action": "block"})
|
||||
thread = threading.Thread(target=broker._handle_connection, args=(blocking_conn,))
|
||||
thread.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
ping_conn = MemoryConn({"action": "ping"})
|
||||
broker._handle_connection(ping_conn)
|
||||
ping_resp = json.loads(ping_conn.response.decode("utf-8"))
|
||||
assert ping_resp["ok"] is True, ping_resp
|
||||
assert ping_resp["pong"] is True, ping_resp
|
||||
assert ping_conn.closed is True, ping_conn.closed
|
||||
|
||||
thread.join(timeout=2)
|
||||
assert not thread.is_alive(), blocking_conn.response
|
||||
blocked_resp = json.loads(blocking_conn.response.decode("utf-8"))
|
||||
assert blocked_resp["ok"] is True, blocked_resp
|
||||
assert blocked_resp["blocked"] is True, blocked_resp
|
||||
`)
|
||||
})
|
||||
|
||||
it('extends profile worker request timeout from wait requests', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
broker = bridge.BridgeBroker("ipc:///tmp/unused.sock")
|
||||
assert broker._worker_request_timeout({"action": "chat"}) == bridge.WorkerProcess.REQUEST_TIMEOUT_SECONDS
|
||||
assert broker._worker_request_timeout({"action": "chat", "timeout": 60}) == bridge.WorkerProcess.REQUEST_TIMEOUT_SECONDS
|
||||
assert broker._worker_request_timeout({"action": "chat", "timeout": 300}) == 310
|
||||
|
||||
captured = {}
|
||||
worker = bridge.WorkerProcess("default", "default", "ipc:///tmp/worker.sock", None, None)
|
||||
worker.start = lambda: None
|
||||
original_send = bridge._send_bridge_request
|
||||
try:
|
||||
def fake_send(endpoint, req, timeout):
|
||||
captured["endpoint"] = endpoint
|
||||
captured["req"] = req
|
||||
captured["timeout"] = timeout
|
||||
return {"ok": True}
|
||||
bridge._send_bridge_request = fake_send
|
||||
response = worker.request({"action": "chat"}, 310)
|
||||
finally:
|
||||
bridge._send_bridge_request = original_send
|
||||
|
||||
assert response["ok"] is True, response
|
||||
assert captured["endpoint"] == "ipc:///tmp/worker.sock", captured
|
||||
assert captured["req"] == {"action": "chat"}, captured
|
||||
assert captured["timeout"] == 310, captured
|
||||
`)
|
||||
})
|
||||
|
||||
it('awaits MCP server shutdown without holding the MCP registry lock', () => {
|
||||
runPython(String.raw`
|
||||
${harness}
|
||||
|
||||
import asyncio
|
||||
|
||||
lock = threading.Lock()
|
||||
servers = {}
|
||||
events = []
|
||||
|
||||
class FakeMcpTask:
|
||||
async def shutdown(self):
|
||||
events.append("shutdown-started")
|
||||
acquired = lock.acquire(blocking=False)
|
||||
events.append(("lock-free-during-shutdown", acquired))
|
||||
if acquired:
|
||||
lock.release()
|
||||
await asyncio.sleep(0)
|
||||
events.append("shutdown-finished")
|
||||
|
||||
task = FakeMcpTask()
|
||||
servers["github"] = task
|
||||
|
||||
def run_on_mcp_loop(factory, timeout=30):
|
||||
events.append(("timeout", timeout))
|
||||
asyncio.run(factory())
|
||||
|
||||
result = bridge.BridgeServer._shutdown_mcp_server(
|
||||
"github",
|
||||
servers,
|
||||
lock,
|
||||
run_on_mcp_loop,
|
||||
)
|
||||
|
||||
assert result is True, result
|
||||
assert "github" not in servers, servers
|
||||
assert events == [
|
||||
("timeout", 15),
|
||||
"shutdown-started",
|
||||
("lock-free-during-shutdown", True),
|
||||
"shutdown-finished",
|
||||
], events
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'path'
|
||||
|
||||
type FsMocks = {
|
||||
readFile: ReturnType<typeof vi.fn>
|
||||
writeFile: ReturnType<typeof vi.fn>
|
||||
mkdir: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('fs/promises', () => ({ readFile, writeFile, mkdir }))
|
||||
vi.doMock('os', () => ({ homedir: () => home }))
|
||||
|
||||
const mod = await import('../../packages/server/src/services/auth')
|
||||
return {
|
||||
...mod,
|
||||
mocks: { readFile, writeFile, mkdir },
|
||||
appHome: join(home, '.hermes-web-ui'),
|
||||
tokenFile: join(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
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv
|
||||
})
|
||||
|
||||
describe('getToken', () => {
|
||||
it('ignores legacy AUTH_DISABLED=1 and still creates an auth token', async () => {
|
||||
process.env.AUTH_DISABLED = '1'
|
||||
const readFile = vi.fn().mockRejectedValue(new Error('ENOENT'))
|
||||
const writeFile = vi.fn()
|
||||
const mkdir = vi.fn()
|
||||
const { getToken } = await loadAuth({ readFile, writeFile, mkdir })
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(writeFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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(readFile).toHaveBeenCalledWith(tokenFile, 'utf-8')
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
const expectedWriteOptions = process.platform === 'win32' ? {} : { mode: 0o600 }
|
||||
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(mkdir).toHaveBeenCalledWith(appHome, { recursive: true })
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
tokenFile,
|
||||
expect.stringMatching(/^[a-f0-9]{64}\n$/),
|
||||
expectedWriteOptions,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireAuth', () => {
|
||||
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 { 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 /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)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 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)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns 401 JSON on auth failure', 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)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(ctx.set).toHaveBeenCalledWith('Content-Type', 'application/json')
|
||||
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────
|
||||
const mcpToolsMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge/client', () => ({
|
||||
AgentBridgeClient: vi.fn().mockImplementation(() => ({
|
||||
mcpTools: mcpToolsMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────
|
||||
describe('bridgeMcpAction - mcp_tools_list', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('passes server and profile to client.mcpTools', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github' }, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', undefined)
|
||||
})
|
||||
|
||||
it('passes raw=true to client.mcpTools', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github', raw: true }, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', true)
|
||||
})
|
||||
|
||||
it('passes raw=false to client.mcpTools', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github', raw: false }, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', false)
|
||||
})
|
||||
|
||||
it('passes undefined server when not provided', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', {}, 'test-profile')
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile', undefined)
|
||||
})
|
||||
|
||||
it('passes undefined profile when not provided', async () => {
|
||||
mcpToolsMock.mockResolvedValue({ ok: true, results: [] })
|
||||
const { bridgeMcpAction } = await import('../../packages/server/src/services/hermes/mcp')
|
||||
await bridgeMcpAction('mcp_tools_list', { server: 'github' })
|
||||
expect(mcpToolsMock).toHaveBeenCalledWith('github', undefined, undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock DB module before importing
|
||||
const addMessageMock = vi.fn()
|
||||
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
||||
addMessage: addMessageMock,
|
||||
getSession: vi.fn(),
|
||||
getSessionDetail: vi.fn(),
|
||||
getSessionDetailPaginated: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
updateSessionStats: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/lib/context-compressor', () => ({
|
||||
ChatContextCompressor: class {},
|
||||
countTokens: vi.fn(() => 1),
|
||||
SUMMARY_PREFIX: '[Summary] ',
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
||||
getCompressionSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/lib/llm-json', () => ({
|
||||
parseLLMJSON: vi.fn(),
|
||||
parseToolArguments: vi.fn(),
|
||||
parseAnthropicContentArray: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/lib/llm-prompt', () => ({
|
||||
getSystemPrompt: vi.fn(() => ''),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
||||
updateUsage: vi.fn(),
|
||||
}))
|
||||
|
||||
// --- Types mirroring run-chat response flushing ---
|
||||
|
||||
interface SessionMessage {
|
||||
id: number | string
|
||||
session_id: string
|
||||
role: string
|
||||
content: string
|
||||
runMarker?: string
|
||||
tool_call_id?: string | null
|
||||
tool_calls?: any[] | null
|
||||
tool_name?: string | null
|
||||
timestamp: number
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
interface ResponseRunState {
|
||||
runMarker?: string
|
||||
responseId?: string
|
||||
insertedKeys: Set<string>
|
||||
toolCalls: Map<string, any>
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
messages: SessionMessage[]
|
||||
isWorking: boolean
|
||||
events: Array<{ event: string; data: any }>
|
||||
queue: any[]
|
||||
responseRun?: ResponseRunState
|
||||
}
|
||||
|
||||
function createSessionState(): SessionState {
|
||||
return { messages: [], isWorking: false, events: [], queue: [] }
|
||||
}
|
||||
|
||||
function createRun(runMarker: string): ResponseRunState {
|
||||
return { runMarker, insertedKeys: new Set<string>(), toolCalls: new Map<string, any>() }
|
||||
}
|
||||
|
||||
// --- Simulated event handlers (mirroring actual implementation) ---
|
||||
|
||||
function applyDelta(state: SessionState, sessionId: string, runMarker: string, deltaText: string) {
|
||||
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
|
||||
if (last?.role === 'assistant' && last.finish_reason == null && !last.tool_calls?.length) {
|
||||
last.content += deltaText
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: state.messages.length + 1,
|
||||
session_id: sessionId,
|
||||
runMarker,
|
||||
role: 'assistant',
|
||||
content: deltaText,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function applyTextDone(state: SessionState, runMarker: string) {
|
||||
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
|
||||
if (last?.role === 'assistant' && last.finish_reason == null) {
|
||||
last.finish_reason = 'stop'
|
||||
}
|
||||
}
|
||||
|
||||
function applyToolCall(state: SessionState, sessionId: string, runMarker: string, callId: string, name: string, args: string) {
|
||||
const run = state.responseRun!
|
||||
const key = `assistant:${callId}`
|
||||
if (!run.insertedKeys.has(key)) {
|
||||
run.insertedKeys.add(key)
|
||||
const toolCall = { id: callId, type: 'function', function: { name, arguments: args } }
|
||||
run.toolCalls.set(callId, toolCall)
|
||||
state.messages.push({
|
||||
id: state.messages.length + 1,
|
||||
session_id: sessionId,
|
||||
runMarker,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [toolCall],
|
||||
finish_reason: 'tool_calls',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function applyToolOutput(state: SessionState, sessionId: string, runMarker: string, callId: string, output: string) {
|
||||
const run = state.responseRun!
|
||||
const key = `tool:${callId}`
|
||||
if (!run.insertedKeys.has(key)) {
|
||||
run.insertedKeys.add(key)
|
||||
const toolName = run.toolCalls.get(callId)?.function?.name || null
|
||||
state.messages.push({
|
||||
id: state.messages.length + 1,
|
||||
session_id: sessionId,
|
||||
runMarker,
|
||||
role: 'tool',
|
||||
content: output,
|
||||
tool_call_id: callId,
|
||||
tool_name: toolName,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors flushResponseRunToDb — writes all non-user messages for this run to DB. */
|
||||
function flushResponseRunToDb(state: SessionState, sessionId: string) {
|
||||
const run = state.responseRun
|
||||
if (!run?.runMarker) return
|
||||
for (const msg of state.messages) {
|
||||
if (msg.runMarker !== run.runMarker) continue
|
||||
if (msg.role === 'user') continue
|
||||
addMessageMock({
|
||||
session_id: sessionId,
|
||||
role: msg.role,
|
||||
content: msg.content || '',
|
||||
tool_call_id: msg.tool_call_id ?? null,
|
||||
tool_calls: msg.tool_calls ?? null,
|
||||
tool_name: msg.tool_name ?? null,
|
||||
finish_reason: msg.finish_reason ?? null,
|
||||
timestamp: msg.timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SID = 'test-session'
|
||||
const MARKER = 'resp_run_abc123'
|
||||
|
||||
describe('chat-run message flush', () => {
|
||||
beforeEach(() => {
|
||||
addMessageMock.mockClear()
|
||||
})
|
||||
|
||||
it('flushes simple text response to DB on normal completion', () => {
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
state.messages.push({ id: 1, session_id: SID, runMarker: MARKER, role: 'user', content: 'hello', timestamp: 100 })
|
||||
applyDelta(state, SID, MARKER, 'Hello! ')
|
||||
applyDelta(state, SID, MARKER, 'How can I help?')
|
||||
applyTextDone(state, MARKER)
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
expect(addMessageMock).toHaveBeenCalledTimes(1)
|
||||
expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
session_id: SID,
|
||||
role: 'assistant',
|
||||
content: 'Hello! How can I help?',
|
||||
finish_reason: 'stop',
|
||||
}))
|
||||
})
|
||||
|
||||
it('flushes tool calls with correct interleaved order', () => {
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
state.messages.push({ id: 1, session_id: SID, runMarker: MARKER, role: 'user', content: 'search baidu', timestamp: 100 })
|
||||
applyDelta(state, SID, MARKER, 'Let me search.')
|
||||
applyTextDone(state, MARKER)
|
||||
applyToolCall(state, SID, MARKER, 'call_1', 'terminal', '{"cmd":"opencli web read baidu"}')
|
||||
applyToolOutput(state, SID, MARKER, 'call_1', '{"output": "百度热搜..."}')
|
||||
applyDelta(state, SID, MARKER, 'Here are the results:')
|
||||
applyTextDone(state, MARKER)
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
expect(addMessageMock).toHaveBeenCalledTimes(4)
|
||||
const calls = addMessageMock.mock.calls.map(c => ({ role: c[0].role, hasToolCalls: !!c[0].tool_calls?.length }))
|
||||
expect(calls).toEqual([
|
||||
{ role: 'assistant', hasToolCalls: false },
|
||||
{ role: 'assistant', hasToolCalls: true },
|
||||
{ role: 'tool', hasToolCalls: false },
|
||||
{ role: 'assistant', hasToolCalls: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('flushes partial messages on abort (no output_text.done)', () => {
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
state.messages.push({ id: 1, session_id: SID, runMarker: MARKER, role: 'user', content: 'hello', timestamp: 100 })
|
||||
applyDelta(state, SID, MARKER, 'Let me ')
|
||||
applyDelta(state, SID, MARKER, 'search...')
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
expect(addMessageMock).toHaveBeenCalledTimes(1)
|
||||
expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'Let me search...',
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not write user messages (already written by handleRun)', () => {
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
state.messages.push({ id: 1, session_id: SID, runMarker: MARKER, role: 'user', content: 'user msg', timestamp: 100 })
|
||||
state.messages.push({ id: 2, session_id: SID, runMarker: MARKER, role: 'assistant', content: 'reply', timestamp: 101, finish_reason: 'stop' })
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
expect(addMessageMock).toHaveBeenCalledTimes(1)
|
||||
expect(addMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ role: 'user' }))
|
||||
})
|
||||
|
||||
it('does not merge separate assistant messages around tool calls', () => {
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
applyDelta(state, SID, MARKER, 'Text before tool.')
|
||||
applyTextDone(state, MARKER)
|
||||
applyToolCall(state, SID, MARKER, 'call_1', 'search', '{"q":"test"}')
|
||||
applyToolOutput(state, SID, MARKER, 'call_1', 'search results')
|
||||
applyDelta(state, SID, MARKER, 'Text after tool.')
|
||||
applyTextDone(state, MARKER)
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
const assistantTextCalls = addMessageMock.mock.calls
|
||||
.filter(c => c[0].role === 'assistant' && !c[0].tool_calls?.length)
|
||||
|
||||
expect(assistantTextCalls).toHaveLength(2)
|
||||
expect(assistantTextCalls[0][0].content).toBe('Text before tool.')
|
||||
expect(assistantTextCalls[1][0].content).toBe('Text after tool.')
|
||||
})
|
||||
|
||||
it('handles text → tool → text without output_text.done between them', () => {
|
||||
// Scenario: only one output_text.done at the very end, not between blocks
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
// First text block via deltas, NO output_text.done yet
|
||||
applyDelta(state, SID, MARKER, '没卡,刚搜完。')
|
||||
applyToolCall(state, SID, MARKER, 'call_1', 'browser', '{"url":"..."}')
|
||||
applyToolOutput(state, SID, MARKER, 'call_1', '')
|
||||
// Second text block via deltas
|
||||
applyDelta(state, SID, MARKER, '搜到了!详情如下:')
|
||||
// Now output_text.done fires — only marks finish_reason, does NOT overwrite
|
||||
applyTextDone(state, MARKER)
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
const assistantTextCalls = addMessageMock.mock.calls
|
||||
.filter(c => c[0].role === 'assistant' && !c[0].tool_calls?.length)
|
||||
|
||||
// Must have 2 separate text messages, NOT merged
|
||||
expect(assistantTextCalls).toHaveLength(2)
|
||||
expect(assistantTextCalls[0][0].content).toBe('没卡,刚搜完。')
|
||||
expect(assistantTextCalls[1][0].content).toBe('搜到了!详情如下:')
|
||||
})
|
||||
|
||||
it('multiple tool calls with text between them stay separated', () => {
|
||||
const state = createSessionState()
|
||||
state.responseRun = createRun(MARKER)
|
||||
|
||||
applyDelta(state, SID, MARKER, 'Text A.')
|
||||
applyTextDone(state, MARKER)
|
||||
applyToolCall(state, SID, MARKER, 'call_1', 'search', '{}')
|
||||
applyToolOutput(state, SID, MARKER, 'call_1', 'result1')
|
||||
applyDelta(state, SID, MARKER, 'Text B.')
|
||||
applyTextDone(state, MARKER)
|
||||
applyToolCall(state, SID, MARKER, 'call_2', 'search', '{}')
|
||||
applyToolOutput(state, SID, MARKER, 'call_2', 'result2')
|
||||
applyDelta(state, SID, MARKER, 'Text C.')
|
||||
applyTextDone(state, MARKER)
|
||||
|
||||
flushResponseRunToDb(state, SID)
|
||||
|
||||
const textCalls = addMessageMock.mock.calls
|
||||
.filter(c => c[0].role === 'assistant' && !c[0].tool_calls?.length)
|
||||
.map(c => c[0].content)
|
||||
|
||||
expect(textCalls).toEqual(['Text A.', 'Text B.', 'Text C.'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { scryptSync, timingSafeEqual } from 'crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
type ChildProcessMocks = {
|
||||
execFileSync: ReturnType<typeof vi.fn>
|
||||
execSync: ReturnType<typeof vi.fn>
|
||||
spawn: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function loadCli(overrides: Partial<ChildProcessMocks> = {}) {
|
||||
const execFileSync = overrides.execFileSync ?? vi.fn()
|
||||
const execSync = overrides.execSync ?? vi.fn()
|
||||
const spawn = overrides.spawn ?? vi.fn()
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('child_process', () => ({ execFileSync, execSync, spawn }))
|
||||
|
||||
const mod = await import('../../bin/hermes-web-ui.mjs')
|
||||
return {
|
||||
...mod,
|
||||
mocks: { execFileSync, execSync, spawn },
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, passwordHash: string): boolean {
|
||||
const [scheme, salt, expectedHex] = passwordHash.split(':')
|
||||
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
|
||||
const expected = Buffer.from(expectedHex, 'hex')
|
||||
const actual = scryptSync(password, salt, expected.length)
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
describe('CLI port detection', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
vi.doUnmock('child_process')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to lsof without executing ss when ss is unavailable', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
|
||||
const execFileSync = vi.fn((command: string, args: string[]) => {
|
||||
if (command === 'sh' && args.at(-1) === 'ss') {
|
||||
throw new Error('not found')
|
||||
}
|
||||
if (command === 'sh' && args.at(-1) === 'lsof') {
|
||||
return ''
|
||||
}
|
||||
if (command === 'lsof') {
|
||||
return '1234\n1234\n'
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`)
|
||||
})
|
||||
const { getListeningPids, mocks } = await loadCli({ execFileSync })
|
||||
|
||||
expect(getListeningPids(8648)).toEqual([1234])
|
||||
expect(mocks.execFileSync).not.toHaveBeenCalledWith(
|
||||
'ss',
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
'lsof',
|
||||
['-tiTCP:8648', '-sTCP:LISTEN'],
|
||||
expect.objectContaining({ encoding: 'utf-8' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses ss first when available', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
|
||||
const execFileSync = vi.fn((command: string, args: string[]) => {
|
||||
if (command === 'sh' && args.at(-1) === 'ss') {
|
||||
return ''
|
||||
}
|
||||
if (command === 'ss') {
|
||||
return 'LISTEN 0 511 0.0.0.0:8648 0.0.0.0:* users:(("node",pid=4321,fd=20))\n'
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`)
|
||||
})
|
||||
const { getListeningPids } = await loadCli({ execFileSync })
|
||||
|
||||
expect(getListeningPids(8648)).toEqual([4321])
|
||||
})
|
||||
|
||||
it('parses Linux netstat listener output as a final fallback', async () => {
|
||||
const { parseUnixNetstatListeningPids } = await loadCli()
|
||||
|
||||
expect(parseUnixNetstatListeningPids(
|
||||
[
|
||||
'tcp 0 0 0.0.0.0:8648 0.0.0.0:* LISTEN 2468/node',
|
||||
'tcp 0 0 0.0.0.0:5173 0.0.0.0:* LISTEN 1357/node',
|
||||
].join('\n'),
|
||||
8648,
|
||||
)).toEqual([2468])
|
||||
})
|
||||
|
||||
it('clears the login lock file from the configured Web UI home', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-locks-'))
|
||||
process.env.HERMES_WEB_UI_HOME = home
|
||||
const lockFile = join(home, '.login-lock.json')
|
||||
writeFileSync(lockFile, '{"passwordIpMap":{}}\n')
|
||||
|
||||
try {
|
||||
const { clearLoginLocks } = await loadCli()
|
||||
const result = clearLoginLocks({ silent: true, checkRunning: false })
|
||||
|
||||
expect(result).toEqual({ path: lockFile, removed: true, serverRunning: false })
|
||||
expect(existsSync(lockFile)).toBe(false)
|
||||
|
||||
const second = clearLoginLocks({ silent: true, checkRunning: false })
|
||||
expect(second).toEqual({ path: lockFile, removed: false, serverRunning: false })
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans a stale server PID file during stop', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-stale-pid-'))
|
||||
process.env.HERMES_WEB_UI_HOME = home
|
||||
const pidFile = join(home, 'server.pid')
|
||||
writeFileSync(pidFile, '999999999\n')
|
||||
|
||||
try {
|
||||
const { stopDaemon } = await loadCli()
|
||||
stopDaemon()
|
||||
|
||||
expect(existsSync(pidFile)).toBe(false)
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resets an existing admin user to the default password', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-default-login-'))
|
||||
process.env.HERMES_WEB_UI_HOME = home
|
||||
const dbPath = join(home, 'hermes-web-ui.db')
|
||||
|
||||
try {
|
||||
const { resetDefaultLogin } = await loadCli()
|
||||
const created = await resetDefaultLogin({ silent: true })
|
||||
expect(created.action).toBe('created')
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
try {
|
||||
const initial = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get('admin') as any
|
||||
expect(verifyPassword('123456', initial.password_hash)).toBe(true)
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run('scrypt:bad:bad', 'admin')
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const updated = await resetDefaultLogin({ silent: true })
|
||||
expect(updated.action).toBe('updated')
|
||||
|
||||
const verifyDb = new DatabaseSync(dbPath)
|
||||
try {
|
||||
const rows = verifyDb.prepare('SELECT id, username, password_hash, role, status FROM users WHERE username = ?').all('admin') as any[]
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(verifyPassword('123456', rows[0].password_hash)).toBe(true)
|
||||
expect(rows[0].role).toBe('super_admin')
|
||||
expect(rows[0].status).toBe('active')
|
||||
} finally {
|
||||
verifyDb.close()
|
||||
}
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
let hermesHome = ''
|
||||
|
||||
function writeHermesFile(path: string, content: string) {
|
||||
mkdirSync(hermesHome, { recursive: true })
|
||||
writeFileSync(join(hermesHome, path), content)
|
||||
}
|
||||
|
||||
function writeConfigYaml(content: string) {
|
||||
writeHermesFile('config.yaml', content)
|
||||
}
|
||||
|
||||
function writeEnv(content = '') {
|
||||
writeHermesFile('.env', content)
|
||||
}
|
||||
|
||||
function writeAuthJson(auth: Record<string, unknown>, path = 'auth.json') {
|
||||
writeHermesFile(path, JSON.stringify(auth, null, 2))
|
||||
}
|
||||
|
||||
function readAuthJson(path = 'auth.json') {
|
||||
return JSON.parse(readFileSync(join(hermesHome, path), 'utf-8'))
|
||||
}
|
||||
|
||||
function makeCtx(profile?: string): any {
|
||||
return {
|
||||
params: {},
|
||||
query: {},
|
||||
request: { body: {} },
|
||||
state: profile ? { profile: { name: profile } } : {},
|
||||
get: () => '',
|
||||
body: undefined,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelsController() {
|
||||
vi.resetModules()
|
||||
vi.doMock('../../packages/server/src/services/app-config', () => ({
|
||||
readAppConfig: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
vi.doMock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||
getCopilotModelsDetailed: vi.fn().mockResolvedValue([]),
|
||||
resolveCopilotOAuthToken: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
return import('../../packages/server/src/controllers/hermes/models')
|
||||
}
|
||||
|
||||
async function loadCodexAuthController() {
|
||||
vi.resetModules()
|
||||
vi.doMock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}))
|
||||
return import('../../packages/server/src/controllers/hermes/codex-auth')
|
||||
}
|
||||
|
||||
describe('OpenAI Codex credential pool auth compatibility', () => {
|
||||
beforeEach(() => {
|
||||
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-codex-pool-'))
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
process.env.CODEX_HOME = join(hermesHome, 'codex-home')
|
||||
writeConfigYaml('model:\n default: gpt-5.5\n provider: openai-codex\n')
|
||||
writeEnv('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../packages/server/src/services/app-config')
|
||||
vi.doUnmock('../../packages/server/src/services/hermes/copilot-models')
|
||||
vi.doUnmock('../../packages/server/src/services/logger')
|
||||
delete process.env.HERMES_HOME
|
||||
delete process.env.CODEX_HOME
|
||||
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
it('lists OpenAI Codex models when auth.json only has credential_pool entries', async () => {
|
||||
writeAuthJson({
|
||||
version: 1,
|
||||
providers: {},
|
||||
active_provider: 'openai-codex',
|
||||
credential_pool: {
|
||||
'openai-codex': [
|
||||
{ id: 'main', auth_type: 'oauth', access_token: 'access-token-from-pool', refresh_token: 'refresh-token-from-pool' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { getAvailable } = await loadModelsController()
|
||||
const ctx = makeCtx()
|
||||
|
||||
await getAvailable(ctx)
|
||||
|
||||
expect(ctx.body.default).toBe('gpt-5.5')
|
||||
expect(ctx.body.default_provider).toBe('openai-codex')
|
||||
expect(ctx.body.groups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'openai-codex',
|
||||
label: 'OpenAI Codex',
|
||||
models: expect.arrayContaining(['gpt-5.5', 'gpt-5.4-mini']),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('reports Codex authenticated from credential_pool without requiring legacy providers tokens', async () => {
|
||||
writeAuthJson({
|
||||
version: 1,
|
||||
providers: {},
|
||||
active_provider: 'openai-codex',
|
||||
credential_pool: {
|
||||
'openai-codex': [
|
||||
{ id: 'main', auth_type: 'oauth', access_token: 'non-jwt-access-token', refresh_token: 'refresh-token-from-pool', last_refresh: '2026-05-10T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { status } = await loadCodexAuthController()
|
||||
const ctx = makeCtx()
|
||||
|
||||
await status(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ authenticated: true, last_refresh: '2026-05-10T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('reports Codex status from the request-scoped profile', async () => {
|
||||
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
|
||||
writeAuthJson({ version: 1, providers: {}, credential_pool: {} })
|
||||
writeAuthJson({
|
||||
version: 1,
|
||||
providers: {},
|
||||
credential_pool: {
|
||||
'openai-codex': [
|
||||
{ access_token: 'research-token', refresh_token: 'research-refresh', last_refresh: '2026-06-02T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
}, 'profiles/research/auth.json')
|
||||
|
||||
const { status } = await loadCodexAuthController()
|
||||
const ctx = makeCtx('research')
|
||||
|
||||
await status(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ authenticated: true, last_refresh: '2026-06-02T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('persists Codex OAuth credentials in the request-scoped profile only', async () => {
|
||||
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
|
||||
|
||||
const { saveCodexOAuthTokensForProfile } = await loadCodexAuthController()
|
||||
saveCodexOAuthTokensForProfile('research', 'research-access-token', 'research-refresh-token')
|
||||
|
||||
expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false)
|
||||
const auth = readAuthJson('profiles/research/auth.json')
|
||||
expect(auth.providers['openai-codex'].tokens.access_token).toBe('research-access-token')
|
||||
expect(auth.credential_pool['openai-codex'][0].access_token).toBe('research-access-token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,680 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { claudeProxyMessages, claudeProxyModels, registerClaudeCodeProxyTarget } from '../../packages/server/src/services/claude-code-proxy'
|
||||
import { codexProxyModels, codexProxyResponses, registerCodexProxyTarget } from '../../packages/server/src/services/codex-proxy'
|
||||
import { prepareCodingAgentLaunch } from '../../packages/server/src/services/coding-agents'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
function makeHome() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-coding-agent-launch-'))
|
||||
homes.push(home)
|
||||
process.env.HERMES_WEB_UI_HOME = home
|
||||
return home
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HERMES_WEB_UI_HOME
|
||||
vi.unstubAllGlobals()
|
||||
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeProxyContext(routeKey: string, token: string, body: any): any {
|
||||
return {
|
||||
params: { key: routeKey },
|
||||
request: { body },
|
||||
responseHeaders: {} as Record<string, string>,
|
||||
get(name: string) {
|
||||
if (name.toLowerCase() === 'authorization') return `Bearer ${token}`
|
||||
return ''
|
||||
},
|
||||
set(name: string, value: string) {
|
||||
this.responseHeaders[name] = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('coding agent launch preparation', () => {
|
||||
it('launches Claude Code with the global config when requested', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('claude-code', {
|
||||
mode: 'global',
|
||||
profile: 'default',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
agentId: 'claude-code',
|
||||
mode: 'global',
|
||||
profile: 'default',
|
||||
provider: 'global',
|
||||
model: '',
|
||||
rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'),
|
||||
workspaceDir: join(home, 'coding-agent', 'workspace', 'default', 'global'),
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: {},
|
||||
shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && claude`,
|
||||
files: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('launches Codex with the global config when requested', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('codex', {
|
||||
mode: 'global',
|
||||
profile: 'default',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
agentId: 'codex',
|
||||
mode: 'global',
|
||||
profile: 'default',
|
||||
provider: 'global',
|
||||
model: '',
|
||||
rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'),
|
||||
workspaceDir: join(home, 'coding-agent', 'workspace', 'default', 'global'),
|
||||
command: 'codex',
|
||||
args: [],
|
||||
env: {},
|
||||
shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && codex`,
|
||||
files: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('launches Claude Code with scoped settings instead of a CLI --model override', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('claude-code', {
|
||||
profile: 'default',
|
||||
provider: 'openrouter',
|
||||
model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
apiKey: 'sk-test',
|
||||
})
|
||||
|
||||
expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'claude-code'))
|
||||
expect(result.workspaceDir).toBe(join(home, 'coding-agent', 'workspace', 'default', 'openrouter'))
|
||||
expect(result.args).toEqual([
|
||||
'--settings',
|
||||
join(result.rootDir, 'settings.json'),
|
||||
'--mcp-config',
|
||||
join(result.rootDir, 'mcp.json'),
|
||||
])
|
||||
expect(result.shellCommand).toContain(`cd ${join(home, 'coding-agent', 'workspace', 'default', 'openrouter')} && claude`)
|
||||
expect(result.shellCommand).not.toContain('--model')
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(result.rootDir, 'settings.json'), 'utf-8'))
|
||||
expect(settings.model).toBe('cognitivecomputations/dolphin-mistral-24b-venice-edition:free')
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/)
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/)
|
||||
expect(settings.env).toMatchObject({
|
||||
ANTHROPIC_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: 'Dolphin Mistral 24b Venice Edition:Free',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: 'Dolphin Mistral 24b Venice Edition:Free',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'Dolphin Mistral 24b Venice Edition:Free',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'Dolphin Mistral 24b Venice Edition:Free',
|
||||
})
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).not.toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('keeps Claude Code protocol overrides behind the local proxy', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('claude-code', {
|
||||
profile: 'default',
|
||||
provider: 'openrouter',
|
||||
model: 'anthropic/claude-sonnet-4.6',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
apiKey: 'sk-test',
|
||||
apiMode: 'anthropic_messages',
|
||||
})
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(result.rootDir, 'settings.json'), 'utf-8'))
|
||||
expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/)
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/)
|
||||
})
|
||||
|
||||
it('keeps Codex model selection on the CLI while isolating CODEX_HOME', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'openrouter',
|
||||
model: 'openai/gpt-oss-20b:free',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
apiKey: 'sk-test',
|
||||
})
|
||||
|
||||
expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'codex'))
|
||||
expect(result.workspaceDir).toBe(join(home, 'coding-agent', 'workspace', 'default', 'openrouter'))
|
||||
expect(result.args).toEqual(['--model', 'openai/gpt-oss-20b:free'])
|
||||
expect(result.env).toEqual({ CODEX_HOME: result.rootDir })
|
||||
|
||||
const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8')
|
||||
expect(config).toContain('requires_openai_auth = false')
|
||||
expect(config).toContain(`model_catalog_json = "${join(result.rootDir, 'codex-model-catalog.json')}"`)
|
||||
|
||||
const catalog = JSON.parse(readFileSync(join(result.rootDir, 'codex-model-catalog.json'), 'utf-8'))
|
||||
expect(catalog.models.some((entry: any) => entry.slug === 'openai/gpt-oss-20b:free')).toBe(true)
|
||||
expect(catalog.models[0]).toHaveProperty('base_instructions')
|
||||
expect(catalog.models[0]).toHaveProperty('model_messages')
|
||||
})
|
||||
|
||||
it('points Codex Chat Completions providers at the local Responses proxy', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
|
||||
const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8')
|
||||
expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`)
|
||||
expect(config).toContain('wire_api = "responses"')
|
||||
expect(config).toContain('requires_openai_auth = false')
|
||||
expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/)
|
||||
expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'deepseek', 'codex'))
|
||||
|
||||
const catalog = JSON.parse(readFileSync(join(result.rootDir, 'codex-model-catalog.json'), 'utf-8'))
|
||||
const deepseekModel = catalog.models.find((entry: any) => entry.slug === 'deepseek-v4-pro')
|
||||
expect(deepseekModel).toMatchObject({
|
||||
display_name: 'Deepseek V4 Pro',
|
||||
})
|
||||
expect(deepseekModel.context_window).toBeGreaterThan(0)
|
||||
expect(deepseekModel.max_context_window).toBe(deepseekModel.context_window)
|
||||
expect(deepseekModel.model_messages.instructions_template).toContain('{{ base_instructions }}')
|
||||
})
|
||||
|
||||
it('points Codex Anthropic Messages providers at the local Responses proxy', async () => {
|
||||
const home = makeHome()
|
||||
|
||||
const result = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'anthropic-compatible',
|
||||
model: 'claude-sonnet-4-6',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'anthropic_messages',
|
||||
})
|
||||
|
||||
const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8')
|
||||
expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`)
|
||||
expect(config).toContain('wire_api = "responses"')
|
||||
expect(config).toContain('requires_openai_auth = false')
|
||||
expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/)
|
||||
expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'anthropic-compatible', 'codex'))
|
||||
})
|
||||
|
||||
it('adapts Codex Responses requests to OpenAI Chat Completions', async () => {
|
||||
makeHome()
|
||||
const launch = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8')
|
||||
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
|
||||
const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || ''
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
id: 'chatcmpl_test',
|
||||
choices: [{
|
||||
finish_reason: 'stop',
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
}],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(routeKey, token, {
|
||||
max_output_tokens: 16,
|
||||
input: [
|
||||
{ role: 'user', content: [{ type: 'input_text', text: 'hello' }] },
|
||||
{ role: 'developer', content: [{ type: 'input_text', text: 'be terse' }] },
|
||||
],
|
||||
})
|
||||
|
||||
await codexProxyResponses(ctx)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/v1/chat/completions', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }),
|
||||
}))
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(requestBody).toMatchObject({
|
||||
model: 'deepseek-v4-pro',
|
||||
max_tokens: 16,
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'system', content: 'be terse' },
|
||||
],
|
||||
})
|
||||
expect(ctx.body.output[0].content[0].text).toBe('ok')
|
||||
expect(ctx.body.usage).toMatchObject({ input_tokens: 3, output_tokens: 1, total_tokens: 4 })
|
||||
})
|
||||
|
||||
it('adapts Codex Responses requests to Anthropic Messages', async () => {
|
||||
makeHome()
|
||||
const launch = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'anthropic-compatible',
|
||||
model: 'claude-sonnet-4-6',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'anthropic_messages',
|
||||
})
|
||||
const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8')
|
||||
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
|
||||
const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || ''
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
id: 'msg_test',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-6',
|
||||
content: [
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { query: 'repo' } },
|
||||
],
|
||||
stop_reason: 'tool_use',
|
||||
usage: { input_tokens: 5, output_tokens: 2 },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(routeKey, token, {
|
||||
instructions: 'be terse',
|
||||
max_output_tokens: 64,
|
||||
input: [
|
||||
{ role: 'user', content: [{ type: 'input_text', text: 'hello' }] },
|
||||
{ type: 'function_call_output', call_id: 'call_0', output: 'done' },
|
||||
],
|
||||
tools: [{
|
||||
type: 'function',
|
||||
name: 'search',
|
||||
description: 'Search files',
|
||||
parameters: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
}],
|
||||
})
|
||||
|
||||
await codexProxyResponses(ctx)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer sk-upstream',
|
||||
'x-api-key': 'sk-upstream',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
}))
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(requestBody).toMatchObject({
|
||||
model: 'claude-sonnet-4-6',
|
||||
system: 'be terse',
|
||||
max_tokens: 64,
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_0', content: 'done' }] },
|
||||
],
|
||||
tools: [{
|
||||
name: 'search',
|
||||
description: 'Search files',
|
||||
input_schema: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
}],
|
||||
})
|
||||
expect(ctx.body.output[0].content[0].text).toBe('ok')
|
||||
expect(ctx.body.output[1]).toMatchObject({
|
||||
type: 'function_call',
|
||||
call_id: 'toolu_1',
|
||||
name: 'search',
|
||||
arguments: '{"query":"repo"}',
|
||||
})
|
||||
expect(ctx.body.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 })
|
||||
})
|
||||
|
||||
it('streams Codex proxy text as complete Responses message events', async () => {
|
||||
makeHome()
|
||||
const launch = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8')
|
||||
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
|
||||
const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || ''
|
||||
const encoder = new TextEncoder()
|
||||
const fetchMock = vi.fn(async () => new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"p"}}]}\n\n'))
|
||||
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"ong"}}]}\n\n'))
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(routeKey, token, {
|
||||
stream: true,
|
||||
input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
|
||||
})
|
||||
|
||||
await codexProxyResponses(ctx)
|
||||
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of ctx.body) chunks.push(String(chunk))
|
||||
const sse = chunks.join('')
|
||||
expect(sse).toContain('event: response.output_item.added')
|
||||
expect(sse).toContain('event: response.content_part.added')
|
||||
expect(sse).toContain('"delta":"p"')
|
||||
expect(sse).toContain('"delta":"ong"')
|
||||
expect(sse).toContain('event: response.output_text.done')
|
||||
expect(sse).toContain('"text":"pong"')
|
||||
expect(sse).toContain('event: response.output_item.done')
|
||||
expect(sse).toContain('"output":[{"type":"message"')
|
||||
})
|
||||
|
||||
it('streams Codex proxy Anthropic text as Responses message events', async () => {
|
||||
makeHome()
|
||||
const launch = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'anthropic-compatible',
|
||||
model: 'claude-sonnet-4-6',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'anthropic_messages',
|
||||
})
|
||||
const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8')
|
||||
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
|
||||
const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || ''
|
||||
const encoder = new TextEncoder()
|
||||
const fetchMock = vi.fn(async () => new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","usage":{"input_tokens":3,"output_tokens":0}}}\n\n'))
|
||||
controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'))
|
||||
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"he"}}\n\n'))
|
||||
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"llo"}}\n\n'))
|
||||
controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(routeKey, token, {
|
||||
stream: true,
|
||||
input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
|
||||
})
|
||||
|
||||
await codexProxyResponses(ctx)
|
||||
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of ctx.body) chunks.push(String(chunk))
|
||||
const sse = chunks.join('')
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ 'anthropic-version': '2023-06-01' }),
|
||||
}))
|
||||
expect(sse).toContain('event: response.output_item.added')
|
||||
expect(sse).toContain('"delta":"he"')
|
||||
expect(sse).toContain('"delta":"llo"')
|
||||
expect(sse).toContain('event: response.output_text.done')
|
||||
expect(sse).toContain('"text":"hello"')
|
||||
expect(sse).toContain('event: response.completed')
|
||||
})
|
||||
|
||||
it('exposes Codex proxy models with route-token authentication', async () => {
|
||||
makeHome()
|
||||
const launch = await prepareCodingAgentLaunch('codex', {
|
||||
profile: 'default',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8')
|
||||
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
|
||||
const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || ''
|
||||
const ctx = makeProxyContext(routeKey, token, {})
|
||||
|
||||
await codexProxyModels(ctx)
|
||||
|
||||
expect(ctx.body).toMatchObject({
|
||||
object: 'list',
|
||||
data: [{ id: 'deepseek-v4-pro', object: 'model', owned_by: 'deepseek' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('adapts Claude Code streaming requests to the Responses API for codex_responses providers', async () => {
|
||||
const target = registerClaudeCodeProxyTarget({
|
||||
provider: 'fun-codex',
|
||||
model: 'gpt-5.5',
|
||||
baseUrl: 'https://api.apikey.fun/v1',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'codex_responses',
|
||||
})
|
||||
const encoder = new TextEncoder()
|
||||
const fetchMock = vi.fn(async () => new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"response.output_text.delta","delta":"hi"}\n\n'))
|
||||
controller.enqueue(encoder.encode('data: {"type":"response.completed","response":{"status":"completed","usage":{"output_tokens":1}}}\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(target.routeKey, target.token, {
|
||||
stream: true,
|
||||
max_tokens: 32,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
|
||||
await claudeProxyMessages(ctx)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.apikey.fun/v1/responses', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }),
|
||||
}))
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(requestBody).toMatchObject({
|
||||
model: 'gpt-5.5',
|
||||
stream: true,
|
||||
store: false,
|
||||
max_output_tokens: 32,
|
||||
input: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of ctx.body) chunks.push(String(chunk))
|
||||
const sse = chunks.join('')
|
||||
expect(ctx.responseHeaders['Content-Type']).toContain('text/event-stream')
|
||||
expect(sse).toContain('event: message_start')
|
||||
expect(sse).toContain('"type":"text_delta","text":"hi"')
|
||||
expect(sse).toContain('event: message_stop')
|
||||
})
|
||||
|
||||
it('round-trips reasoning_content for DeepSeek-style OpenAI Chat tool calls', async () => {
|
||||
const target = registerClaudeCodeProxyTarget({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-reasoner',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
id: 'chatcmpl_test',
|
||||
choices: [{
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
reasoning_content: 'Need to inspect the repository first.',
|
||||
content: null,
|
||||
tool_calls: [{
|
||||
id: 'call_2',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"query":"proxy"}' },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 8 },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(target.routeKey, target.token, {
|
||||
max_tokens: 32,
|
||||
messages: [
|
||||
{ role: 'user', content: 'check it' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Need the current repo files.' },
|
||||
{ type: 'tool_use', id: 'call_1', name: 'search', input: { query: 'reasoning_content' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'call_1', content: 'found one file' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await claudeProxyMessages(ctx)
|
||||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(requestBody.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
reasoning_content: 'Need the current repo files.',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"query":"reasoning_content"}' },
|
||||
}],
|
||||
})
|
||||
expect(ctx.body.content[0]).toEqual({
|
||||
type: 'thinking',
|
||||
thinking: 'Need to inspect the repository first.',
|
||||
})
|
||||
expect(ctx.body.content[1]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'call_2',
|
||||
name: 'search',
|
||||
input: { query: 'proxy' },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes Anthropic Messages providers through the local proxy without exposing upstream credentials', async () => {
|
||||
const target = registerClaudeCodeProxyTarget({
|
||||
provider: 'fun-claude',
|
||||
model: 'claude-sonnet-4-6',
|
||||
baseUrl: 'https://api.apikey.fun',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'anthropic_messages',
|
||||
})
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
id: 'msg_test',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-6',
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const ctx = makeProxyContext(target.routeKey, target.token, {
|
||||
model: 'ignored-client-model',
|
||||
max_tokens: 32,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
})
|
||||
|
||||
await claudeProxyMessages(ctx)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.apikey.fun/v1/messages', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer sk-upstream',
|
||||
'x-api-key': 'sk-upstream',
|
||||
}),
|
||||
}))
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(requestBody.model).toBe('claude-sonnet-4-6')
|
||||
expect(ctx.body.content[0].text).toBe('hi')
|
||||
})
|
||||
|
||||
it('keeps Claude proxy routes separate for the same model with different protocols', () => {
|
||||
const chat = registerClaudeCodeProxyTarget({
|
||||
provider: 'same-provider',
|
||||
model: 'same-model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
apiKey: 'sk-chat',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
const anthropic = registerClaudeCodeProxyTarget({
|
||||
provider: 'same-provider',
|
||||
model: 'same-model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
apiKey: 'sk-anthropic',
|
||||
apiMode: 'anthropic_messages',
|
||||
})
|
||||
|
||||
expect(chat.routeKey).not.toBe(anthropic.routeKey)
|
||||
expect(chat.token).not.toBe(anthropic.token)
|
||||
})
|
||||
|
||||
it('keeps Codex proxy routes separate for the same model with different upstream URLs', () => {
|
||||
const first = registerCodexProxyTarget({
|
||||
profile: 'default',
|
||||
provider: 'same-provider',
|
||||
model: 'same-model',
|
||||
baseUrl: 'https://api-one.example.com/v1',
|
||||
apiKey: 'sk-one',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
const second = registerCodexProxyTarget({
|
||||
profile: 'default',
|
||||
provider: 'same-provider',
|
||||
model: 'same-model',
|
||||
baseUrl: 'https://api-two.example.com/v1',
|
||||
apiKey: 'sk-two',
|
||||
apiMode: 'chat_completions',
|
||||
})
|
||||
|
||||
expect(first.routeKey).not.toBe(second.routeKey)
|
||||
expect(first.token).not.toBe(second.token)
|
||||
})
|
||||
|
||||
it('exposes Claude-visible alias models from the local proxy models endpoint', async () => {
|
||||
const target = registerClaudeCodeProxyTarget({
|
||||
provider: 'openrouter',
|
||||
model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
apiKey: 'sk-upstream',
|
||||
apiMode: 'codex_responses',
|
||||
})
|
||||
const ctx = makeProxyContext(target.routeKey, target.token, {})
|
||||
|
||||
await claudeProxyModels(ctx)
|
||||
|
||||
const ids = ctx.body.data.map((model: any) => model.id)
|
||||
expect(ids).toContain('claude-haiku-4-5')
|
||||
expect(ids).toContain('claude-sonnet-4-6')
|
||||
expect(ids).toContain('claude-opus-4-7')
|
||||
expect(ids).toContain('cognitivecomputations/dolphin-mistral-24b-venice-edition:free')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
const { mockRestartGateway, mockDestroyProfile } = vi.hoisted(() => ({
|
||||
mockRestartGateway: vi.fn().mockResolvedValue({ running: true, profile: 'default' }),
|
||||
mockDestroyProfile: vi.fn().mockResolvedValue({ destroyed: true }),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/gateway-autostart', () => {
|
||||
return {
|
||||
restartGatewayForProfile: mockRestartGateway,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
|
||||
AgentBridgeClient: class {
|
||||
destroyProfile = mockDestroyProfile
|
||||
},
|
||||
}))
|
||||
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const tempHomes: string[] = []
|
||||
let hermesHome = ''
|
||||
|
||||
async function loadController() {
|
||||
vi.resetModules()
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
return import('../../packages/server/src/controllers/hermes/config')
|
||||
}
|
||||
|
||||
function makeCtx(body: unknown, profile?: string): any {
|
||||
return {
|
||||
request: { body },
|
||||
query: {},
|
||||
state: profile ? { profile: { name: profile } } : {},
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-controller-'))
|
||||
tempHomes.push(hermesHome)
|
||||
await mkdir(hermesHome, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
describe('config controller locked file updates', () => {
|
||||
it('deep merges a config section and restarts the gateway through hermes-cli', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'telegram:',
|
||||
' enabled: false',
|
||||
' extra:',
|
||||
' mode: old',
|
||||
'model:',
|
||||
' default: glm-5.1',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
const { updateConfig } = await loadController()
|
||||
const ctx = makeCtx({ section: 'telegram', values: { enabled: true, extra: { token_mode: 'env' } } })
|
||||
|
||||
await updateConfig(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
expect(mockRestartGateway).toHaveBeenCalledWith('default')
|
||||
expect(mockDestroyProfile).not.toHaveBeenCalled()
|
||||
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
expect(config.telegram.enabled).toBe(true)
|
||||
expect(config.telegram.extra).toEqual({ mode: 'old', token_mode: 'env' })
|
||||
expect(config.model.default).toBe('glm-5.1')
|
||||
})
|
||||
|
||||
it('clears credential env values and removes matching config fields without losing unrelated env keys', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'platforms:',
|
||||
' weixin:',
|
||||
' token: old-token',
|
||||
' extra:',
|
||||
' account_id: old-account',
|
||||
' base_url: https://old.example',
|
||||
'model:',
|
||||
' default: glm-5.1',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
await writeFile(join(hermesHome, '.env'), [
|
||||
'OPENROUTER_API_KEY=keep',
|
||||
'WEIXIN_TOKEN=old-token',
|
||||
'WEIXIN_ACCOUNT_ID=old-account',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
const { updateCredentials } = await loadController()
|
||||
const ctx = makeCtx({ platform: 'weixin', values: { token: '', extra: { account_id: '', base_url: 'https://new.example' } } })
|
||||
|
||||
await updateCredentials(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||
expect(env).not.toContain('WEIXIN_TOKEN=')
|
||||
expect(env).not.toContain('WEIXIN_ACCOUNT_ID=')
|
||||
expect(env).toContain('WEIXIN_BASE_URL=https://new.example')
|
||||
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
expect(config.platforms.weixin.token).toBeUndefined()
|
||||
expect(config.platforms.weixin.extra.account_id).toBeUndefined()
|
||||
expect(config.platforms.weixin.extra.base_url).toBe('https://old.example')
|
||||
expect(config.model.default).toBe('glm-5.1')
|
||||
})
|
||||
|
||||
it('writes QQBot credentials to env and overlays them into platform config reads', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'platforms:',
|
||||
' qqbot:',
|
||||
' extra:',
|
||||
' markdown_support: true',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
||||
const { updateCredentials, getConfig } = await loadController()
|
||||
|
||||
await updateCredentials(makeCtx({
|
||||
platform: 'qqbot',
|
||||
values: {
|
||||
extra: { app_id: 'qq-app', client_secret: 'qq-secret' },
|
||||
allowed_users: 'user-1,user-2',
|
||||
allow_all_users: false,
|
||||
},
|
||||
}))
|
||||
|
||||
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||
expect(env).toContain('QQ_APP_ID=qq-app')
|
||||
expect(env).toContain('QQ_CLIENT_SECRET=qq-secret')
|
||||
expect(env).toContain('QQ_ALLOWED_USERS=user-1,user-2')
|
||||
expect(env).toContain('QQ_ALLOW_ALL_USERS=false')
|
||||
|
||||
const ctx = makeCtx({})
|
||||
await getConfig(ctx)
|
||||
expect(ctx.body.platforms.qqbot.extra.app_id).toBe('qq-app')
|
||||
expect(ctx.body.platforms.qqbot.extra.client_secret).toBe('qq-secret')
|
||||
expect(ctx.body.platforms.qqbot.extra.markdown_support).toBe(true)
|
||||
expect(ctx.body.platforms.qqbot.allowed_users).toBe('user-1,user-2')
|
||||
expect(ctx.body.platforms.qqbot.allow_all_users).toBe(false)
|
||||
})
|
||||
|
||||
it('reads and writes channel settings in the request-scoped profile only', async () => {
|
||||
const researchDir = join(hermesHome, 'profiles', 'research')
|
||||
await mkdir(researchDir, { recursive: true })
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'telegram:',
|
||||
' require_mention: false',
|
||||
'model:',
|
||||
' default: keep-default-model',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
await writeFile(join(hermesHome, '.env'), [
|
||||
'TELEGRAM_BOT_TOKEN=keep-default-token',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
await writeFile(join(researchDir, 'config.yaml'), [
|
||||
'telegram:',
|
||||
' require_mention: false',
|
||||
'model:',
|
||||
' default: research-model',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
await writeFile(join(researchDir, '.env'), [
|
||||
'TELEGRAM_BOT_TOKEN=old-research-token',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
|
||||
const { updateConfig, updateCredentials, getConfig } = await loadController()
|
||||
|
||||
await updateConfig(makeCtx({
|
||||
section: 'telegram',
|
||||
values: { require_mention: true, free_response_chats: 'chat-1' },
|
||||
}, 'research'))
|
||||
await updateCredentials(makeCtx({
|
||||
platform: 'telegram',
|
||||
values: { token: 'new-research-token' },
|
||||
}, 'research'))
|
||||
|
||||
expect(mockRestartGateway).toHaveBeenCalledWith('research')
|
||||
expect(mockDestroyProfile).not.toHaveBeenCalled()
|
||||
const defaultConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
const researchConfig = YAML.load(await readFile(join(researchDir, 'config.yaml'), 'utf-8')) as any
|
||||
expect(defaultConfig.telegram.require_mention).toBe(false)
|
||||
expect(researchConfig.telegram.require_mention).toBe(true)
|
||||
expect(researchConfig.telegram.free_response_chats).toBe('chat-1')
|
||||
expect(await readFile(join(hermesHome, '.env'), 'utf-8')).toContain('TELEGRAM_BOT_TOKEN=keep-default-token')
|
||||
expect(await readFile(join(researchDir, '.env'), 'utf-8')).toContain('TELEGRAM_BOT_TOKEN=new-research-token')
|
||||
|
||||
const ctx = makeCtx({}, 'research')
|
||||
await getConfig(ctx)
|
||||
expect(ctx.body.platforms.telegram.token).toBe('new-research-token')
|
||||
expect(ctx.body.telegram.require_mention).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const tempHomes: string[] = []
|
||||
let hermesHome = ''
|
||||
|
||||
async function loadHelpers() {
|
||||
vi.resetModules()
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
return import('../../packages/server/src/services/config-helpers')
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-helpers-'))
|
||||
tempHomes.push(hermesHome)
|
||||
await mkdir(hermesHome, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
describe('config-helpers locked file updates', () => {
|
||||
it('merges concurrent config.yaml updates by re-reading under the file lock', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: old\n', 'utf-8')
|
||||
const { updateConfigYaml } = await loadHelpers()
|
||||
|
||||
await Promise.all([
|
||||
updateConfigYaml(async (cfg) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
cfg.model.default = 'glm-5.1'
|
||||
return cfg
|
||||
}),
|
||||
updateConfigYaml((cfg) => {
|
||||
cfg.platforms = cfg.platforms || {}
|
||||
cfg.platforms.api_server = { extra: { port: 8648 } }
|
||||
return cfg
|
||||
}),
|
||||
])
|
||||
|
||||
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
expect(config.model.default).toBe('glm-5.1')
|
||||
expect(config.platforms.api_server.extra.port).toBe(8648)
|
||||
await expect(readFile(join(hermesHome, 'config.yaml.bak'), 'utf-8')).resolves.toContain('model:')
|
||||
})
|
||||
|
||||
it('serializes concurrent .env updates without losing keys', async () => {
|
||||
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
||||
const { saveEnvValue } = await loadHelpers()
|
||||
|
||||
await Promise.all([
|
||||
saveEnvValue('DEEPSEEK_API_KEY', 'deepseek'),
|
||||
saveEnvValue('MOONSHOT_API_KEY', 'moonshot'),
|
||||
])
|
||||
|
||||
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||
expect(env).toContain('DEEPSEEK_API_KEY=deepseek')
|
||||
expect(env).toContain('MOONSHOT_API_KEY=moonshot')
|
||||
})
|
||||
|
||||
it('rejects invalid .env keys instead of writing keyless lines', async () => {
|
||||
const envPath = join(hermesHome, '.env')
|
||||
await writeFile(envPath, 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
||||
const { saveEnvValue } = await loadHelpers()
|
||||
|
||||
await expect(saveEnvValue('', 'leaked-value')).rejects.toThrow('Invalid .env key')
|
||||
await expect(saveEnvValue('=BROKEN', 'leaked-value')).rejects.toThrow('Invalid .env key')
|
||||
|
||||
const env = await readFile(envPath, 'utf-8')
|
||||
expect(env).toBe('OPENROUTER_API_KEY=keep\n')
|
||||
expect(env).not.toContain('=leaked-value')
|
||||
})
|
||||
|
||||
it('skips writing config.yaml when an updater returns write false', async () => {
|
||||
const configPath = join(hermesHome, 'config.yaml')
|
||||
await writeFile(configPath, 'model:\n default: old\n', 'utf-8')
|
||||
const before = await readFile(configPath, 'utf-8')
|
||||
const { updateConfigYaml } = await loadHelpers()
|
||||
|
||||
const result = await updateConfigYaml((cfg) => ({ data: cfg, result: 'unchanged', write: false }))
|
||||
|
||||
expect(result).toBe('unchanged')
|
||||
await expect(readFile(configPath, 'utf-8')).resolves.toBe(before)
|
||||
await expect(readFile(`${configPath}.bak`, 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('strips api_server config before gateway restart', async () => {
|
||||
const { stripLegacyApiServerGatewayConfig } = await loadHelpers()
|
||||
const result = stripLegacyApiServerGatewayConfig({
|
||||
model: { default: 'glm-5.1' },
|
||||
platforms: {
|
||||
api_server: {
|
||||
enabled: true,
|
||||
key: '',
|
||||
cors_origins: '*',
|
||||
extra: {
|
||||
port: 8642,
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
},
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.config).toEqual({
|
||||
model: { default: 'glm-5.1' },
|
||||
platforms: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('removes custom api_server fields as well', async () => {
|
||||
const { stripLegacyApiServerGatewayConfig } = await loadHelpers()
|
||||
const result = stripLegacyApiServerGatewayConfig({
|
||||
platforms: {
|
||||
api_server: {
|
||||
key: 'custom-key',
|
||||
cors_origins: 'https://example.com',
|
||||
extra: {
|
||||
port: 8642,
|
||||
host: '127.0.0.1',
|
||||
mode: 'custom',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.config).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
pinSkill: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
getSkillUsageStatsFromDb: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db', () => ({
|
||||
getDb: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
|
||||
MODEL_CONTEXT_TABLE: 'model_context',
|
||||
}))
|
||||
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const tempHomes: string[] = []
|
||||
let hermesHome = ''
|
||||
|
||||
async function loadModelsController() {
|
||||
vi.resetModules()
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
return import('../../packages/server/src/controllers/hermes/models')
|
||||
}
|
||||
|
||||
async function loadSkillsController() {
|
||||
vi.resetModules()
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
return import('../../packages/server/src/controllers/hermes/skills')
|
||||
}
|
||||
|
||||
function makeCtx(body: unknown): any {
|
||||
return {
|
||||
request: { body },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
query: {},
|
||||
params: {},
|
||||
state: {},
|
||||
get: vi.fn(() => ''),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-controller-'))
|
||||
tempHomes.push(hermesHome)
|
||||
await mkdir(hermesHome, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
hermesHome = ''
|
||||
})
|
||||
|
||||
describe('config mutating controllers', () => {
|
||||
it('setConfigModel updates only the model section and preserves existing config', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'terminal:',
|
||||
' backend: local',
|
||||
'model:',
|
||||
' default: old',
|
||||
' provider: old-provider',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
const { setConfigModel } = await loadModelsController()
|
||||
const ctx = makeCtx({ default: 'glm-5.1', provider: 'custom:glm' })
|
||||
|
||||
await setConfigModel(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:glm' })
|
||||
expect(config.terminal.backend).toBe('local')
|
||||
})
|
||||
|
||||
it('setConfigModel uses the requested profile header when auth has not populated state.profile', async () => {
|
||||
const researchDir = join(hermesHome, 'profiles', 'research')
|
||||
await mkdir(researchDir, { recursive: true })
|
||||
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: root-model\n', 'utf-8')
|
||||
await writeFile(join(researchDir, 'config.yaml'), 'model:\n default: old-research\n', 'utf-8')
|
||||
const { setConfigModel } = await loadModelsController()
|
||||
const ctx = makeCtx({ default: 'research-model', provider: 'deepseek' })
|
||||
ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'research' : '')
|
||||
|
||||
await setConfigModel(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
const rootConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
const researchConfig = YAML.load(await readFile(join(researchDir, 'config.yaml'), 'utf-8')) as any
|
||||
expect(rootConfig.model.default).toBe('root-model')
|
||||
expect(researchConfig.model).toEqual({ default: 'research-model', provider: 'deepseek' })
|
||||
})
|
||||
|
||||
it('skill toggle preserves unrelated config while adding and removing disabled skills', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'model:',
|
||||
' default: glm-5.1',
|
||||
'skills:',
|
||||
' disabled:',
|
||||
' - old-skill',
|
||||
'',
|
||||
].join('\n'), 'utf-8')
|
||||
const { toggle } = await loadSkillsController()
|
||||
|
||||
await toggle(makeCtx({ name: 'new-skill', enabled: false }))
|
||||
await toggle(makeCtx({ name: 'old-skill', enabled: true }))
|
||||
|
||||
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||
expect(config.model.default).toBe('glm-5.1')
|
||||
expect(config.skills.disabled).toEqual(['new-skill'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { homedir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { getListenHost, getWebUiHome, shouldCreateWebUiDataDir } from '../../packages/server/src/config'
|
||||
|
||||
describe('server config', () => {
|
||||
it('defaults to an IPv4 bind host', () => {
|
||||
expect(getListenHost({})).toBe('0.0.0.0')
|
||||
})
|
||||
|
||||
it('uses BIND_HOST when provided', () => {
|
||||
expect(getListenHost({ BIND_HOST: ' :: ' })).toBe('::')
|
||||
})
|
||||
|
||||
it('ignores blank BIND_HOST values', () => {
|
||||
expect(getListenHost({ BIND_HOST: ' ' })).toBe('0.0.0.0')
|
||||
})
|
||||
|
||||
it('defaults web-ui home to ~/.hermes-web-ui', () => {
|
||||
expect(getWebUiHome({})).toBe(join(homedir(), '.hermes-web-ui'))
|
||||
})
|
||||
|
||||
it('uses HERMES_WEB_UI_HOME when provided', () => {
|
||||
expect(getWebUiHome({ HERMES_WEB_UI_HOME: ' ./tmp/hermes-ui ' })).toBe(resolve('./tmp/hermes-ui'))
|
||||
})
|
||||
|
||||
it('uses HERMES_WEBUI_STATE_DIR as a compatibility alias', () => {
|
||||
expect(getWebUiHome({ HERMES_WEBUI_STATE_DIR: ' ./tmp/hermes-state ' })).toBe(resolve('./tmp/hermes-state'))
|
||||
})
|
||||
|
||||
it('only creates the development data directory outside production', () => {
|
||||
expect(shouldCreateWebUiDataDir({ NODE_ENV: 'development' })).toBe(true)
|
||||
expect(shouldCreateWebUiDataDir({ NODE_ENV: 'production' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,502 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getCompressionSnapshotMock = vi.fn()
|
||||
const saveCompressionSnapshotMock = vi.fn()
|
||||
const deleteCompressionSnapshotMock = vi.fn()
|
||||
const bridgeRequestMock = vi.fn()
|
||||
const bridgeDestroyMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
||||
getCompressionSnapshot: getCompressionSnapshotMock,
|
||||
saveCompressionSnapshot: saveCompressionSnapshotMock,
|
||||
deleteCompressionSnapshot: deleteCompressionSnapshotMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
|
||||
AgentBridgeClient: class {
|
||||
request = bridgeRequestMock
|
||||
destroy = bridgeDestroyMock
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ChatContextCompressor', () => {
|
||||
let originalFetch: typeof global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch
|
||||
getCompressionSnapshotMock.mockReset()
|
||||
saveCompressionSnapshotMock.mockReset()
|
||||
deleteCompressionSnapshotMock.mockReset()
|
||||
bridgeRequestMock.mockReset()
|
||||
bridgeDestroyMock.mockReset()
|
||||
bridgeRequestMock.mockRejectedValue(new Error('summarizer failed'))
|
||||
bridgeDestroyMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('keeps full history when full summarization fails', async () => {
|
||||
const { ChatContextCompressor } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({ config: { tailMessageCount: 3 } })
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages).toHaveLength(messages.length)
|
||||
expect(result.messages.map(m => m.content)).toEqual(messages.map(m => m.content))
|
||||
expect(result.meta.compressed).toBe(false)
|
||||
expect(result.meta.llmCompressed).toBe(false)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps all new messages when incremental summarization fails', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({ config: { tailMessageCount: 3 } })
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 1,
|
||||
messageCountAtTime: 2,
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages).toHaveLength(7)
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: `${SUMMARY_PREFIX}\n\nprevious summary`,
|
||||
})
|
||||
expect(result.messages.slice(1).map(m => m.content)).toEqual(messages.slice(2).map(m => m.content))
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(false)
|
||||
expect(result.meta.compressedStartIndex).toBe(1)
|
||||
expect(result.meta.verbatimCount).toBe(6)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call the summarizer when snapshot has only tail messages after it', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({ config: { tailMessageCount: 10 } })
|
||||
const messages = Array.from({ length: 6 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 3,
|
||||
messageCountAtTime: 4,
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.messages).toHaveLength(3)
|
||||
expect(result.messages[0].content).toBe(`${SUMMARY_PREFIX}\n\nprevious summary`)
|
||||
expect(result.messages.slice(1).map(m => m.content)).toEqual(['message 4', 'message 5'])
|
||||
expect(result.meta.llmCompressed).toBe(false)
|
||||
expect(result.meta.compressedStartIndex).toBe(3)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps configured first and last messages during full compression', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 3, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = Array.from({ length: 10 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\ncompressed summary`,
|
||||
'message 7',
|
||||
'message 8',
|
||||
'message 9',
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(5)
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'compressed summary', 6, 10)
|
||||
})
|
||||
|
||||
it('routes summarization through the provided worker key and destroys only the temporary agent session', async () => {
|
||||
const { ChatContextCompressor } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 0, tailMessageCount: 1, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = [
|
||||
{ role: 'user', content: 'old context' },
|
||||
{ role: 'assistant', content: 'old response' },
|
||||
{ role: 'user', content: 'tail' },
|
||||
]
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
await compressor.compress(messages, 'http://upstream', undefined, 's1', {
|
||||
profile: 'default',
|
||||
workerKey: 'default:compression:s1',
|
||||
})
|
||||
|
||||
expect(bridgeRequestMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'chat',
|
||||
profile: 'default',
|
||||
worker_key: 'default:compression:s1',
|
||||
message: 'Generate the context checkpoint summary now.',
|
||||
wait: true,
|
||||
}), expect.any(Object))
|
||||
const request = bridgeRequestMock.mock.calls[0][0]
|
||||
expect(request.conversation_history[0]).toEqual(expect.objectContaining({
|
||||
role: 'user',
|
||||
content: expect.stringContaining('TURNS TO SUMMARIZE:'),
|
||||
}))
|
||||
const compressSessionId = bridgeRequestMock.mock.calls[0][0].session_id
|
||||
expect(String(compressSessionId)).toMatch(/^compress_/)
|
||||
expect(bridgeDestroyMock).toHaveBeenCalledWith(
|
||||
compressSessionId,
|
||||
'default',
|
||||
'default:compression:s1',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not pre-prune tool results before sending them to the summarizer', async () => {
|
||||
const { ChatContextCompressor } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 0, tailMessageCount: 1, summaryBudget: 1000 },
|
||||
})
|
||||
const longToolOutput = `${'x'.repeat(180)}KEEP_MARKER${'y'.repeat(180)}`
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'calling terminal',
|
||||
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'terminal', arguments: '{}' } }],
|
||||
},
|
||||
{ role: 'tool', name: 'terminal', tool_call_id: 'call_1', content: longToolOutput },
|
||||
{ role: 'user', content: 'tail' },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
const request = bridgeRequestMock.mock.calls[0][0]
|
||||
const serializedHistory = JSON.stringify(request.conversation_history)
|
||||
expect(serializedHistory).toContain('KEEP_MARKER')
|
||||
expect(serializedHistory).not.toContain('[terminal] ')
|
||||
})
|
||||
|
||||
it('keeps protected head tool results verbatim after successful full compression', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 1, summaryBudget: 1000 },
|
||||
})
|
||||
const longToolOutput = `${'head-tool-output '.repeat(30)}KEEP_HEAD_TOOL`
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'calling terminal',
|
||||
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'terminal', arguments: '{}' } }],
|
||||
},
|
||||
{ role: 'tool', name: 'terminal', tool_call_id: 'call_1', content: longToolOutput },
|
||||
{ role: 'user', content: 'middle' },
|
||||
{ role: 'assistant', content: 'tail' },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'calling terminal',
|
||||
longToolOutput,
|
||||
`${SUMMARY_PREFIX}\n\ncompressed summary`,
|
||||
'tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the previous summary plus a safe tail when an existing snapshot index is stale', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 3, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'stale previous summary',
|
||||
lastMessageIndex: 20,
|
||||
messageCountAtTime: 21,
|
||||
})
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'rebuilt summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(deleteCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(bridgeRequestMock).not.toHaveBeenCalled()
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\nstale previous summary`,
|
||||
'message 5',
|
||||
'message 6',
|
||||
'message 7',
|
||||
])
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('folds a stale snapshot safe tail into a new summary when it still exceeds budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 800, headMessageCount: 2, tailMessageCount: 3, summaryBudget: 1000 },
|
||||
})
|
||||
const largeTail = 'tail-token '.repeat(200)
|
||||
const messages = [
|
||||
{ role: 'user', content: 'message 0' },
|
||||
{ role: 'assistant', content: 'message 1' },
|
||||
{ role: 'user', content: 'message 2' },
|
||||
{ role: 'assistant', content: 'message 3' },
|
||||
{ role: 'user', content: 'message 4' },
|
||||
{ role: 'assistant', content: largeTail },
|
||||
{ role: 'user', content: largeTail },
|
||||
{ role: 'assistant', content: largeTail },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'stale previous summary',
|
||||
lastMessageIndex: 20,
|
||||
messageCountAtTime: 21,
|
||||
})
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'updated stale summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(deleteCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(bridgeRequestMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\nupdated stale summary`,
|
||||
])
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'updated stale summary', 7, 8)
|
||||
})
|
||||
|
||||
it('compresses the full history when protected windows cover all messages', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 3, tailMessageCount: 20, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = Array.from({ length: 20 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed all messages' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(bridgeRequestMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
`${SUMMARY_PREFIX}\n\ncompressed all messages`,
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(0)
|
||||
expect(result.meta.compressedStartIndex).toBe(19)
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'compressed all messages', 19, 20)
|
||||
})
|
||||
|
||||
it('drops protected messages when compressed output still exceeds the trigger budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 200, headMessageCount: 2, tailMessageCount: 2, summaryBudget: 100 },
|
||||
})
|
||||
const largeText = 'tail-token '.repeat(500)
|
||||
const messages = [
|
||||
{ role: 'user', content: 'head 0' },
|
||||
{ role: 'assistant', content: 'head 1' },
|
||||
{ role: 'user', content: 'middle 2' },
|
||||
{ role: 'assistant', content: 'middle 3' },
|
||||
{ role: 'user', content: largeText },
|
||||
{ role: 'assistant', content: largeText },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'short summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
`${SUMMARY_PREFIX}\n\nshort summary`,
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(0)
|
||||
})
|
||||
|
||||
it('truncates the summary when the summary alone exceeds the trigger budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX, countTokens } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 120, headMessageCount: 2, tailMessageCount: 2, summaryBudget: 100 },
|
||||
})
|
||||
const messages = Array.from({ length: 6 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
const longSummary = 'summary-token '.repeat(500)
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: longSummary },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages).toHaveLength(1)
|
||||
expect(String(result.messages[0].content)).toContain('[Summary truncated to fit context budget]')
|
||||
expect(String(result.messages[0].content).startsWith(SUMMARY_PREFIX)).toBe(true)
|
||||
expect(countTokens(String(result.messages[0].content))).toBeLessThanOrEqual(140)
|
||||
expect(result.meta.verbatimCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps configured first messages when incremental compression reuses an existing snapshot', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 10 },
|
||||
})
|
||||
const messages = Array.from({ length: 6 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 3,
|
||||
messageCountAtTime: 4,
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(bridgeRequestMock).not.toHaveBeenCalled()
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\nprevious summary`,
|
||||
'message 4',
|
||||
'message 5',
|
||||
])
|
||||
expect(result.meta.verbatimCount).toBe(4)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('folds all new messages into the summary when incremental tail protection would exceed budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 1000, headMessageCount: 3, tailMessageCount: 20, summaryBudget: 100 },
|
||||
})
|
||||
const largeText = 'new-token '.repeat(80)
|
||||
const messages = [
|
||||
{ role: 'user', content: 'head 0' },
|
||||
{ role: 'assistant', content: 'head 1' },
|
||||
{ role: 'user', content: 'head 2' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `${largeText}${i}`,
|
||||
})),
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 2,
|
||||
messageCountAtTime: 3,
|
||||
})
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'updated summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(bridgeRequestMock).toHaveBeenCalledTimes(1)
|
||||
const request = bridgeRequestMock.mock.calls[0][0]
|
||||
expect(request.message).toBe('Generate the context checkpoint summary now.')
|
||||
expect(request.conversation_history.slice(0, 3)).toEqual([
|
||||
{ role: 'user', content: '[Previous summary]\nprevious summary' },
|
||||
{ role: 'assistant', content: 'Understood, I will update the summary.' },
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: expect.stringContaining('NEW TURNS TO INCORPORATE:'),
|
||||
}),
|
||||
])
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'head 0',
|
||||
'head 1',
|
||||
'head 2',
|
||||
`${SUMMARY_PREFIX}\n\nupdated summary`,
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(3)
|
||||
expect(result.meta.compressedStartIndex).toBe(22)
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'updated summary', 22, 23)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,587 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SummaryCache } from '../../packages/server/src/services/hermes/context-engine/summary-cache'
|
||||
import {
|
||||
buildAgentInstructions,
|
||||
buildSummarizationSystemPrompt,
|
||||
buildFullSummaryPrompt,
|
||||
buildIncrementalUpdatePrompt,
|
||||
} from '../../packages/server/src/services/hermes/context-engine/prompt'
|
||||
import { ContextEngine } from '../../packages/server/src/services/hermes/context-engine/compressor'
|
||||
import type { StoredMessage, MessageFetcher, GatewayCaller } from '../../packages/server/src/services/hermes/context-engine/types'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function makeMessage(overrides: Partial<StoredMessage> = {}): StoredMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
roomId: 'room-1',
|
||||
senderId: 'user-1',
|
||||
senderName: 'Alice',
|
||||
content: 'Hello world',
|
||||
timestamp: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMessages(count: number, roomId = 'room-1', startTimestamp = 1000): StoredMessage[] {
|
||||
return Array.from({ length: count }, (_, i) => makeMessage({
|
||||
id: `msg-${i}`,
|
||||
roomId,
|
||||
senderId: i % 3 === 0 ? 'agent-socket' : `user-${i}`,
|
||||
senderName: i % 3 === 0 ? 'Claude' : `User${i}`,
|
||||
content: `Message ${i} with some content`,
|
||||
timestamp: startTimestamp + i * 1000,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── SummaryCache ─────────────────────────────────────────────
|
||||
|
||||
describe('SummaryCache', () => {
|
||||
it('stores and retrieves entries', () => {
|
||||
const cache = new SummaryCache(60_000)
|
||||
cache.set('room-1', {
|
||||
summary: 'Summary text',
|
||||
lastMessageId: 'msg-10',
|
||||
lastMessageTimestamp: 5000,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
const entry = cache.get('room-1')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.summary).toBe('Summary text')
|
||||
})
|
||||
|
||||
it('returns undefined for expired entries', () => {
|
||||
const cache = new SummaryCache(100) // 100ms TTL
|
||||
cache.set('room-1', {
|
||||
summary: 'Old summary',
|
||||
lastMessageId: 'msg-5',
|
||||
lastMessageTimestamp: 5000,
|
||||
createdAt: Date.now() - 200, // created 200ms ago
|
||||
})
|
||||
expect(cache.get('room-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('invalidates entries for a room', () => {
|
||||
const cache = new SummaryCache(60_000)
|
||||
cache.set('room-1', { summary: 'A', lastMessageId: 'msg-1', lastMessageTimestamp: 1000, createdAt: Date.now() })
|
||||
cache.set('room-2', { summary: 'C', lastMessageId: 'msg-3', lastMessageTimestamp: 3000, createdAt: Date.now() })
|
||||
|
||||
cache.invalidate('room-1')
|
||||
expect(cache.get('room-1')).toBeUndefined()
|
||||
expect(cache.get('room-2')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enforces max entry limit', () => {
|
||||
const cache = new SummaryCache(60_000)
|
||||
// Fill cache beyond limit (internal MAX_ENTRIES = 200)
|
||||
for (let i = 0; i < 210; i++) {
|
||||
cache.set(`room-${i}`, {
|
||||
summary: `Summary ${i}`,
|
||||
lastMessageId: `msg-${i}`,
|
||||
lastMessageTimestamp: i * 1000,
|
||||
createdAt: Date.now() - (210 - i), // earlier entries have older createdAt
|
||||
})
|
||||
}
|
||||
// Cache should not exceed 200 entries
|
||||
expect(cache.size).toBeLessThanOrEqual(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Prompts ──────────────────────────────────────────────────
|
||||
|
||||
describe('prompts', () => {
|
||||
it('builds agent instructions with all fields', () => {
|
||||
const result = buildAgentInstructions({
|
||||
agentName: 'Claude',
|
||||
roomName: 'general',
|
||||
agentDescription: 'AI coding assistant',
|
||||
memberNames: ['Alice', 'Bob', 'Claude'],
|
||||
members: [
|
||||
{ userId: 'u1', name: 'Alice', description: 'dev' },
|
||||
{ userId: 'u2', name: 'Bob', description: 'designer' },
|
||||
{ userId: 'u3', name: 'Claude', description: '' },
|
||||
],
|
||||
})
|
||||
expect(result).toContain('"Claude"')
|
||||
expect(result).toContain('general')
|
||||
expect(result).toContain('AI coding assistant')
|
||||
expect(result).toContain('Alice')
|
||||
expect(result).toContain('Bob')
|
||||
expect(result).toContain('- Claude')
|
||||
expect(result).not.toContain('@Claude')
|
||||
})
|
||||
|
||||
it('builds agent instructions with empty member list', () => {
|
||||
const result = buildAgentInstructions({
|
||||
agentName: 'GPT',
|
||||
roomName: 'dev',
|
||||
agentDescription: 'Helper',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
})
|
||||
expect(result).toContain('"GPT"')
|
||||
expect(result).toContain('未知')
|
||||
})
|
||||
|
||||
it('builds agent instructions using memberNames when members is empty', () => {
|
||||
const result = buildAgentInstructions({
|
||||
agentName: 'GPT',
|
||||
roomName: 'dev',
|
||||
agentDescription: 'Helper',
|
||||
memberNames: ['Alice', 'Bob'],
|
||||
members: [],
|
||||
})
|
||||
expect(result).toContain('Alice')
|
||||
expect(result).toContain('Bob')
|
||||
})
|
||||
|
||||
it('builds summarization system prompt', () => {
|
||||
const result = buildSummarizationSystemPrompt()
|
||||
expect(result).toContain('摘要')
|
||||
})
|
||||
|
||||
it('builds full summary prompt', () => {
|
||||
const result = buildFullSummaryPrompt()
|
||||
expect(result).toContain('摘要')
|
||||
})
|
||||
|
||||
it('builds incremental update prompt', () => {
|
||||
const result = buildIncrementalUpdatePrompt()
|
||||
expect(result).toContain('更新')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ContextEngine.buildContext ────────────────────────────────
|
||||
|
||||
describe('ContextEngine.buildContext', () => {
|
||||
let mockSummarize = vi.fn().mockResolvedValue({ summary: 'Summary of conversation.', sessionId: 'comp-1' })
|
||||
const mockGatewayCaller: GatewayCaller = {
|
||||
summarize: mockSummarize,
|
||||
}
|
||||
|
||||
let mockFetcher: MessageFetcher
|
||||
let engine: ContextEngine
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetcher = {
|
||||
getMessages: vi.fn().mockReturnValue([]),
|
||||
getContextSnapshot: vi.fn().mockReturnValue(null),
|
||||
saveContextSnapshot: vi.fn(),
|
||||
deleteContextSnapshot: vi.fn(),
|
||||
}
|
||||
engine = new ContextEngine({
|
||||
config: { maxHistoryTokens: 4000, tailMessageCount: 10, triggerTokens: 100_000, charsPerToken: 4, summarizationTimeoutMs: 30_000 },
|
||||
messageFetcher: mockFetcher,
|
||||
gatewayCaller: { summarize: mockSummarize },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns all messages as history when under threshold', async () => {
|
||||
const messages = makeMessages(10) // 10 messages, under trigger threshold
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: ['Alice'],
|
||||
members: [{ userId: 'u1', name: 'Alice', description: '' }],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
expect(result.meta.totalMessages).toBe(10)
|
||||
expect(result.meta.compressed).toBe(false)
|
||||
expect(result.conversationHistory).toHaveLength(10)
|
||||
expect(result.instructions).toContain('Claude')
|
||||
// No LLM call for short conversations
|
||||
expect(mockSummarize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records full context token estimates without compressing when under threshold', async () => {
|
||||
const messages = makeMessages(3)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
const contextTokenEstimator = vi.fn().mockResolvedValue(19_379)
|
||||
const onProgress = vi.fn()
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: ['Alice'],
|
||||
members: [{ userId: 'u1', name: 'Alice', description: '' }],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
contextTokenEstimator,
|
||||
onProgress,
|
||||
})
|
||||
|
||||
expect(result.meta.compressed).toBe(false)
|
||||
expect(result.meta.contextTokenEstimate).toBe(19_379)
|
||||
expect(result.meta.messageTokenEstimate).toBeGreaterThan(0)
|
||||
expect(contextTokenEstimator).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([{ role: 'assistant', content: expect.stringContaining('[Claude]') }]),
|
||||
expect.stringContaining('"Claude"'),
|
||||
)
|
||||
expect(mockSummarize).not.toHaveBeenCalled()
|
||||
expect(onProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses full context token estimates to trigger group compression', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
const onProgress = vi.fn()
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
contextTokenEstimator: vi.fn().mockResolvedValue(120_000),
|
||||
onProgress,
|
||||
})
|
||||
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.contextTokenEstimate).toBe(120_000)
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetcher.saveContextSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(onProgress).toHaveBeenCalledWith({
|
||||
status: 'compressing',
|
||||
path: 'full',
|
||||
messageCount: 20,
|
||||
tokenCount: 120_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when group prompt and tools exceed threshold with too little history to compress', async () => {
|
||||
const messages = makeMessages(4)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
await expect(engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
contextTokenEstimator: vi.fn().mockResolvedValue(120_000),
|
||||
})).rejects.toThrow('Context window is too small')
|
||||
|
||||
expect(mockSummarize).not.toHaveBeenCalled()
|
||||
expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws on snapshot path when overhead plus new messages exceed threshold without compressible history', async () => {
|
||||
const messages = makeMessages(12)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: 'Existing summary',
|
||||
lastMessageId: 'msg-9',
|
||||
lastMessageTimestamp: messages[9].timestamp,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
await expect(engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
contextTokenEstimator: vi.fn().mockResolvedValue(120_000),
|
||||
})).rejects.toThrow('Context window is too small')
|
||||
|
||||
expect(mockSummarize).not.toHaveBeenCalled()
|
||||
expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('splits into head/tail and compresses middle when over threshold', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 }, // Force compression with tiny threshold
|
||||
})
|
||||
|
||||
expect(result.meta.totalMessages).toBe(20)
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses cache hit when available and no new messages', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
// First call — creates snapshot (with forced compression)
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
// Verify snapshot was saved
|
||||
expect(mockFetcher.saveContextSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Simulate that the snapshot now exists in storage
|
||||
const savedSnapshot = mockFetcher.saveContextSnapshot.mock.calls[0]
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: savedSnapshot[1],
|
||||
lastMessageId: savedSnapshot[2],
|
||||
lastMessageTimestamp: savedSnapshot[3],
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
// Second call — cache hit (snapshot exists, same messages)
|
||||
const result2 = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
expect(result2.meta.hadSnapshot).toBe(true)
|
||||
// Only one LLM call (from the first buildContext)
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does incremental update when cache hit with new messages', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
// First call — full compression (with forced compression)
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
// Simulate that the snapshot now exists in storage
|
||||
const savedSnapshot = mockFetcher.saveContextSnapshot.mock.calls[0]
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: savedSnapshot[1],
|
||||
lastMessageId: savedSnapshot[2],
|
||||
lastMessageTimestamp: savedSnapshot[3],
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
// First call: no previousSummary
|
||||
// GatewayCaller.summarize signature: upstream, apiKey, systemPrompt, messages, roomId, profile, previousSummary
|
||||
const firstCallArgs = mockSummarize.mock.calls[0]
|
||||
expect(firstCallArgs[4]).toBe('room-1') // roomId
|
||||
expect(firstCallArgs[5]).toBe('default') // profile
|
||||
expect(firstCallArgs[6]).toBeUndefined() // previousSummary not passed
|
||||
|
||||
// Insert a new message
|
||||
const middleInsert = makeMessage({
|
||||
id: 'msg-new', roomId: 'room-1', senderId: 'user-99',
|
||||
senderName: 'NewUser', content: 'New middle message', timestamp: 12000,
|
||||
})
|
||||
const updatedMessages = [...messages.slice(0, 9), middleInsert, ...messages.slice(9)]
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(updatedMessages)
|
||||
|
||||
const onProgress = vi.fn()
|
||||
// Second call — incremental update
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: updatedMessages[updatedMessages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
onProgress,
|
||||
})
|
||||
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(2)
|
||||
// Second call: has previousSummary
|
||||
const secondCallArgs = mockSummarize.mock.calls[1]
|
||||
expect(secondCallArgs[6]).toBe('Summary of conversation.')
|
||||
expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'compressing',
|
||||
path: 'snapshot',
|
||||
}))
|
||||
})
|
||||
|
||||
it('falls back to no-summary on LLM failure', async () => {
|
||||
mockSummarize.mockRejectedValue(new Error('LLM timeout'))
|
||||
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
// Should not throw, and should still return history
|
||||
expect(result.conversationHistory.length).toBeGreaterThan(0)
|
||||
// No summary pair in the output
|
||||
expect(result.conversationHistory[0]?.content).not.toContain('Previous conversation summary')
|
||||
})
|
||||
|
||||
it('trims tail when over token budget', async () => {
|
||||
const engine = new ContextEngine({
|
||||
config: {
|
||||
maxHistoryTokens: 200, // small budget
|
||||
tailMessageCount: 10,
|
||||
triggerTokens: 10, // force compression
|
||||
charsPerToken: 4,
|
||||
summarizationTimeoutMs: 30_000,
|
||||
},
|
||||
messageFetcher: mockFetcher,
|
||||
gatewayCaller: { summarize: mockSummarize },
|
||||
})
|
||||
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
// History should be trimmed to fit within 200 tokens
|
||||
// Use same estimation logic as compressor: CJK * 1.5 + other / charsPerToken
|
||||
const totalChars = result.conversationHistory.reduce((sum, m) => sum + m.content.length, 0)
|
||||
const cjk = (result.conversationHistory.map(m => m.content).join('').match(/[⺀-鿿가- -〿-]/g) || []).length
|
||||
const other = totalChars - cjk
|
||||
const estimatedTokens = Math.ceil(cjk * 1.5 + other / 4)
|
||||
expect(estimatedTokens).toBeLessThanOrEqual(200)
|
||||
})
|
||||
|
||||
it('maps agent messages to assistant role', async () => {
|
||||
const messages = [
|
||||
makeMessage({ senderId: 'user-1', senderName: 'Alice', content: 'Hello', timestamp: 1000 }),
|
||||
makeMessage({ senderId: 'agent-socket', senderName: 'Claude', content: 'Hi there', timestamp: 2000 }),
|
||||
]
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
// First message from user → 'user' role with name prefix
|
||||
expect(result.conversationHistory[0].role).toBe('user')
|
||||
expect(result.conversationHistory[0].content).toContain('[Alice]')
|
||||
|
||||
// Second message from agent → 'assistant' role with sender prefix for group-chat context.
|
||||
expect(result.conversationHistory[1].role).toBe('assistant')
|
||||
expect(result.conversationHistory[1].content).toBe('[Claude]: Hi there')
|
||||
})
|
||||
|
||||
it('maps other messages to user role with name prefix', async () => {
|
||||
const messages = [
|
||||
makeMessage({ senderId: 'user-2', senderName: 'Bob', content: 'Hey', timestamp: 1000 }),
|
||||
]
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
expect(result.conversationHistory[0].role).toBe('user')
|
||||
expect(result.conversationHistory[0].content).toBe('[Bob]: Hey')
|
||||
})
|
||||
|
||||
it('generates instructions with agent identity', async () => {
|
||||
const messages = makeMessages(1)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: 'Code helper', agentSocketId: 'agent-socket', roomName: 'dev',
|
||||
memberNames: ['Alice', 'Bob'],
|
||||
members: [
|
||||
{ userId: 'u1', name: 'Alice', description: 'dev' },
|
||||
{ userId: 'u2', name: 'Bob', description: 'designer' },
|
||||
],
|
||||
upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[0],
|
||||
})
|
||||
|
||||
expect(result.instructions).toContain('"Claude"')
|
||||
expect(result.instructions).toContain('Code helper')
|
||||
expect(result.instructions).toContain('dev')
|
||||
expect(result.instructions).toContain('Alice')
|
||||
})
|
||||
|
||||
it('invalidates room cache', async () => {
|
||||
// Create a snapshot via the fetcher mock
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: 'Test',
|
||||
lastMessageId: 'msg-10',
|
||||
lastMessageTimestamp: 1000,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
const messages = makeMessages(5)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
// Build context to create snapshot
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
// Invalidate
|
||||
engine.invalidateRoom('room-1')
|
||||
expect(mockFetcher.deleteContextSnapshot).toHaveBeenCalledWith('room-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const profileDirState = vi.hoisted(() => ({ value: '' }))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileDir: () => profileDirState.value,
|
||||
}))
|
||||
|
||||
function ensureSqliteAvailable() {
|
||||
const [major, minor] = process.versions.node.split('.').map(Number)
|
||||
if (major < 22 || (major === 22 && minor < 5)) {
|
||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createSchema(db: any) {
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT,
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT,
|
||||
reasoning_content TEXT
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function insertSession(db: any, session: Record<string, unknown>) {
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, source, user_id, model, model_config, system_prompt, parent_session_id,
|
||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
reasoning_tokens, billing_provider, billing_base_url, billing_mode,
|
||||
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
|
||||
pricing_version, title, api_call_count
|
||||
) VALUES (
|
||||
@id, @source, @user_id, @model, @model_config, @system_prompt, @parent_session_id,
|
||||
@started_at, @ended_at, @end_reason, @message_count, @tool_call_count,
|
||||
@input_tokens, @output_tokens, @cache_read_tokens, @cache_write_tokens,
|
||||
@reasoning_tokens, @billing_provider, @billing_base_url, @billing_mode,
|
||||
@estimated_cost_usd, @actual_cost_usd, @cost_status, @cost_source,
|
||||
@pricing_version, @title, @api_call_count
|
||||
)
|
||||
`).run({
|
||||
user_id: null,
|
||||
model_config: null,
|
||||
system_prompt: null,
|
||||
billing_base_url: null,
|
||||
billing_mode: null,
|
||||
cost_source: null,
|
||||
pricing_version: null,
|
||||
api_call_count: 0,
|
||||
...session,
|
||||
})
|
||||
}
|
||||
|
||||
function insertMessage(db: any, message: Record<string, unknown>) {
|
||||
db.prepare(`
|
||||
INSERT INTO messages (
|
||||
id, session_id, role, content, tool_call_id, tool_calls, tool_name,
|
||||
timestamp, token_count, finish_reason, reasoning, reasoning_details,
|
||||
codex_reasoning_items, reasoning_content
|
||||
) VALUES (
|
||||
@id, @session_id, @role, @content, @tool_call_id, @tool_calls, @tool_name,
|
||||
@timestamp, @token_count, @finish_reason, @reasoning, @reasoning_details,
|
||||
@codex_reasoning_items, @reasoning_content
|
||||
)
|
||||
`).run({
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
reasoning_details: null,
|
||||
codex_reasoning_items: null,
|
||||
reasoning_content: null,
|
||||
...message,
|
||||
})
|
||||
}
|
||||
|
||||
describe('conversation DB service', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-20T00:00:00Z'))
|
||||
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-conversations-db-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('aggregates a compression continuation without using full CLI export', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
|
||||
createSchema(db)
|
||||
|
||||
insertSession(db, {
|
||||
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',
|
||||
})
|
||||
insertSession(db, {
|
||||
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',
|
||||
})
|
||||
|
||||
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'Start here', timestamp: 101 })
|
||||
insertMessage(db, { id: 2, session_id: 'root', role: 'assistant', content: 'Assistant reply', timestamp: 102 })
|
||||
insertMessage(db, { id: 3, session_id: 'root-cont', role: 'user', content: 'Continue with more detail', timestamp: 110 })
|
||||
insertMessage(db, { id: 4, session_id: 'root-cont', role: 'assistant', content: 'Continued answer', timestamp: 111 })
|
||||
db.close()
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
|
||||
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(summaries[0]).toEqual(expect.objectContaining({
|
||||
id: 'root-cont',
|
||||
title: 'Continuation',
|
||||
started_at: 100,
|
||||
thread_session_count: 2,
|
||||
ended_at: 111,
|
||||
cost_status: 'mixed',
|
||||
actual_cost_usd: 0.30000000000000004,
|
||||
}))
|
||||
|
||||
const detailFromTip = await mod.getConversationDetailFromDb('root-cont', { humanOnly: true })
|
||||
expect(detailFromTip?.session_id).toBe('root-cont')
|
||||
expect(detailFromTip?.thread_session_count).toBe(2)
|
||||
expect(detailFromTip?.messages.map((message: any) => message.content)).toEqual([
|
||||
'Start here',
|
||||
'Assistant reply',
|
||||
'Continue with more detail',
|
||||
'Continued answer',
|
||||
])
|
||||
|
||||
const detailFromRoot = await mod.getConversationDetailFromDb('root', { humanOnly: true })
|
||||
expect(detailFromRoot?.messages.map((message: any) => message.content)).toEqual(
|
||||
detailFromTip?.messages.map((message: any) => message.content),
|
||||
)
|
||||
})
|
||||
|
||||
it('treats branched children as their own visible conversations', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
|
||||
createSchema(db)
|
||||
|
||||
insertSession(db, {
|
||||
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',
|
||||
})
|
||||
insertSession(db, {
|
||||
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',
|
||||
})
|
||||
|
||||
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'Root prompt', timestamp: 101 })
|
||||
insertMessage(db, { id: 2, session_id: 'branch-child', role: 'user', content: 'Branch prompt', timestamp: 202 })
|
||||
insertMessage(db, { id: 3, session_id: 'branch-child', role: 'assistant', content: 'Branch answer', timestamp: 203 })
|
||||
db.close()
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
|
||||
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
|
||||
expect(summaries.map((summary: any) => summary.id)).toEqual(['branch-child', 'root'])
|
||||
|
||||
const detail = await mod.getConversationDetailFromDb('branch-child', { humanOnly: true })
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
|
||||
})
|
||||
|
||||
it('keeps non-compression child sessions visible instead of hiding them under their parent', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
|
||||
createSchema(db)
|
||||
|
||||
insertSession(db, {
|
||||
id: 'parent',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Parent',
|
||||
started_at: 100,
|
||||
ended_at: 150,
|
||||
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',
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'review-child',
|
||||
parent_session_id: 'parent',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Independent review',
|
||||
started_at: 300,
|
||||
ended_at: 320,
|
||||
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',
|
||||
})
|
||||
|
||||
insertMessage(db, { id: 1, session_id: 'parent', role: 'user', content: 'Parent prompt', timestamp: 101 })
|
||||
insertMessage(db, { id: 2, session_id: 'review-child', role: 'user', content: 'Review prompt', timestamp: 301 })
|
||||
insertMessage(db, { id: 3, session_id: 'review-child', role: 'assistant', content: 'Review answer', timestamp: 302 })
|
||||
db.close()
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
|
||||
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
|
||||
expect(summaries.map((summary: any) => summary.id)).toEqual(['review-child', 'parent'])
|
||||
|
||||
const detail = await mod.getConversationDetailFromDb('review-child', { humanOnly: true })
|
||||
expect(detail?.thread_session_count).toBe(1)
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Review prompt', 'Review answer'])
|
||||
})
|
||||
|
||||
it('excludes synthetic-only roots from human-only summaries and details', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
|
||||
createSchema(db)
|
||||
|
||||
insertSession(db, {
|
||||
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',
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 1,
|
||||
session_id: 'synthetic-root',
|
||||
role: 'user',
|
||||
content: "You've reached the maximum number of tool-calling iterations allowed.",
|
||||
timestamp: 100,
|
||||
})
|
||||
db.close()
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
|
||||
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
|
||||
const detail = await mod.getConversationDetailFromDb('synthetic-root', { humanOnly: true })
|
||||
|
||||
expect(summaries).toEqual([])
|
||||
expect(detail).toBeNull()
|
||||
})
|
||||
|
||||
it('returns an empty detail payload for non-human-only sessions with no visible messages', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
|
||||
createSchema(db)
|
||||
|
||||
insertSession(db, {
|
||||
id: 'assistant-empty',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Empty detail',
|
||||
started_at: 200,
|
||||
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',
|
||||
})
|
||||
db.close()
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
|
||||
const detail = await mod.getConversationDetailFromDb('assistant-empty', { humanOnly: false })
|
||||
|
||||
expect(detail).toEqual({
|
||||
session_id: 'assistant-empty',
|
||||
messages: [],
|
||||
visible_count: 0,
|
||||
thread_session_count: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
return { ...actual, homedir: () => '/fake/home' }
|
||||
})
|
||||
|
||||
const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockUpdateConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockSaveEnvValue: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined),
|
||||
mockUpdateConfigYaml: vi.fn(),
|
||||
mockResolveWithSource: vi.fn(),
|
||||
mockInvalidate: vi.fn(),
|
||||
mockReadAppConfig: vi.fn(),
|
||||
mockWriteAppConfig: vi.fn().mockResolvedValue({ copilotEnabled: true }),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
saveEnvValue: mockSaveEnvValue,
|
||||
readConfigYaml: mockReadConfigYaml,
|
||||
writeConfigYaml: mockWriteConfigYaml,
|
||||
updateConfigYaml: mockUpdateConfigYaml,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||
resolveCopilotOAuthTokenWithSource: mockResolveWithSource,
|
||||
invalidateAllCaches: mockInvalidate,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveEnvPath: () => '/fake/home/.hermes/.env',
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/app-config', () => ({
|
||||
readAppConfig: mockReadAppConfig,
|
||||
writeAppConfig: mockWriteAppConfig,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as ctrl from '../../packages/server/src/controllers/hermes/copilot-auth'
|
||||
|
||||
function makeCtx(): any {
|
||||
return { params: {}, request: { body: {} }, body: undefined, status: 200 }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReadFile.mockResolvedValue('')
|
||||
mockReadConfigYaml.mockResolvedValue({})
|
||||
mockUpdateConfigYaml.mockImplementation(async (updater: any) => {
|
||||
const cfg = await mockReadConfigYaml()
|
||||
const updated = await updater(cfg)
|
||||
if (updated && typeof updated === 'object' && Object.hasOwn(updated, 'data')) {
|
||||
if (updated.write === false) return updated.result
|
||||
await mockWriteConfigYaml(updated.data)
|
||||
return updated.result
|
||||
}
|
||||
await mockWriteConfigYaml(updated)
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — checkToken', () => {
|
||||
it('reports has_token=false / source=null / enabled=false when nothing resolves', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
const ctx = makeCtx()
|
||||
await ctrl.checkToken(ctx)
|
||||
expect(ctx.body).toEqual({ has_token: false, source: null, enabled: false })
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports source and enabled flag', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.checkToken(ctx)
|
||||
expect(ctx.body).toEqual({ has_token: true, source: 'env', enabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — enable', () => {
|
||||
it('persists copilotEnabled=true and invalidates cache', async () => {
|
||||
const ctx = makeCtx()
|
||||
await ctrl.enable(ctx)
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: true })
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — disable', () => {
|
||||
it('clears ~/.hermes/.env when token source is env', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'gho_xxx'
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).toHaveBeenCalledWith('COPILOT_GITHUB_TOKEN', '')
|
||||
expect(process.env.COPILOT_GITHUB_TOKEN).toBeUndefined()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: true, cleared_default: false })
|
||||
})
|
||||
|
||||
it('does NOT touch .env when token source is gh-cli (preserves gh CLI session)', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'gh-cli' })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
|
||||
})
|
||||
|
||||
it('does NOT touch .env when token source is apps-json (preserves VS Code Copilot)', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'apps-json' })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
|
||||
})
|
||||
|
||||
it('still flips enabled=false even when no token is resolvable', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
})
|
||||
|
||||
it('clears default model when it belongs to copilot', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockWriteConfigYaml).toHaveBeenCalledWith(expect.objectContaining({ model: {} }))
|
||||
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: true }))
|
||||
})
|
||||
|
||||
it('does NOT touch default model when it belongs to a different provider', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'glm-4', provider: 'zhipu' } })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockWriteConfigYaml).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: false }))
|
||||
})
|
||||
|
||||
it('returns 500 and does NOT flip enabled flag when writeConfigYaml fails', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
|
||||
mockWriteConfigYaml.mockRejectedValueOnce(new Error('disk full'))
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(ctx.status).toBe(500)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not write process.env on persistToken / disable cleanup is defensive only', async () => {
|
||||
// disable 不依赖 process.env 被写入;只清理之前可能由外部 export 的覆盖。
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'leftover-from-shell'
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
// source=null → 不动 .env,也不清 process.env(因为不是 web-ui 自己的状态)
|
||||
expect(process.env.COPILOT_GITHUB_TOKEN).toBe('leftover-from-shell')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
startDeviceFlow,
|
||||
pollDeviceFlow,
|
||||
COPILOT_OAUTH_CLIENT_ID,
|
||||
COPILOT_OAUTH_SCOPE,
|
||||
} from '../../packages/server/src/services/hermes/copilot-device-flow'
|
||||
|
||||
function mockJsonResponse(data: any, ok = true, status = 200): any {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
describe('startDeviceFlow', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('POSTs client_id + scope and returns parsed device code', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
device_code: 'DC-1',
|
||||
user_code: 'USER-1234',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}))
|
||||
const data = await startDeviceFlow(fetchSpy as any)
|
||||
expect(data.device_code).toBe('DC-1')
|
||||
expect(data.user_code).toBe('USER-1234')
|
||||
expect(data.verification_uri).toBe('https://github.com/login/device')
|
||||
expect(data.expires_in).toBe(900)
|
||||
expect(data.interval).toBe(5)
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://github.com/login/device/code')
|
||||
expect(init.method).toBe('POST')
|
||||
const body = String(init.body)
|
||||
expect(body).toContain(`client_id=${encodeURIComponent(COPILOT_OAUTH_CLIENT_ID)}`)
|
||||
expect(body).toContain(`scope=${encodeURIComponent(COPILOT_OAUTH_SCOPE)}`)
|
||||
})
|
||||
|
||||
it('throws on non-2xx status', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: false, status: 503, text: async () => 'unavailable',
|
||||
})
|
||||
await expect(startDeviceFlow(fetchSpy as any)).rejects.toThrow(/503/)
|
||||
})
|
||||
|
||||
it('throws when required fields are missing', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ device_code: '' }))
|
||||
await expect(startDeviceFlow(fetchSpy as any)).rejects.toThrow(/missing required/)
|
||||
})
|
||||
|
||||
it('falls back to defaults when expires_in / interval are absent', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
device_code: 'DC-2',
|
||||
user_code: 'AAAA',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
}))
|
||||
const data = await startDeviceFlow(fetchSpy as any)
|
||||
expect(data.expires_in).toBe(900)
|
||||
expect(data.interval).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollDeviceFlow', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('returns success when access_token is present', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
access_token: 'gho_abc',
|
||||
token_type: 'bearer',
|
||||
scope: 'read:user',
|
||||
}))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('success')
|
||||
if (r.kind === 'success') {
|
||||
expect(r.access_token).toBe('gho_abc')
|
||||
expect(r.token_type).toBe('bearer')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps authorization_pending → pending', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'authorization_pending' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('pending')
|
||||
})
|
||||
|
||||
it('maps slow_down → slow_down', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'slow_down' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('slow_down')
|
||||
})
|
||||
|
||||
it('maps access_denied → denied', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'access_denied' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('denied')
|
||||
})
|
||||
|
||||
it('maps expired_token → expired', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'expired_token' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('expired')
|
||||
})
|
||||
|
||||
it('maps unknown server errors → error', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
error: 'unsupported_grant_type',
|
||||
error_description: 'bad grant',
|
||||
}))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('error')
|
||||
if (r.kind === 'error') {
|
||||
expect(r.error).toBe('unsupported_grant_type')
|
||||
expect(r.description).toBe('bad grant')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns error on network failure', async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('error')
|
||||
if (r.kind === 'error') expect(r.error).toBe('network')
|
||||
})
|
||||
|
||||
it('POSTs grant_type, client_id, device_code', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ access_token: 'gho_x' }))
|
||||
await pollDeviceFlow('DEVICE-CODE-XYZ', fetchSpy as any)
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://github.com/login/oauth/access_token')
|
||||
const body = String(init.body)
|
||||
expect(body).toContain(`client_id=${encodeURIComponent(COPILOT_OAUTH_CLIENT_ID)}`)
|
||||
expect(body).toContain('device_code=DEVICE-CODE-XYZ')
|
||||
expect(body).toContain('grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock os.homedir before imports so file path resolution is stable.
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
return { ...actual, homedir: () => '/fake/home' }
|
||||
})
|
||||
|
||||
const { mockReadFile, mockExecFile } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockExecFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({ readFile: mockReadFile }))
|
||||
vi.mock('child_process', () => ({ execFile: mockExecFile }))
|
||||
|
||||
import {
|
||||
resolveCopilotOAuthToken,
|
||||
getCopilotModels,
|
||||
getCopilotModelsDetailed,
|
||||
COPILOT_FALLBACK_MODELS,
|
||||
__resetCopilotModelsCacheForTest,
|
||||
} from '../../packages/server/src/services/hermes/copilot-models'
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env }
|
||||
const ORIGINAL_FETCH = global.fetch
|
||||
|
||||
function clearTokenEnv() {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
delete process.env.GITHUB_TOKEN
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetCopilotModelsCacheForTest()
|
||||
vi.clearAllMocks()
|
||||
clearTokenEnv()
|
||||
// Default: apps.json read fails (ENOENT)
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'))
|
||||
// Default: gh CLI fails
|
||||
mockExecFile.mockImplementation((_cmd: any, _args: any, _opts: any, cb: any) => {
|
||||
cb(new Error('gh not installed'), { stdout: '', stderr: '' })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV }
|
||||
global.fetch = ORIGINAL_FETCH
|
||||
})
|
||||
|
||||
describe('resolveCopilotOAuthToken', () => {
|
||||
it('优先级:COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN', async () => {
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'gho_copilot'
|
||||
process.env.GH_TOKEN = 'gho_gh'
|
||||
process.env.GITHUB_TOKEN = 'gho_github'
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_copilot')
|
||||
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_gh')
|
||||
|
||||
delete process.env.GH_TOKEN
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_github')
|
||||
})
|
||||
|
||||
it('跳过 classic PAT (ghp_),回退到下一来源', async () => {
|
||||
process.env.GH_TOKEN = 'ghp_classic_pat'
|
||||
process.env.GITHUB_TOKEN = 'gho_oauth_token'
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_oauth_token')
|
||||
})
|
||||
|
||||
it('从 .env 读取并去掉两端引号', async () => {
|
||||
expect(await resolveCopilotOAuthToken('GH_TOKEN="gho_quoted"\n')).toBe('gho_quoted')
|
||||
expect(await resolveCopilotOAuthToken("GH_TOKEN='gho_single'\n")).toBe('gho_single')
|
||||
expect(await resolveCopilotOAuthToken('GH_TOKEN=gho_plain\n')).toBe('gho_plain')
|
||||
})
|
||||
|
||||
it('忽略 .env 中以 # 开头的注释行', async () => {
|
||||
expect(await resolveCopilotOAuthToken('GH_TOKEN=# comment\n')).toBe('')
|
||||
})
|
||||
|
||||
it('回退到 ~/.config/github-copilot/apps.json 的 oauth_token', async () => {
|
||||
mockReadFile.mockImplementation(async (p: string) => {
|
||||
if (p.includes('apps.json')) {
|
||||
return JSON.stringify({
|
||||
'github.com:abc': { oauth_token: 'gho_from_apps_json', user: 'me' },
|
||||
})
|
||||
}
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_from_apps_json')
|
||||
})
|
||||
|
||||
it('apps.json 中的 ghp_ token 也应跳过', async () => {
|
||||
mockReadFile.mockImplementation(async (p: string) => {
|
||||
if (p.includes('apps.json')) {
|
||||
return JSON.stringify({ 'github.com:a': { oauth_token: 'ghp_pat_in_apps' } })
|
||||
}
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('')
|
||||
})
|
||||
|
||||
it('最后回退到 `gh auth token`', async () => {
|
||||
mockExecFile.mockImplementation((_cmd: any, _args: any, _opts: any, cb: any) => {
|
||||
cb(null, { stdout: 'gho_from_gh_cli\n', stderr: '' })
|
||||
})
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_from_gh_cli')
|
||||
})
|
||||
|
||||
it('所有来源都失败时返回空字符串', async () => {
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCopilotModels', () => {
|
||||
function mockFetchSequence(responses: Array<Partial<Response> | Error>) {
|
||||
let i = 0
|
||||
global.fetch = vi.fn(async () => {
|
||||
const r = responses[i++]
|
||||
if (r instanceof Error) throw r
|
||||
return r as Response
|
||||
}) as any
|
||||
}
|
||||
|
||||
it('fallback 列表包含当前 Copilot 官方模型', () => {
|
||||
const ids = COPILOT_FALLBACK_MODELS.map(m => m.id)
|
||||
expect(ids).toEqual(expect.arrayContaining([
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-nano',
|
||||
'claude-opus-4.8',
|
||||
'gemini-3.5-flash',
|
||||
'raptor-mini',
|
||||
]))
|
||||
expect(ids).not.toContain('grok-code-fast-1')
|
||||
})
|
||||
|
||||
it('成功路径:返回 chat type 且 supports /chat/completions 的模型 id', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok_copilot' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gpt-5.4', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'claude-opus-4.7', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions', '/v1/messages'] },
|
||||
{ id: 'embedding-1', capabilities: { type: 'embeddings' }, supported_endpoints: ['/embeddings'] },
|
||||
{ id: 'completion-only', capabilities: { type: 'chat' }, supported_endpoints: ['/completions'] },
|
||||
{ id: 'no-endpoints', capabilities: { type: 'chat' } },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toContain('gpt-5.4')
|
||||
expect(ids).toContain('claude-opus-4.7')
|
||||
expect(ids).toContain('no-endpoints') // endpoints 缺省时允许
|
||||
expect(ids).not.toContain('embedding-1')
|
||||
expect(ids).not.toContain('completion-only')
|
||||
})
|
||||
|
||||
it('不再强制 model_picker_enabled —— picker_enabled=false 的模型也返回', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'a', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'], model_picker_enabled: false },
|
||||
{ id: 'b', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'], model_picker_enabled: true },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(expect.arrayContaining(['a', 'b']))
|
||||
})
|
||||
|
||||
it('无 token 时返回 fallback 列表', async () => {
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('token exchange 失败返回 fallback', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([{ ok: false, status: 401 } as any])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('models endpoint 失败返回 fallback', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{ ok: false, status: 503 } as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('网络错误(如超时)返回 fallback', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([new Error('AbortError: timeout')])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('正缓存命中:第二次调用不再发请求', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tok' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'm1', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
const a = await getCopilotModels('')
|
||||
const b = await getCopilotModels('')
|
||||
expect(a).toEqual(['m1'])
|
||||
expect(b).toEqual(['m1'])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('负缓存:失败后短期内不再重试', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
global.fetch = fetchMock as any
|
||||
const a = await getCopilotModels('')
|
||||
const b = await getCopilotModels('')
|
||||
expect(a).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
expect(b).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
// 无 token 时根本不会调 fetch
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('并发请求合并:同时调用 N 次只发一组请求', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tok' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'x', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
const [a, b, c] = await Promise.all([
|
||||
getCopilotModels(''),
|
||||
getCopilotModels(''),
|
||||
getCopilotModels(''),
|
||||
])
|
||||
expect(a).toEqual(['x'])
|
||||
expect(b).toEqual(['x'])
|
||||
expect(c).toEqual(['x'])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCopilotModels noise filter & detailed meta', () => {
|
||||
function mockFetchSequence(responses: Array<Partial<Response> | Error>) {
|
||||
let i = 0
|
||||
global.fetch = vi.fn(async () => {
|
||||
const r = responses[i++]
|
||||
if (r instanceof Error) throw r
|
||||
return r as Response
|
||||
}) as any
|
||||
}
|
||||
|
||||
it('过滤掉噪音 ID(accounts/、text-embedding、rerank 前缀)', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gpt-5.4', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'accounts/msft/routers/abc', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'text-embedding-3-small', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'rerank-v1', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(['gpt-5.4'])
|
||||
})
|
||||
|
||||
it('detailed 返回 preview 字段', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gemini-3-pro-preview', preview: true, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'gpt-4o', preview: false, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const detailed = await getCopilotModelsDetailed('')
|
||||
expect(detailed).toEqual([
|
||||
{ id: 'gemini-3-pro-preview', preview: true, disabled: false },
|
||||
{ id: 'gpt-4o', preview: false, disabled: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('detailed 返回 disabled 字段(policy.state === "disabled")', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gpt-3.5-turbo', policy: { state: 'disabled' }, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'gpt-4o', policy: { state: 'enabled' }, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'claude-sonnet-4', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const detailed = await getCopilotModelsDetailed('')
|
||||
const map = new Map(detailed.map((m) => [m.id, m]))
|
||||
expect(map.get('gpt-3.5-turbo')?.disabled).toBe(true)
|
||||
expect(map.get('gpt-4o')?.disabled).toBe(false)
|
||||
expect(map.get('claude-sonnet-4')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('缓存按 oauth token 隔离:切换账号会重新拉取', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
// 账号 A:token exchange + models
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tokA' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'model-a', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
// 账号 B:另一组 token exchange + models
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tokB' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'model-b', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
|
||||
process.env.GH_TOKEN = 'gho_account_A'
|
||||
const a = await getCopilotModels('')
|
||||
expect(a).toEqual(['model-a'])
|
||||
|
||||
// 切换到账号 B,不 reset cache
|
||||
process.env.GH_TOKEN = 'gho_account_B'
|
||||
const b = await getCopilotModels('')
|
||||
expect(b).toEqual(['model-b'])
|
||||
|
||||
// 再切回 A:应该命中 A 的缓存(不再发请求)
|
||||
process.env.GH_TOKEN = 'gho_account_A'
|
||||
const a2 = await getCopilotModels('')
|
||||
expect(a2).toEqual(['model-a'])
|
||||
|
||||
// 总共 4 次请求(A.exchange、A.models、B.exchange、B.models),切回 A 时命中缓存
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const profileDirState = vi.hoisted(() => ({
|
||||
value: '',
|
||||
dirs: {} as Record<string, string>,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileName: () => 'default',
|
||||
getProfileDir: (profile: string) => profileDirState.dirs[profile] || profileDirState.value,
|
||||
}))
|
||||
|
||||
function createCtx(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
query: {},
|
||||
params: {},
|
||||
status: 200,
|
||||
body: null,
|
||||
...overrides,
|
||||
} as any
|
||||
}
|
||||
|
||||
function writeJobs(jobs: unknown[], profileDir = profileDirState.value) {
|
||||
const cronDir = join(profileDir, 'cron')
|
||||
mkdirSync(cronDir, { recursive: true })
|
||||
writeFileSync(join(cronDir, 'jobs.json'), JSON.stringify({ jobs }))
|
||||
}
|
||||
|
||||
describe('Hermes cron history controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-cron-history-'))
|
||||
profileDirState.dirs = { default: profileDirState.value }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
|
||||
for (const dir of Object.values(profileDirState.dirs)) {
|
||||
if (dir !== profileDirState.value) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads run history from the request profile directory', async () => {
|
||||
const researchDir = mkdtempSync(join(tmpdir(), 'hwui-cron-history-research-'))
|
||||
profileDirState.dirs.research = researchDir
|
||||
writeJobs([
|
||||
{
|
||||
id: 'default-job',
|
||||
name: 'Default job',
|
||||
last_run_at: '2026-05-05T01:00:00+00:00',
|
||||
},
|
||||
])
|
||||
writeJobs([
|
||||
{
|
||||
id: 'research-job',
|
||||
name: 'Research job',
|
||||
last_run_at: '2026-05-05T02:00:00+00:00',
|
||||
},
|
||||
], researchDir)
|
||||
|
||||
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ state: { profile: { name: 'research' } } })
|
||||
await listRuns(ctx)
|
||||
|
||||
expect(ctx.body.runs).toEqual([
|
||||
expect.objectContaining({
|
||||
jobId: 'research-job',
|
||||
runTime: '2026-05-05 02:00:00',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces scheduler metadata when a job ran without an output artifact', async () => {
|
||||
writeJobs([
|
||||
{
|
||||
id: 'silent-job',
|
||||
name: 'Silent watchdog',
|
||||
last_run_at: '2026-05-05T13:01:32.580693+00:00',
|
||||
last_status: 'ok',
|
||||
run_count: 47,
|
||||
script: 'monitor_github_issues.py',
|
||||
no_agent: true,
|
||||
},
|
||||
])
|
||||
|
||||
const { listRuns, readRun } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const listCtx = createCtx({ query: { jobId: 'silent-job' } })
|
||||
await listRuns(listCtx)
|
||||
|
||||
expect(listCtx.body).toEqual({
|
||||
runs: [
|
||||
expect.objectContaining({
|
||||
jobId: 'silent-job',
|
||||
fileName: '__scheduler_metadata__.md',
|
||||
runTime: '2026-05-05 13:01:32',
|
||||
size: 0,
|
||||
hasOutput: false,
|
||||
synthetic: true,
|
||||
runCount: 47,
|
||||
status: 'ok',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const readCtx = createCtx({ params: { jobId: 'silent-job', fileName: '__scheduler_metadata__.md' } })
|
||||
await readRun(readCtx)
|
||||
|
||||
expect(readCtx.body).toMatchObject({
|
||||
jobId: 'silent-job',
|
||||
fileName: '__scheduler_metadata__.md',
|
||||
runTime: '2026-05-05 13:01:32',
|
||||
})
|
||||
expect(readCtx.body.content).toContain('Hermes recorded this cron job as having run')
|
||||
expect(readCtx.body.content).toContain('Recorded runs:')
|
||||
expect(readCtx.body.content).toContain('47')
|
||||
expect(readCtx.body.content).toContain('script-only/no-agent')
|
||||
})
|
||||
|
||||
it('keeps real output files as history entries and parses ISO-style Hermes filenames', async () => {
|
||||
writeJobs([
|
||||
{
|
||||
id: 'output-job',
|
||||
name: 'Output job',
|
||||
last_run_at: '2026-05-05T05:00:00.429347+00:00',
|
||||
run_count: 1,
|
||||
},
|
||||
])
|
||||
const outputDir = join(profileDirState.value, 'cron', 'output', 'output-job')
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
writeFileSync(join(outputDir, '2026-05-05T05-00-00.429347+00-00.md'), '# ok\n')
|
||||
|
||||
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ query: { jobId: 'output-job' } })
|
||||
await listRuns(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({
|
||||
runs: [
|
||||
expect.objectContaining({
|
||||
jobId: 'output-job',
|
||||
fileName: '2026-05-05T05-00-00.429347+00-00.md',
|
||||
runTime: '2026-05-05 05:00:00',
|
||||
hasOutput: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('adds scheduler metadata when the latest recorded run is newer than the newest output file', async () => {
|
||||
writeJobs([
|
||||
{
|
||||
id: 'mixed-job',
|
||||
name: 'Mixed job',
|
||||
last_run_at: '2026-05-05T06:00:00+00:00',
|
||||
run_count: 2,
|
||||
},
|
||||
])
|
||||
const outputDir = join(profileDirState.value, 'cron', 'output', 'mixed-job')
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
writeFileSync(join(outputDir, '2026-05-05T05-00-00.000000+00-00.md'), '# older output\n')
|
||||
|
||||
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ query: { jobId: 'mixed-job' } })
|
||||
await listRuns(ctx)
|
||||
|
||||
expect(ctx.body.runs).toHaveLength(2)
|
||||
expect(ctx.body.runs[0]).toMatchObject({
|
||||
jobId: 'mixed-job',
|
||||
fileName: '__scheduler_metadata__.md',
|
||||
runTime: '2026-05-05 06:00:00',
|
||||
hasOutput: false,
|
||||
})
|
||||
expect(ctx.body.runs[1]).toMatchObject({
|
||||
fileName: '2026-05-05T05-00-00.000000+00-00.md',
|
||||
hasOutput: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips malformed scheduler metadata instead of failing the request', async () => {
|
||||
writeJobs([
|
||||
null,
|
||||
{
|
||||
id: 'bad-job',
|
||||
name: 'Bad job',
|
||||
last_run_at: 123,
|
||||
last_status: { nested: true },
|
||||
},
|
||||
])
|
||||
|
||||
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ query: { jobId: 'bad-job' } })
|
||||
await listRuns(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ runs: [] })
|
||||
})
|
||||
|
||||
it('renders metadata with many backticks without throwing', async () => {
|
||||
const name = Array.from({ length: 2000 }, () => '`x').join('')
|
||||
writeJobs([
|
||||
{
|
||||
id: 'ticks-job',
|
||||
name,
|
||||
last_run_at: '2026-05-05T07:00:00+00:00',
|
||||
},
|
||||
])
|
||||
|
||||
const { readRun } = await import('../../packages/server/src/controllers/hermes/cron-history')
|
||||
|
||||
const ctx = createCtx({ params: { jobId: 'ticks-job', fileName: '__scheduler_metadata__.md' } })
|
||||
await readRun(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.content).toContain('Scheduler run recorded')
|
||||
expect(ctx.body.content).toContain('`x')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Force JSON fallback by mocking isSqliteAvailable
|
||||
vi.mock('../../packages/server/src/db/index', async (importOriginal) => {
|
||||
const actual = await importOriginal() as any
|
||||
return {
|
||||
...actual,
|
||||
isSqliteAvailable: () => false,
|
||||
getDb: () => null,
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
jsonGet,
|
||||
jsonSet,
|
||||
jsonGetAll,
|
||||
jsonDelete,
|
||||
} from '../../packages/server/src/db/index'
|
||||
|
||||
describe('JSON fallback store', () => {
|
||||
it('jsonSet and jsonGet round-trip', () => {
|
||||
expect(typeof jsonSet).toBe('function')
|
||||
expect(typeof jsonGet).toBe('function')
|
||||
expect(typeof jsonGetAll).toBe('function')
|
||||
expect(typeof jsonDelete).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
// Test ensureTable with a real in-memory SQLite (Node 22+)
|
||||
describe('SQLite ensureTable', () => {
|
||||
it('creates table with correct columns and handles migration', () => {
|
||||
// This test requires Node 22.5+ for node:sqlite
|
||||
const nodeVersion = process.versions.node.split('.').map(Number)
|
||||
const isAvailable = nodeVersion[0] > 22 || (nodeVersion[0] === 22 && nodeVersion[1] >= 5)
|
||||
|
||||
if (!isAvailable) {
|
||||
console.log('Skipping SQLite test — Node < 22.5')
|
||||
return
|
||||
}
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const db = new DatabaseSync(':memory:')
|
||||
|
||||
// Simulate ensureTable logic
|
||||
function ensureTable(tableName: string, schema: Record<string, string>): void {
|
||||
const colDefs = Object.entries(schema)
|
||||
.map(([col, def]) => `"${col}" ${def}`)
|
||||
.join(', ')
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
|
||||
|
||||
const rows = db.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{ name: string }>
|
||||
const existingCols = new Set(rows.map(r => r.name))
|
||||
const expectedCols = new Set(Object.keys(schema))
|
||||
|
||||
for (const col of expectedCols) {
|
||||
if (!existingCols.has(col)) {
|
||||
db.exec(`ALTER TABLE "${tableName}" ADD COLUMN "${col}" ${schema[col]}`)
|
||||
}
|
||||
}
|
||||
for (const col of existingCols) {
|
||||
if (!expectedCols.has(col)) {
|
||||
db.exec(`ALTER TABLE "${tableName}" DROP COLUMN "${col}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial schema
|
||||
const schema: Record<string, string> = {
|
||||
session_id: 'TEXT PRIMARY KEY',
|
||||
input_tokens: 'INTEGER NOT NULL DEFAULT 0',
|
||||
output_tokens: 'INTEGER NOT NULL DEFAULT 0',
|
||||
updated_at: 'INTEGER NOT NULL',
|
||||
}
|
||||
ensureTable('session_usage', schema)
|
||||
|
||||
// Verify columns
|
||||
const cols = db.prepare(`PRAGMA table_info("session_usage")`).all() as Array<{ name: string }>
|
||||
const colNames = cols.map(c => c.name)
|
||||
expect(colNames).toContain('session_id')
|
||||
expect(colNames).toContain('input_tokens')
|
||||
expect(colNames).toContain('output_tokens')
|
||||
expect(colNames).toContain('updated_at')
|
||||
|
||||
// Add a column
|
||||
schema['cost_usd'] = 'REAL DEFAULT 0'
|
||||
ensureTable('session_usage', schema)
|
||||
const cols2 = db.prepare(`PRAGMA table_info("session_usage")`).all() as Array<{ name: string }>
|
||||
const colNames2 = cols2.map(c => c.name)
|
||||
expect(colNames2).toContain('cost_usd')
|
||||
|
||||
// Remove a column
|
||||
delete schema['cost_usd']
|
||||
ensureTable('session_usage', schema)
|
||||
const cols3 = db.prepare(`PRAGMA table_info("session_usage")`).all() as Array<{ name: string }>
|
||||
const colNames3 = cols3.map(c => c.name)
|
||||
expect(colNames3).not.toContain('cost_usd')
|
||||
|
||||
// Verify INSERT works
|
||||
db.prepare(
|
||||
`INSERT INTO session_usage (session_id, input_tokens, output_tokens, updated_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run('test-session', 100, 50, Date.now())
|
||||
|
||||
const row = db.prepare('SELECT * FROM session_usage WHERE session_id = ?').get('test-session') as any
|
||||
expect(row.session_id).toBe('test-session')
|
||||
expect(row.input_tokens).toBe(100)
|
||||
expect(row.output_tokens).toBe(50)
|
||||
|
||||
// Verify DELETE works
|
||||
db.prepare('DELETE FROM session_usage WHERE session_id = ?').run('test-session')
|
||||
const deleted = db.prepare('SELECT * FROM session_usage WHERE session_id = ?').get('test-session')
|
||||
expect(deleted).toBeUndefined()
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizePlatformPath } from '../../packages/server/src/services/hermes/file-provider'
|
||||
import { isPathWithin, relativePathFromBase } from '../../packages/server/src/services/hermes/hermes-path'
|
||||
|
||||
describe('file provider platform path normalization', () => {
|
||||
it('converts MSYS drive paths to Windows absolute paths on Windows', () => {
|
||||
expect(normalizePlatformPath('/c/Users/Administrator/Desktop/screenshot.png', 'win32'))
|
||||
.toBe('C:\\Users\\Administrator\\Desktop\\screenshot.png')
|
||||
expect(normalizePlatformPath('/d/tmp/report.txt', 'win32'))
|
||||
.toBe('D:\\tmp\\report.txt')
|
||||
})
|
||||
|
||||
it('leaves MSYS-style paths unchanged on non-Windows platforms', () => {
|
||||
expect(normalizePlatformPath('/c/Users/Administrator/Desktop/screenshot.png', 'darwin'))
|
||||
.toBe('/c/Users/Administrator/Desktop/screenshot.png')
|
||||
expect(normalizePlatformPath('/c/Users/Administrator/Desktop/screenshot.png', 'linux'))
|
||||
.toBe('/c/Users/Administrator/Desktop/screenshot.png')
|
||||
})
|
||||
|
||||
it('leaves normal Windows paths unchanged', () => {
|
||||
expect(normalizePlatformPath('C:\\Users\\Administrator\\Desktop\\screenshot.png', 'win32'))
|
||||
.toBe('C:\\Users\\Administrator\\Desktop\\screenshot.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hermes path containment helpers', () => {
|
||||
it('does not treat sibling paths with the same prefix as inside the base', () => {
|
||||
expect(isPathWithin('/tmp/hermes-profile2/state.db', '/tmp/hermes-profile')).toBe(false)
|
||||
expect(isPathWithin('/tmp/hermes-profile/state.db', '/tmp/hermes-profile')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns normalized relative paths only for children', () => {
|
||||
expect(relativePathFromBase('/tmp/hermes-profile/logs/run.txt', '/tmp/hermes-profile'))
|
||||
.toBe('logs/run.txt')
|
||||
expect(relativePathFromBase('/tmp/hermes-profile2/logs/run.txt', '/tmp/hermes-profile'))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const provider = {
|
||||
listDir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}
|
||||
const createFileProviderMock = vi.fn(async () => provider)
|
||||
const resolveHermesPathMock = vi.fn((relativePath: string) => {
|
||||
const normalized = relativePath.replace(/^\/+/, '')
|
||||
return normalized ? `/home/agent/.hermes/${normalized}` : '/home/agent/.hermes'
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/file-provider', () => ({
|
||||
createFileProvider: createFileProviderMock,
|
||||
resolveHermesPath: resolveHermesPathMock,
|
||||
isSensitivePath: vi.fn(() => false),
|
||||
MAX_EDIT_SIZE: 10 * 1024 * 1024,
|
||||
}))
|
||||
|
||||
describe('file routes path metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
createFileProviderMock.mockClear()
|
||||
resolveHermesPathMock.mockClear()
|
||||
provider.listDir.mockReset()
|
||||
provider.stat.mockReset()
|
||||
})
|
||||
|
||||
it('returns absolute paths for listed entries while preserving relative operation paths', async () => {
|
||||
provider.listDir.mockResolvedValue([
|
||||
{ name: 'app.log', path: 'logs/app.log', isDir: false, size: 12, modTime: '2026-05-20T00:00:00.000Z' },
|
||||
])
|
||||
|
||||
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
||||
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/list')
|
||||
const ctx: any = { query: { path: 'logs' }, state: { profile: { name: 'research' } }, body: null }
|
||||
|
||||
await layer.stack[0](ctx)
|
||||
|
||||
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
||||
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs', 'research')
|
||||
expect(provider.listDir).toHaveBeenCalledWith('/home/agent/.hermes/logs')
|
||||
expect(ctx.body).toEqual({
|
||||
path: 'logs',
|
||||
absolutePath: '/home/agent/.hermes/logs',
|
||||
entries: [
|
||||
{
|
||||
name: 'app.log',
|
||||
path: 'logs/app.log',
|
||||
absolutePath: '/home/agent/.hermes/logs/app.log',
|
||||
isDir: false,
|
||||
size: 12,
|
||||
modTime: '2026-05-20T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an absolute path in stat responses', async () => {
|
||||
provider.stat.mockResolvedValue({
|
||||
name: 'app.log',
|
||||
path: 'logs/app.log',
|
||||
isDir: false,
|
||||
size: 12,
|
||||
modTime: '2026-05-20T00:00:00.000Z',
|
||||
})
|
||||
|
||||
const { fileRoutes } = await import('../../packages/server/src/routes/hermes/files')
|
||||
const layer = fileRoutes.stack.find((entry: any) => entry.path === '/api/hermes/files/stat')
|
||||
const ctx: any = { query: { path: 'logs/app.log' }, state: { profile: { name: 'research' } }, body: null }
|
||||
|
||||
await layer.stack[0](ctx)
|
||||
|
||||
expect(createFileProviderMock).toHaveBeenCalledWith('research')
|
||||
expect(resolveHermesPathMock).toHaveBeenCalledWith('logs/app.log', 'research')
|
||||
expect(ctx.body).toEqual({
|
||||
name: 'app.log',
|
||||
path: 'logs/app.log',
|
||||
absolutePath: '/home/agent/.hermes/logs/app.log',
|
||||
isDir: false,
|
||||
size: 12,
|
||||
modTime: '2026-05-20T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import {
|
||||
gatewayStatusLooksRuntimeLocked,
|
||||
gatewayStatusLooksRunning,
|
||||
gatewayStateLooksRunningForProfile,
|
||||
parseGatewayStatusesFromProfileListOutput,
|
||||
shouldUseManagedGatewayRun,
|
||||
shouldUseManagedGatewayRunForAutostart,
|
||||
} from '../../packages/server/src/services/hermes/gateway-autostart'
|
||||
|
||||
describe('gateway autostart status parsing', () => {
|
||||
it('treats runtime lock conflicts as an already-running gateway', () => {
|
||||
expect(gatewayStatusLooksRuntimeLocked(
|
||||
'Gateway runtime lock is already held by another instance. Exiting.',
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat not-running status as running', () => {
|
||||
expect(gatewayStatusLooksRunning('Gateway is not running')).toBe(false)
|
||||
})
|
||||
|
||||
it('parses gateway status from hermes profile list output', () => {
|
||||
const output = `
|
||||
Profile Model Gateway Alias Distribution
|
||||
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
|
||||
◆default glm-5-turbo running — —
|
||||
akri glm-5-turbo running akri —
|
||||
tester gpt-5.5 stopped tester —
|
||||
`
|
||||
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['default', 'akri', 'tester'])
|
||||
expect(statuses.get('default')).toBe('running')
|
||||
expect(statuses.get('akri')).toBe('running')
|
||||
expect(statuses.get('tester')).toBe('stopped')
|
||||
})
|
||||
|
||||
it('parses gateway status when profile or model fills the table column', () => {
|
||||
const output = `
|
||||
Profile Model Gateway Alias Distribution
|
||||
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
|
||||
daily_assistant deepseek-v4-flash running — —
|
||||
long_model provider/model-name-that-fills-column stopped — —
|
||||
`
|
||||
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['daily_assistant', 'long_model'])
|
||||
expect(statuses.get('daily_assistant')).toBe('running')
|
||||
expect(statuses.get('long_model')).toBe('stopped')
|
||||
})
|
||||
|
||||
it('uses profile-list gateway status text for running checks', () => {
|
||||
expect(gatewayStatusLooksRunning('running')).toBe(true)
|
||||
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
|
||||
expect(gatewayStatusLooksRunning('not running')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows managed gateway mode to be forced by environment', () => {
|
||||
const previous = process.env.HERMES_WEB_UI_MANAGED_GATEWAY
|
||||
process.env.HERMES_WEB_UI_MANAGED_GATEWAY = '1'
|
||||
try {
|
||||
expect(shouldUseManagedGatewayRun()).toBe(true)
|
||||
expect(shouldUseManagedGatewayRunForAutostart()).toBe(true)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.HERMES_WEB_UI_MANAGED_GATEWAY
|
||||
else process.env.HERMES_WEB_UI_MANAGED_GATEWAY = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('uses managed gateway autostart on Windows', () => {
|
||||
expect(shouldUseManagedGatewayRunForAutostart('win32')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects managed gateway state files with a live pid', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-gateway-state-'))
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, 'gateway_state.json'),
|
||||
JSON.stringify({ pid: process.pid, gateway_state: 'running' }),
|
||||
'utf-8',
|
||||
)
|
||||
expect(gatewayStateLooksRunningForProfile(dir)).toBe(true)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const readFileSyncMock = vi.fn()
|
||||
const existsSyncMock = vi.fn()
|
||||
|
||||
vi.mock('fs', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('fs')>()
|
||||
return {
|
||||
...actual,
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-path', () => ({
|
||||
detectHermesHome: () => 'C:/Users/test/.hermes',
|
||||
getHermesBin: () => 'hermes'
|
||||
}))
|
||||
|
||||
describe('GatewayManager diagnostics', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('includes read-only diagnostics when a profile is stopped', async () => {
|
||||
const yamlText = [
|
||||
'platforms:',
|
||||
' api_server:',
|
||||
' extra:',
|
||||
' host: 127.0.0.1',
|
||||
' port: 8643',
|
||||
].join('\n')
|
||||
|
||||
existsSyncMock.mockImplementation((input: unknown) => {
|
||||
const text = String(input)
|
||||
return text.endsWith('config.yaml')
|
||||
})
|
||||
readFileSyncMock.mockImplementation((input: unknown) => {
|
||||
const text = String(input)
|
||||
if (text.endsWith('config.yaml')) {
|
||||
return yamlText
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const { GatewayManager } = await import('../../packages/server/src/services/hermes/gateway-manager')
|
||||
const manager = new GatewayManager('default')
|
||||
const status = await manager.detectStatus('default')
|
||||
|
||||
expect(status.running).toBe(false)
|
||||
expect(status.diagnostics?.config_exists).toBe(true)
|
||||
expect(status.diagnostics?.pid_file_exists).toBe(false)
|
||||
expect(status.diagnostics?.reason).toBe('missing pid file')
|
||||
expect(status.diagnostics?.health_url).toContain('/health')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const originalEnv = { ...process.env }
|
||||
const tempHomes: string[] = []
|
||||
|
||||
function createHermesHome(): string {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-gateway-'))
|
||||
tempHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
async function createManager(home: string): Promise<any> {
|
||||
process.env.HERMES_HOME = home
|
||||
vi.resetModules()
|
||||
const { GatewayManager } = await import('../../packages/server/src/services/hermes/gateway-manager')
|
||||
return new GatewayManager('default') as any
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
process.env = { ...originalEnv }
|
||||
if (originalHermesHome === undefined) {
|
||||
delete process.env.HERMES_HOME
|
||||
} else {
|
||||
process.env.HERMES_HOME = originalHermesHome
|
||||
}
|
||||
|
||||
for (const home of tempHomes.splice(0)) {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('GatewayManager Windows process recovery', () => {
|
||||
it('treats EPERM from process.kill(pid, 0) as an alive process', async () => {
|
||||
const manager = await createManager(createHermesHome())
|
||||
;(vi.spyOn(process, 'kill') as any).mockImplementation(() => {
|
||||
const error = new Error('permission denied') as NodeJS.ErrnoException
|
||||
error.code = 'EPERM'
|
||||
throw error
|
||||
})
|
||||
|
||||
expect(manager.isProcessAlive(12345)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for missing processes', async () => {
|
||||
const manager = await createManager(createHermesHome())
|
||||
;(vi.spyOn(process, 'kill') as any).mockImplementation(() => {
|
||||
const error = new Error('missing process') as NodeJS.ErrnoException
|
||||
error.code = 'ESRCH'
|
||||
throw error
|
||||
})
|
||||
|
||||
expect(manager.isProcessAlive(12345)).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers gateway.pid when PID metadata exists', async () => {
|
||||
const home = createHermesHome()
|
||||
writeFileSync(join(home, 'gateway.pid'), JSON.stringify({ pid: 11111 }))
|
||||
writeFileSync(join(home, 'gateway_state.json'), JSON.stringify({ pid: 22222, gateway_state: 'running' }))
|
||||
|
||||
const manager = await createManager(home)
|
||||
|
||||
expect(manager.readPidFile('default')).toBe(11111)
|
||||
})
|
||||
|
||||
it('falls back to gateway_state.json when gateway.pid is missing', async () => {
|
||||
const home = createHermesHome()
|
||||
writeFileSync(join(home, 'gateway_state.json'), JSON.stringify({ pid: '22222', gateway_state: 'running' }))
|
||||
|
||||
const manager = await createManager(home)
|
||||
|
||||
expect(manager.readPidFile('default')).toBe(22222)
|
||||
})
|
||||
|
||||
it('does not use gateway_state.json for stopped gateways', async () => {
|
||||
const home = createHermesHome()
|
||||
writeFileSync(join(home, 'gateway_state.json'), JSON.stringify({ pid: 22222, gateway_state: 'stopped' }))
|
||||
|
||||
const manager = await createManager(home)
|
||||
|
||||
expect(manager.readPidFile('default')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses profile-scoped gateway_state.json fallback', async () => {
|
||||
const home = createHermesHome()
|
||||
const profileHome = join(home, 'profiles', 'work')
|
||||
mkdirSync(profileHome, { recursive: true })
|
||||
writeFileSync(join(profileHome, 'gateway_state.json'), JSON.stringify({ pid: 33333, gateway_state: 'starting' }))
|
||||
|
||||
const manager = await createManager(home)
|
||||
|
||||
expect(manager.readPidFile('work')).toBe(33333)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GatewayManager gateway process env', () => {
|
||||
it('keeps full inherited env for the default profile for compatibility', async () => {
|
||||
const home = createHermesHome()
|
||||
process.env.WEIXIN_TOKEN = 'from-parent'
|
||||
process.env.CUSTOM_GATEWAY_SETTING = 'keep-me'
|
||||
process.env.HERMES_HOME = home
|
||||
vi.resetModules()
|
||||
const { buildGatewayProcessEnv } = await import('../../packages/server/src/services/hermes/gateway-manager')
|
||||
|
||||
const env = buildGatewayProcessEnv('default', home)
|
||||
|
||||
expect(env.WEIXIN_TOKEN).toBe('from-parent')
|
||||
expect(env.CUSTOM_GATEWAY_SETTING).toBe('keep-me')
|
||||
expect(env.HERMES_HOME).toBe(home)
|
||||
})
|
||||
|
||||
it('removes parent env keys defined by any profile env for non-default profile gateways', async () => {
|
||||
const home = createHermesHome()
|
||||
const workHome = join(home, 'profiles', 'work')
|
||||
mkdirSync(workHome, { recursive: true })
|
||||
writeFileSync(join(home, '.env'), [
|
||||
'WEIXIN_TOKEN=default-weixin',
|
||||
'WECOM_SECRET=default-wecom',
|
||||
'FUTURE_PLATFORM_TOKEN=default-future',
|
||||
'export EXPORTED_SECRET=default-export',
|
||||
'PATH=/default/path',
|
||||
'HTTP_PROXY=http://default-proxy.local:8080',
|
||||
'COMMENTED_OUT_SECRET=not-commented',
|
||||
'# COMMENTED_OUT_SECRET=commented',
|
||||
].join('\n'))
|
||||
writeFileSync(join(workHome, '.env'), [
|
||||
'WORK_ONLY_TOKEN=work-profile',
|
||||
'PARENT_OVERRIDE_ME=work-profile',
|
||||
].join('\n'))
|
||||
|
||||
process.env.PATH = '/opt/hermes/.venv/bin:/usr/bin'
|
||||
process.env.HOME = '/home/agent'
|
||||
process.env.HTTP_PROXY = 'http://proxy.local:8080'
|
||||
process.env.HERMES_BIN = '/opt/hermes/.venv/bin/hermes'
|
||||
process.env.HERMES_ALLOW_ROOT_GATEWAY = '1'
|
||||
process.env.HERMES_HOME = home
|
||||
process.env.WEIXIN_TOKEN = 'from-parent'
|
||||
process.env.WECOM_SECRET = 'from-parent'
|
||||
process.env.FUTURE_PLATFORM_TOKEN = 'from-parent'
|
||||
process.env.EXPORTED_SECRET = 'from-parent'
|
||||
process.env.WORK_ONLY_TOKEN = 'from-parent'
|
||||
process.env.PARENT_OVERRIDE_ME = 'from-parent'
|
||||
process.env.UNKNOWN_SERVICE_TOKEN = 'keep-me'
|
||||
process.env.COMMENTED_OUT_SECRET = 'from-parent'
|
||||
process.env.CUSTOM_GATEWAY_SETTING = 'from-parent'
|
||||
vi.resetModules()
|
||||
const { buildGatewayProcessEnv } = await import('../../packages/server/src/services/hermes/gateway-manager')
|
||||
|
||||
const env = buildGatewayProcessEnv('work', join(home, 'profiles', 'work'))
|
||||
|
||||
expect(env.HERMES_HOME).toBe(join(home, 'profiles', 'work'))
|
||||
expect(env.PATH).toBe('/opt/hermes/.venv/bin:/usr/bin')
|
||||
expect(env.HOME).toBe('/home/agent')
|
||||
expect(env.HTTP_PROXY).toBe('http://proxy.local:8080')
|
||||
expect(env.HERMES_BIN).toBe('/opt/hermes/.venv/bin/hermes')
|
||||
expect(env.HERMES_ALLOW_ROOT_GATEWAY).toBe('1')
|
||||
expect(env.WEIXIN_TOKEN).toBeUndefined()
|
||||
expect(env.WECOM_SECRET).toBeUndefined()
|
||||
expect(env.FUTURE_PLATFORM_TOKEN).toBeUndefined()
|
||||
expect(env.EXPORTED_SECRET).toBeUndefined()
|
||||
expect(env.WORK_ONLY_TOKEN).toBeUndefined()
|
||||
expect(env.PARENT_OVERRIDE_ME).toBeUndefined()
|
||||
expect(env.COMMENTED_OUT_SECRET).toBeUndefined()
|
||||
expect(env.UNKNOWN_SERVICE_TOKEN).toBe('keep-me')
|
||||
expect(env.CUSTOM_GATEWAY_SETTING).toBe('from-parent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GatewayManager gateway port allocation', () => {
|
||||
it('skips the Web UI listen port when assigning gateway ports', async () => {
|
||||
const home = createHermesHome()
|
||||
mkdirSync(join(home, 'profiles', 'work'), { recursive: true })
|
||||
process.env.HERMES_HOME = home
|
||||
process.env.PORT = '8648'
|
||||
vi.resetModules()
|
||||
const { GatewayManager } = await import('../../packages/server/src/services/hermes/gateway-manager')
|
||||
const manager = new GatewayManager('default') as any
|
||||
manager.allocatedPorts = new Set([8642, 8643, 8644, 8645, 8646, 8647])
|
||||
|
||||
const endpoint = await manager.resolvePort('work')
|
||||
|
||||
expect(endpoint.port).toBe(8649)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countTokens } from '../../packages/server/src/lib/context-compressor'
|
||||
import {
|
||||
estimateGroupHistoryMessageTokens,
|
||||
groupBridgeReasoningDeltaFromEvent,
|
||||
groupContextTokensWithFixedOverhead,
|
||||
} from '../../packages/server/src/services/hermes/group-chat/agent-clients'
|
||||
|
||||
describe('group chat fixed context cache helpers', () => {
|
||||
it('adds cached fixed context to group chat message tokens', () => {
|
||||
const history = [
|
||||
{ role: 'user', content: '[Alice]: hello' },
|
||||
{ role: 'assistant', content: '[Bot]: hi there' },
|
||||
]
|
||||
|
||||
const messageTokens = estimateGroupHistoryMessageTokens(history)
|
||||
|
||||
expect(messageTokens).toBe(countTokens('[Alice]: hello') + countTokens('[Bot]: hi there'))
|
||||
expect(groupContextTokensWithFixedOverhead(20_000, history)).toBe(20_000 + messageTokens)
|
||||
})
|
||||
|
||||
it('signals fallback when fixed context is unavailable', () => {
|
||||
expect(groupContextTokensWithFixedOverhead(undefined, [{ content: 'hello' }])).toBeUndefined()
|
||||
expect(groupContextTokensWithFixedOverhead(null, [{ content: 'hello' }])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps spinner thinking events out of persisted group-chat reasoning', () => {
|
||||
expect(groupBridgeReasoningDeltaFromEvent({
|
||||
event: 'thinking.delta',
|
||||
text: '(◕‿◕✿) pondering...',
|
||||
})).toBeNull()
|
||||
expect(groupBridgeReasoningDeltaFromEvent({
|
||||
event: 'reasoning.delta',
|
||||
text: 'real reasoning',
|
||||
})).toBe('real reasoning')
|
||||
expect(groupBridgeReasoningDeltaFromEvent({
|
||||
event: 'reasoning.delta',
|
||||
text: '',
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { socketHandlers, mockSocket, mockIo } = vi.hoisted(() => {
|
||||
const socketHandlers = new Map<string, (...args: any[]) => void>()
|
||||
const mockSocket: any = {
|
||||
id: 'socket-1',
|
||||
connected: true,
|
||||
io: { on: vi.fn() },
|
||||
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
||||
socketHandlers.set(event, handler)
|
||||
if (event === 'connect') queueMicrotask(() => handler())
|
||||
return mockSocket
|
||||
}),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
const mockIo = vi.fn(() => mockSocket)
|
||||
return { socketHandlers, mockSocket, mockIo }
|
||||
})
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: mockIo,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/auth', () => ({
|
||||
getToken: vi.fn(async () => 'test-token'),
|
||||
}))
|
||||
|
||||
import { AgentClients } from '../../packages/server/src/services/hermes/group-chat/agent-clients'
|
||||
import { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat'
|
||||
import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat'
|
||||
|
||||
function routeHandler(path: string, method: string) {
|
||||
const layer = (groupChatRoutes as any).stack.find((item: any) => item.path === path && item.methods.includes(method))
|
||||
if (!layer) throw new Error(`Route not found: ${method} ${path}`)
|
||||
return layer.stack[0]
|
||||
}
|
||||
|
||||
describe('Group Chat member/agent identity sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
socketHandlers.clear()
|
||||
})
|
||||
|
||||
it('uses the persisted group-chat agent id as the runtime agent id and socket user id', async () => {
|
||||
const clients = new AgentClients()
|
||||
|
||||
const client = await clients.createAgent({
|
||||
agentId: 'agent-stable-1',
|
||||
profile: 'default',
|
||||
name: 'Worker',
|
||||
description: '',
|
||||
invited: 0,
|
||||
} as any)
|
||||
|
||||
expect(client.agentId).toBe('agent-stable-1')
|
||||
expect(mockIo).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:8648/group-chat',
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
token: 'test-token',
|
||||
userId: 'agent-stable-1',
|
||||
name: 'Worker',
|
||||
source: 'agent',
|
||||
agentSocketSecret: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the same persisted agent id into the runtime client when adding an agent', async () => {
|
||||
const addRoomAgent = vi.fn((roomId: string, agentId: string, profile: string, name: string, description: string, invited: number) => ({
|
||||
id: 'row-1', roomId, agentId, profile, name, description, invited,
|
||||
}))
|
||||
const chatServer = {
|
||||
getStorage: () => ({
|
||||
getRoomAgents: vi.fn(() => []),
|
||||
addRoomAgent,
|
||||
}),
|
||||
agentClients: {
|
||||
createAgent: vi.fn(async () => ({ agentId: 'runtime-agent' })),
|
||||
addAgentToRoom: vi.fn(async () => undefined),
|
||||
},
|
||||
}
|
||||
setGroupChatServer(chatServer as any)
|
||||
|
||||
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
|
||||
const ctx: any = {
|
||||
params: { roomId: 'room-1' },
|
||||
request: { body: { profile: 'default', name: 'Worker' } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
await handler(ctx, async () => {})
|
||||
|
||||
const persisted = ctx.body.agent
|
||||
expect(persisted.agentId).toBeTruthy()
|
||||
expect(chatServer.agentClients.createAgent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: persisted.agentId,
|
||||
profile: 'default',
|
||||
name: 'Worker',
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not persist an agent when the runtime client cannot connect', async () => {
|
||||
const addRoomAgent = vi.fn()
|
||||
const chatServer = {
|
||||
getStorage: () => ({
|
||||
getRoomAgents: vi.fn(() => []),
|
||||
addRoomAgent,
|
||||
}),
|
||||
agentClients: {
|
||||
createAgent: vi.fn(async () => {
|
||||
throw new Error('Connection timeout')
|
||||
}),
|
||||
addAgentToRoom: vi.fn(),
|
||||
removeAgentFromRoom: vi.fn(),
|
||||
},
|
||||
}
|
||||
setGroupChatServer(chatServer as any)
|
||||
|
||||
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
|
||||
const ctx: any = {
|
||||
params: { roomId: 'room-1' },
|
||||
request: { body: { profile: 'default', name: 'Worker' } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
await handler(ctx, async () => {})
|
||||
|
||||
expect(ctx.status).toBe(502)
|
||||
expect(ctx.body).toMatchObject({
|
||||
code: 'PROFILE_AGENT_CONNECT_FAILED',
|
||||
profile: 'default',
|
||||
reason: 'Connection timeout',
|
||||
})
|
||||
expect(addRoomAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not persist an agent and disconnects runtime state when room join fails', async () => {
|
||||
const addRoomAgent = vi.fn()
|
||||
const runtimeClient = { agentId: 'agent-stable-1' }
|
||||
const chatServer = {
|
||||
getStorage: () => ({
|
||||
getRoomAgents: vi.fn(() => []),
|
||||
addRoomAgent,
|
||||
}),
|
||||
agentClients: {
|
||||
createAgent: vi.fn(async () => runtimeClient),
|
||||
addAgentToRoom: vi.fn(async () => {
|
||||
throw new Error('join failed')
|
||||
}),
|
||||
removeAgentFromRoom: vi.fn(),
|
||||
},
|
||||
}
|
||||
setGroupChatServer(chatServer as any)
|
||||
|
||||
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
|
||||
const ctx: any = {
|
||||
params: { roomId: 'room-1' },
|
||||
request: { body: { profile: 'default', name: 'Worker' } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
await handler(ctx, async () => {})
|
||||
|
||||
expect(ctx.status).toBe(502)
|
||||
expect(ctx.body).toMatchObject({
|
||||
code: 'PROFILE_AGENT_CONNECT_FAILED',
|
||||
profile: 'default',
|
||||
reason: 'join failed',
|
||||
})
|
||||
expect(addRoomAgent).not.toHaveBeenCalled()
|
||||
expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1')
|
||||
})
|
||||
|
||||
it('rolls back AgentClients room state when joining a room fails', async () => {
|
||||
const clients = new AgentClients()
|
||||
const runtimeClient = {
|
||||
agentId: 'agent-stable-1',
|
||||
name: 'Worker',
|
||||
joinRoom: vi.fn(async () => {
|
||||
throw new Error('join failed')
|
||||
}),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
|
||||
await expect(clients.addAgentToRoom('room-1', runtimeClient as any)).rejects.toThrow('join failed')
|
||||
|
||||
expect(runtimeClient.disconnect).toHaveBeenCalled()
|
||||
expect(clients.getAgents('room-1')).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the runtime agent by persisted agentId and returns synchronized room state', async () => {
|
||||
const agentsBefore = [{ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 }]
|
||||
const storage = {
|
||||
getRoomAgent: vi.fn(() => agentsBefore[0]),
|
||||
getRoomAgents: vi.fn(() => []),
|
||||
removeRoomMembersForAgent: vi.fn(),
|
||||
removeRoomAgent: vi.fn(),
|
||||
getRoomMembers: vi.fn(() => [{ id: 'member-1', userId: 'human-1', name: 'Han', description: '', joinedAt: 1 }]),
|
||||
}
|
||||
const chatServer = {
|
||||
getStorage: () => storage,
|
||||
agentClients: { removeAgentFromRoom: vi.fn() },
|
||||
}
|
||||
setGroupChatServer(chatServer as any)
|
||||
|
||||
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', 'DELETE')
|
||||
const ctx: any = {
|
||||
params: { roomId: 'room-1', agentId: 'row-1' },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
await handler(ctx, async () => {})
|
||||
|
||||
expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1')
|
||||
expect(storage.removeRoomMembersForAgent).toHaveBeenCalledWith('room-1', agentsBefore[0])
|
||||
expect(storage.removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-1')
|
||||
expect(ctx.body).toEqual({
|
||||
success: true,
|
||||
agents: [],
|
||||
members: [{ id: 'member-1', userId: 'human-1', name: 'Han', description: '', joinedAt: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('filters room list to rooms containing one of the regular admin profiles', async () => {
|
||||
const allRooms = [
|
||||
{ id: 'room-default', name: 'Default', inviteCode: null },
|
||||
{ id: 'room-private', name: 'Private', inviteCode: null },
|
||||
]
|
||||
const visibleRooms = [allRooms[0]]
|
||||
const storage = {
|
||||
getAllRooms: vi.fn(() => allRooms),
|
||||
getRoomsForProfiles: vi.fn(() => visibleRooms),
|
||||
}
|
||||
setGroupChatServer({ getStorage: () => storage } as any)
|
||||
|
||||
const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET')
|
||||
const ctx: any = {
|
||||
state: { user: { id: 2, username: 'ops', role: 'admin', profiles: ['default', 'research'] } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
await handler(ctx, async () => {})
|
||||
|
||||
expect(storage.getRoomsForProfiles).toHaveBeenCalledWith(['default', 'research'])
|
||||
expect(storage.getAllRooms).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ rooms: visibleRooms })
|
||||
})
|
||||
|
||||
it('keeps room list unrestricted for super admins', async () => {
|
||||
const rooms = [{ id: 'room-1', name: 'All', inviteCode: null }]
|
||||
const storage = {
|
||||
getAllRooms: vi.fn(() => rooms),
|
||||
getRoomsForProfiles: vi.fn(() => []),
|
||||
}
|
||||
setGroupChatServer({ getStorage: () => storage } as any)
|
||||
|
||||
const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET')
|
||||
const ctx: any = {
|
||||
state: { user: { id: 1, username: 'admin', role: 'super_admin' } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
await handler(ctx, async () => {})
|
||||
|
||||
expect(storage.getAllRooms).toHaveBeenCalledOnce()
|
||||
expect(storage.getRoomsForProfiles).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ rooms })
|
||||
})
|
||||
|
||||
it('routes @mentions from users and bounded agent replies', () => {
|
||||
const server = Object.create(GroupChatServer.prototype) as any
|
||||
const emit = vi.fn()
|
||||
server.rooms = new Map([
|
||||
['room-1', {
|
||||
hasOnlineMember: vi.fn(() => true),
|
||||
getOnlineMemberBySocketId: vi.fn((socketId: string) => socketId === 'agent-socket'
|
||||
? { userId: 'agent-1', name: '丫鬟', source: 'agent' }
|
||||
: { userId: 'human-1', name: 'Human', source: 'human' }),
|
||||
}],
|
||||
])
|
||||
server.socketUserMap = new Map([
|
||||
['human-socket', 'human-1'],
|
||||
['agent-socket', 'agent-1'],
|
||||
])
|
||||
server.userInfoMap = new Map([
|
||||
['human-1', { name: 'Human', description: '' }],
|
||||
['agent-1', { name: '丫鬟', description: '' }],
|
||||
])
|
||||
server.agentClients = { processMentions: vi.fn(async () => undefined) }
|
||||
server.storage = {
|
||||
saveMessageAndRefreshRoom: vi.fn((msg: any) => ({ message: msg, totalTokens: 123 })),
|
||||
}
|
||||
server.nsp = { to: vi.fn(() => ({ emit })) }
|
||||
|
||||
server.handleMessage({ id: 'human-socket' }, { roomId: 'room-1', content: '@all hi', role: 'user' }, vi.fn())
|
||||
expect(server.agentClients.processMentions).toHaveBeenCalledTimes(1)
|
||||
expect(server.agentClients.processMentions).toHaveBeenLastCalledWith('room-1', expect.objectContaining({
|
||||
content: '@all hi',
|
||||
senderId: 'human-1',
|
||||
mentionDepth: 0,
|
||||
}))
|
||||
|
||||
server.agentClients.processMentions.mockClear()
|
||||
server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all agent says hi', role: 'assistant', mentionDepth: 1 }, vi.fn())
|
||||
expect(server.agentClients.processMentions).toHaveBeenCalledTimes(1)
|
||||
expect(server.agentClients.processMentions).toHaveBeenLastCalledWith('room-1', expect.objectContaining({
|
||||
content: '@all agent says hi',
|
||||
senderId: 'agent-1',
|
||||
mentionDepth: 1,
|
||||
}))
|
||||
|
||||
server.agentClients.processMentions.mockClear()
|
||||
server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all too deep', role: 'assistant', mentionDepth: 4 }, vi.fn())
|
||||
expect(server.agentClients.processMentions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isAllAgentsMentioned,
|
||||
isAgentMentioned,
|
||||
isReservedMentionName,
|
||||
resolveMentionTargets,
|
||||
stripMentionRoutingTokens,
|
||||
} from '../../packages/server/src/services/hermes/group-chat/mention-routing'
|
||||
|
||||
type TestAgent = { name: string; id?: string; agentId?: string; profile?: string }
|
||||
|
||||
const agents: TestAgent[] = [
|
||||
{ name: 'Alice', id: 'socket-alice', agentId: 'agent-alice' },
|
||||
{ name: 'Bob', id: 'socket-bob', agentId: 'agent-bob' },
|
||||
{ name: 'Regex.Bot', id: 'socket-regex', agentId: 'agent-regex' },
|
||||
]
|
||||
|
||||
describe('group chat mention routing', () => {
|
||||
it('reserves @all so it cannot be confused with a literal agent name', () => {
|
||||
expect(isReservedMentionName('all')).toBe(true)
|
||||
expect(isReservedMentionName(' ALL ')).toBe(true)
|
||||
expect(isReservedMentionName('Alice')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes @all as a standalone mention with safe boundaries', () => {
|
||||
expect(isAllAgentsMentioned('@all please compare notes')).toBe(true)
|
||||
expect(isAllAgentsMentioned('please compare notes @ALL')).toBe(true)
|
||||
expect(isAllAgentsMentioned('@all, compare notes')).toBe(true)
|
||||
expect(isAllAgentsMentioned('email user@all.example')).toBe(false)
|
||||
expect(isAllAgentsMentioned('@alligator should not notify everyone')).toBe(false)
|
||||
expect(isAllAgentsMentioned('prefix@all should not notify everyone')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps exact agent mentions boundary-aware and regex-safe', () => {
|
||||
expect(isAgentMentioned('@Regex.Bot please review', 'Regex.Bot')).toBe(true)
|
||||
expect(isAgentMentioned('@RegexxBot should not match', 'Regex.Bot')).toBe(false)
|
||||
expect(isAgentMentioned('@Alice, please review', 'Alice')).toBe(true)
|
||||
expect(isAgentMentioned('mailto@Alice.example', 'Alice')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes @all to every room agent except the sender identity', () => {
|
||||
expect(resolveMentionTargets(agents, '@all summarize the options', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot'])
|
||||
})
|
||||
|
||||
it('keeps same-name human senders routable because sender exclusion uses identity, not display name', () => {
|
||||
const sameNameAgents: TestAgent[] = [
|
||||
{ name: 'test', id: 'socket-agent-test', agentId: 'agent-test' },
|
||||
{ name: 'tt', id: 'socket-agent-tt', agentId: 'agent-tt' },
|
||||
]
|
||||
|
||||
expect(resolveMentionTargets(sameNameAgents, '@all can you talk to me?', 'human-test-user').map(a => a.name)).toEqual(['test', 'tt'])
|
||||
expect(resolveMentionTargets(sameNameAgents, '@test why no response?', 'human-test-user').map(a => a.name)).toEqual(['test'])
|
||||
})
|
||||
|
||||
it('still excludes an agent from routing to itself when the sender identity matches that agent', () => {
|
||||
const sameNameAgents: TestAgent[] = [
|
||||
{ name: 'test', id: 'socket-agent-test', agentId: 'agent-test' },
|
||||
{ name: 'tt', id: 'socket-agent-tt', agentId: 'agent-tt' },
|
||||
]
|
||||
|
||||
expect(resolveMentionTargets(sameNameAgents, '@all compare plans', 'socket-agent-test').map(a => a.name)).toEqual(['tt'])
|
||||
expect(resolveMentionTargets(sameNameAgents, '@all compare plans', 'agent-test').map(a => a.name)).toEqual(['tt'])
|
||||
expect(resolveMentionTargets(sameNameAgents, '@test check yourself', 'socket-agent-test').map(a => a.name)).toEqual([])
|
||||
})
|
||||
|
||||
it('routes explicit mentions without treating partial @all text as broadcast', () => {
|
||||
expect(resolveMentionTargets(agents, '@Bob and @Regex.Bot compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot'])
|
||||
expect(resolveMentionTargets(agents, '@alligator and @Bob compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob'])
|
||||
})
|
||||
|
||||
it('dedupes mixed @all and explicit mentions', () => {
|
||||
expect(resolveMentionTargets(agents, '@all @Bob compare plans', 'socket-alice').map(a => a.name)).toEqual(['Bob', 'Regex.Bot'])
|
||||
})
|
||||
|
||||
it('strips the broadcast token and this agent mention before routing to the model', () => {
|
||||
expect(stripMentionRoutingTokens('@all @Bob please review', 'Bob')).toBe('please review')
|
||||
expect(stripMentionRoutingTokens('@ALL, @Regex.Bot: please review', 'Regex.Bot')).toBe('please review')
|
||||
expect(stripMentionRoutingTokens('@all please review', 'all')).toBe('please review')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
function readRootPackage() {
|
||||
return JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8')) as {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthControllerWithoutInjectedVersion() {
|
||||
vi.resetModules()
|
||||
delete (globalThis as any).__APP_VERSION__
|
||||
|
||||
vi.doMock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
getVersion: vi.fn().mockResolvedValue('Hermes Agent v0.11.0\n'),
|
||||
}))
|
||||
|
||||
return import('../../packages/server/src/controllers/health')
|
||||
}
|
||||
|
||||
async function loadHealthControllerWithInjectedVersion(version: string) {
|
||||
vi.resetModules()
|
||||
;(globalThis as any).__APP_VERSION__ = version
|
||||
|
||||
vi.doMock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
getVersion: vi.fn().mockResolvedValue('Hermes Agent v0.11.0\n'),
|
||||
}))
|
||||
|
||||
return import('../../packages/server/src/controllers/health')
|
||||
}
|
||||
|
||||
function createMockCtx() {
|
||||
return {
|
||||
body: null as any,
|
||||
}
|
||||
}
|
||||
|
||||
describe('health controller version metadata', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
;(globalThis as any).__APP_VERSION__ = 'test'
|
||||
})
|
||||
|
||||
it('reads the root package version in ts-node/dev mode instead of falling back to 0.0.0', async () => {
|
||||
const pkg = readRootPackage()
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
|
||||
|
||||
const { healthCheck } = await loadHealthControllerWithoutInjectedVersion()
|
||||
const ctx = createMockCtx()
|
||||
|
||||
await healthCheck(ctx)
|
||||
|
||||
expect(ctx.body.webui_version).toBe(pkg.version)
|
||||
expect(ctx.body.webui_version).not.toBe('0.0.0')
|
||||
})
|
||||
|
||||
it('uses the injected build version when available', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
|
||||
|
||||
const { healthCheck } = await loadHealthControllerWithInjectedVersion('9.9.9-test')
|
||||
const ctx = createMockCtx()
|
||||
|
||||
await healthCheck(ctx)
|
||||
|
||||
expect(ctx.body.webui_version).toBe('9.9.9-test')
|
||||
})
|
||||
|
||||
it('checks npm latest using the root package name', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const pkg = readRootPackage()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ version: '99.99.99' }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { checkLatestVersion, healthCheck } = await loadHealthControllerWithoutInjectedVersion()
|
||||
|
||||
await checkLatestVersion()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`https://registry.npmjs.org/${pkg.name}/latest`,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
)
|
||||
|
||||
const ctx = createMockCtx()
|
||||
await healthCheck(ctx)
|
||||
|
||||
expect(ctx.body.webui_latest).toBe('99.99.99')
|
||||
expect(ctx.body.webui_update_available).toBe(true)
|
||||
})
|
||||
|
||||
it('does not throw when latest-version lookup fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
||||
|
||||
const { checkLatestVersion } = await loadHealthControllerWithoutInjectedVersion()
|
||||
|
||||
await expect(checkLatestVersion()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockExecFileAsync = vi.hoisted(() => vi.fn())
|
||||
const mockSpawnHermes = vi.hoisted(() => vi.fn())
|
||||
const mockLoggerError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-process', () => ({
|
||||
execHermes: (args: string[], options: unknown) => mockExecFileAsync('hermes', args, options),
|
||||
spawnHermes: mockSpawnHermes,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: {
|
||||
error: mockLoggerError,
|
||||
},
|
||||
}))
|
||||
|
||||
import * as service from '../../packages/server/src/services/hermes/hermes-kanban'
|
||||
|
||||
describe('hermes kanban service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists boards without mutating or depending on CLI current', async () => {
|
||||
mockExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify([{ slug: 'default' }]) })
|
||||
|
||||
await expect(service.listBoards({ includeArchived: true })).resolves.toEqual([{ slug: 'default' }])
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', 'boards', 'list', '--json', '--all'])
|
||||
})
|
||||
|
||||
it('creates and archives boards through canonical CLI board commands', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ slug: 'project-a', name: 'Project A' }]) })
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await expect(service.createBoard({ slug: 'project-a', name: 'Project A', description: 'desc', icon: '📌', color: '#8b5cf6', switchCurrent: true })).resolves.toEqual({ slug: 'project-a', name: 'Project A' })
|
||||
await expect(service.archiveBoard('project-a')).resolves.toBeUndefined()
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', 'boards', 'create', 'project-a', '--name', 'Project A', '--description', 'desc', '--icon', '📌', '--color', '#8b5cf6', '--switch'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', 'boards', 'list', '--json', '--all'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', 'boards', 'rm', 'project-a'])
|
||||
})
|
||||
|
||||
it('exposes capability metadata for WUI/canonical parity gaps', async () => {
|
||||
await expect(service.getCapabilities()).resolves.toMatchObject({
|
||||
source: 'hermes-cli',
|
||||
supports: { boardsList: true, boardCreate: true, commentsWrite: true, dispatch: true, links: true },
|
||||
missing: expect.arrayContaining(['cliCurrentSwitch', 'bulk', 'homeSubscriptions']),
|
||||
capabilities: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'commentsWrite', status: 'supported', canonicalCommand: 'comment', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'links', status: 'supported', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'bulk', status: 'partial', canonicalRoute: '/tasks/bulk', requiresBoard: true }),
|
||||
expect.objectContaining({ key: 'events', status: 'partial', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true }),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it('builds board-scoped watch args for the kanban event bridge', () => {
|
||||
expect(service.buildWatchArgs({ board: 'Project_A', interval: 0.25 })).toEqual(['kanban', '--board', 'project_a', 'watch', '--interval', '0.25'])
|
||||
expect(service.buildWatchArgs()).toEqual(['kanban', '--board', 'default', 'watch', '--interval', '0.5'])
|
||||
})
|
||||
|
||||
it('builds link/unlink and bulk-equivalent task commands with explicit board', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: 'linked\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'unlinked\n' })
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockResolvedValueOnce({ stdout: '' })
|
||||
.mockRejectedValueOnce(new Error('cannot complete task-2'))
|
||||
|
||||
await expect(service.linkTasks('task-1', 'task-2', { board: 'project-a' })).resolves.toEqual({ ok: true, output: 'linked\n' })
|
||||
await expect(service.unlinkTasks('task-1', 'task-2', { board: 'project-a' })).resolves.toEqual({ ok: true, output: 'unlinked\n' })
|
||||
await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1', 'task-2'], status: 'done', assignee: 'alice', summary: 'closed' })).resolves.toEqual({
|
||||
results: [
|
||||
{ id: 'task-1', ok: true },
|
||||
{ id: 'task-2', ok: false, error: 'Failed to complete kanban tasks: cannot complete task-2' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'link', 'task-1', 'task-2'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'unlink', 'task-1', 'task-2'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'complete', 'task-1', '--summary', 'closed'])
|
||||
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'assign', 'task-1', 'alice'])
|
||||
expect(mockExecFileAsync.mock.calls[4][1]).toEqual(['kanban', '--board', 'project-a', 'complete', 'task-2', '--summary', 'closed'])
|
||||
})
|
||||
|
||||
it('treats zero-exit stderr from mutation CLI calls as failures', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): missing-a, missing-b\n' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'No such link: missing-a -> missing-b\n' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): task-1\n' })
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'kanban: unknown task(s): task-2\n' })
|
||||
|
||||
await expect(service.linkTasks('missing-a', 'missing-b', { board: 'project-a' })).rejects.toThrow('Failed to link kanban tasks: kanban: unknown task(s): missing-a, missing-b')
|
||||
await expect(service.unlinkTasks('missing-a', 'missing-b', { board: 'project-a' })).rejects.toThrow('Failed to unlink kanban tasks: No such link: missing-a -> missing-b')
|
||||
await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1', 'task-2'], status: 'done' })).resolves.toEqual({
|
||||
results: [
|
||||
{ id: 'task-1', ok: false, error: 'Failed to complete kanban tasks: kanban: unknown task(s): task-1' },
|
||||
{ id: 'task-2', ok: false, error: 'Failed to complete kanban tasks: kanban: unknown task(s): task-2' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns per-task bulk errors for unsupported direct status patches before shelling out', async () => {
|
||||
await expect(service.bulkUpdateTasks({ board: 'project-a', ids: ['task-1'], status: 'running' })).resolves.toEqual({
|
||||
results: [{ id: 'task-1', ok: false, error: 'Bulk status running is not supported by the CLI bridge' }],
|
||||
})
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds comment/log/diagnostics commands with explicit board', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: 'comment added\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'worker log\n' })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ task_id: 'task-1', severity: 'warning' }]) })
|
||||
|
||||
await expect(service.addComment('task-1', '--not-an-option', { board: 'default', author: 'han' })).resolves.toEqual({ ok: true, output: 'comment added\n' })
|
||||
await expect(service.getTaskLog('task-1', { board: 'default', tail: 4000 })).resolves.toEqual({ task_id: 'task-1', path: null, exists: true, size_bytes: 11, content: 'worker log\n', truncated: false })
|
||||
await expect(service.getDiagnostics({ board: 'default', task: 'task-1', severity: 'warning' })).resolves.toEqual([{ task_id: 'task-1', severity: 'warning' }])
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'comment', 'task-1', '--not-an-option', '--author', 'han'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'log', 'task-1', '--tail', '4000'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'diagnostics', '--json', '--task', 'task-1', '--severity', 'warning'])
|
||||
})
|
||||
|
||||
it('maps no-log task logs to canonical empty-log shape', async () => {
|
||||
mockExecFileAsync
|
||||
.mockRejectedValueOnce({ code: 1, stderr: '(no log for task-1 — task may not have spawned yet)' })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
|
||||
|
||||
await expect(service.getTaskLog('task-1', { board: 'default' })).resolves.toEqual({
|
||||
task_id: 'task-1',
|
||||
path: null,
|
||||
exists: false,
|
||||
size_bytes: 0,
|
||||
content: '',
|
||||
truncated: false,
|
||||
})
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'log', 'task-1'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'show', 'task-1', '--json'])
|
||||
})
|
||||
|
||||
it('builds recovery and dispatch commands with explicit board', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: 'reclaimed\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'reassigned\n' })
|
||||
.mockResolvedValueOnce({ stdout: '{"task_id":"task-1","created":true}\n' })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ spawned: 1 }) })
|
||||
|
||||
await expect(service.reclaimTask('task-1', { board: 'project-a', reason: 'stale lock' })).resolves.toEqual({ ok: true, output: 'reclaimed\n' })
|
||||
await expect(service.reassignTask('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })).resolves.toEqual({ ok: true, output: 'reassigned\n' })
|
||||
await expect(service.specifyTask('task-1', { board: 'project-a', author: 'han' })).resolves.toEqual([{ task_id: 'task-1', created: true }])
|
||||
await expect(service.dispatch({ board: 'project-a', dryRun: true, max: 2, failureLimit: 3 })).resolves.toEqual({ spawned: 1 })
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'reclaim', 'task-1', '--reason', 'stale lock'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'reassign', 'task-1', 'bob', '--reclaim', '--reason', 'handoff'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'specify', 'task-1', '--json', '--author', 'han'])
|
||||
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'dispatch', '--json', '--dry-run', '--max', '2', '--failure-limit', '3'])
|
||||
})
|
||||
|
||||
it('builds list/create/stats CLI calls with global --board before the action', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'task-2' }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ total: 1, by_status: {}, by_assignee: {} }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'archived-1', status: 'archived' }, { id: 'archived-2', status: 'archived' }]) })
|
||||
|
||||
await expect(service.listTasks({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: true })).resolves.toEqual([{ id: 'task-1' }])
|
||||
await expect(service.createTask('Ship', { board: 'project-a', body: 'write', assignee: 'alice', priority: 3, tenant: 'ops' })).resolves.toEqual({ id: 'task-2' })
|
||||
await expect(service.getStats({ board: 'project-a' })).resolves.toEqual({ total: 3, by_status: { archived: 2 }, by_assignee: {} })
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--archived', '--status', 'todo', '--assignee', 'alice', '--tenant', 'ops'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'create', 'Ship', '--json', '--body', 'write', '--assignee', 'alice', '--priority', '3', '--tenant', 'ops'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'stats', '--json'])
|
||||
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--archived', '--status', 'archived'])
|
||||
})
|
||||
|
||||
it('normalizes omitted board to default instead of falling through to CLI current', async () => {
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ total: 0, by_status: {}, by_assignee: {} }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
|
||||
await service.listTasks()
|
||||
await service.getStats()
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'list', '--json'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'stats', '--json'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'list', '--json', '--archived', '--status', 'archived'])
|
||||
})
|
||||
|
||||
it('builds action CLI calls and maps not-found show to null', async () => {
|
||||
mockExecFileAsync
|
||||
.mockRejectedValueOnce({ code: 1 })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([{ name: 'alice' }]) })
|
||||
|
||||
await expect(service.getTask('missing', { board: 'default' })).resolves.toBeNull()
|
||||
await service.completeTasks(['task-1'], 'done', { board: 'default' })
|
||||
await service.blockTask('task-1', 'wait', { board: 'default' })
|
||||
await service.unblockTasks(['task-1'], { board: 'default' })
|
||||
await service.assignTask('task-1', 'alice', { board: 'default' })
|
||||
await expect(service.getAssignees({ board: 'default' })).resolves.toEqual([{ name: 'alice' }])
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'show', 'missing', '--json'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'complete', 'task-1', '--summary', 'done'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'block', 'task-1', 'wait'])
|
||||
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'default', 'unblock', 'task-1'])
|
||||
expect(mockExecFileAsync.mock.calls[4][1]).toEqual(['kanban', '--board', 'default', 'assign', 'task-1', 'alice'])
|
||||
expect(mockExecFileAsync.mock.calls[5][1]).toEqual(['kanban', '--board', 'default', 'assignees', '--json'])
|
||||
})
|
||||
|
||||
it('rejects invalid board slugs before shelling out', async () => {
|
||||
await expect(service.listTasks({ board: 'bad;slug' })).rejects.toThrow('Invalid kanban board slug')
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes board slugs using canonical upstream-compatible rules', async () => {
|
||||
const sixtyFourChars = 'a'.repeat(64)
|
||||
mockExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
|
||||
await service.listTasks({ board: 'Team_Alpha' })
|
||||
await service.listTasks({ board: sixtyFourChars })
|
||||
await service.listTasks({ board: 'default' })
|
||||
|
||||
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'team_alpha', 'list', '--json'])
|
||||
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', sixtyFourChars, 'list', '--json'])
|
||||
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'list', '--json'])
|
||||
await expect(service.listTasks({ board: 'bad/slug' })).rejects.toThrow('Invalid kanban board slug')
|
||||
await expect(service.listTasks({ board: 'bad.slug' })).rejects.toThrow('Invalid kanban board slug')
|
||||
await expect(service.listTasks({ board: '..' })).rejects.toThrow('Invalid kanban board slug')
|
||||
await expect(service.listTasks({ board: 'bad slug' })).rejects.toThrow('Invalid kanban board slug')
|
||||
await expect(service.listTasks({ board: ' ' })).rejects.toThrow('Invalid kanban board slug')
|
||||
})
|
||||
|
||||
it('does not hide non-no-log failures from the kanban log command', async () => {
|
||||
mockExecFileAsync
|
||||
.mockRejectedValueOnce({ code: 1, stderr: 'permission denied', message: 'permission denied' })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
|
||||
|
||||
await expect(service.getTaskLog('task-1', { board: 'default' })).rejects.toThrow('Failed to read kanban task log: permission denied')
|
||||
expect(mockLoggerError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat misleading no-log fragments as canonical no-log messages', async () => {
|
||||
mockExecFileAsync
|
||||
.mockRejectedValueOnce({ code: 1, stderr: 'permission denied: no log for diagnostic file', message: 'permission denied' })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ task: { id: 'task-1' }, runs: [], comments: [], events: [] }) })
|
||||
|
||||
await expect(service.getTaskLog('task-1', { board: 'default' })).rejects.toThrow('Failed to read kanban task log: permission denied')
|
||||
})
|
||||
|
||||
it('wraps CLI failures with service-specific errors', async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error('boom'))
|
||||
|
||||
await expect(service.listTasks()).rejects.toThrow('Failed to list kanban tasks: boom')
|
||||
expect(mockLoggerError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'fs'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { detectHermesHome } from '../../packages/server/src/services/hermes/hermes-path'
|
||||
|
||||
describe('Hermes path detection', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
const originalPlatform = process.platform
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'hermes-path-'))
|
||||
process.env = { ...originalEnv }
|
||||
delete process.env.HERMES_HOME
|
||||
delete process.env.LOCALAPPDATA
|
||||
delete process.env.APPDATA
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
process.env = { ...originalEnv }
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = ''
|
||||
})
|
||||
|
||||
it('keeps explicit HERMES_HOME even when the path does not exist', () => {
|
||||
process.env.HERMES_HOME = join(tempDir, 'custom-home')
|
||||
|
||||
expect(detectHermesHome()).toBe(resolve(tempDir, 'custom-home'))
|
||||
})
|
||||
|
||||
it('falls back to ~/.hermes on Windows when LOCALAPPDATA hermes is missing', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
process.env.LOCALAPPDATA = join(tempDir, 'Local')
|
||||
|
||||
expect(detectHermesHome()).toBe(resolve(homedir(), '.hermes'))
|
||||
})
|
||||
|
||||
it('uses existing Windows LOCALAPPDATA hermes before APPDATA', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
const localHermes = join(tempDir, 'Local', 'hermes')
|
||||
const roamingHermes = join(tempDir, 'Roaming', 'hermes')
|
||||
mkdirSync(localHermes, { recursive: true })
|
||||
mkdirSync(roamingHermes, { recursive: true })
|
||||
process.env.LOCALAPPDATA = join(tempDir, 'Local')
|
||||
process.env.APPDATA = join(tempDir, 'Roaming')
|
||||
|
||||
expect(detectHermesHome()).toBe(resolve(localHermes))
|
||||
})
|
||||
|
||||
it('falls back to existing Windows APPDATA hermes when LOCALAPPDATA hermes is missing', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
const roamingHermes = join(tempDir, 'Roaming', 'hermes')
|
||||
mkdirSync(roamingHermes, { recursive: true })
|
||||
process.env.LOCALAPPDATA = join(tempDir, 'Local')
|
||||
process.env.APPDATA = join(tempDir, 'Roaming')
|
||||
|
||||
expect(detectHermesHome()).toBe(resolve(roamingHermes))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Hermes plugin discovery environment', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'hermes-plugins-env-'))
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('uses the same venv python and agent root resolved from the hermes binary as the bridge', async () => {
|
||||
const agentRoot = join(tempDir, 'agent')
|
||||
const venvBin = join(agentRoot, '.venv', 'bin')
|
||||
const hermesCliDir = join(agentRoot, 'hermes_cli')
|
||||
const captureFile = join(tempDir, 'capture.txt')
|
||||
const fakePython = join(venvBin, 'python')
|
||||
const fakeHermes = join(venvBin, 'hermes')
|
||||
|
||||
mkdirSync(venvBin, { recursive: true })
|
||||
mkdirSync(hermesCliDir, { recursive: true })
|
||||
writeFileSync(join(agentRoot, 'run_agent.py'), '')
|
||||
writeFileSync(join(hermesCliDir, 'plugins.py'), '')
|
||||
writeFileSync(fakePython, [
|
||||
'#!/bin/sh',
|
||||
'printf "%s\\n%s\\n%s\\n%s\\n" "$0" "$1" "$2" "$HERMES_AGENT_ROOT_RESOLVED" > "$CAPTURE_FILE"',
|
||||
'printf "%s\\n" \'{"plugins":[],"warnings":[],"metadata":{"hermesAgentRoot":"","pythonExecutable":"","cwd":"","projectPluginsEnabled":false}}\'',
|
||||
'',
|
||||
].join('\n'))
|
||||
chmodSync(fakePython, 0o755)
|
||||
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||
chmodSync(fakeHermes, 0o755)
|
||||
|
||||
delete process.env.HERMES_AGENT_ROOT
|
||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
delete process.env.HERMES_AGENT_BRIDGE_UV
|
||||
delete process.env.HERMES_PYTHON
|
||||
process.env.HERMES_HOME = join(tempDir, 'home')
|
||||
process.env.HERMES_BIN = fakeHermes
|
||||
process.env.CAPTURE_FILE = captureFile
|
||||
|
||||
const { listHermesPlugins } = await import('../../packages/server/src/services/hermes/plugins')
|
||||
await expect(listHermesPlugins()).resolves.toMatchObject({ plugins: [] })
|
||||
|
||||
const [command, firstArg, secondArg, resolvedRoot] = readFileSync(captureFile, 'utf8').trim().split('\n')
|
||||
expect(command).toBe(fakePython)
|
||||
expect(firstArg).toBe('-I')
|
||||
expect(secondArg).toBe('-c')
|
||||
expect(resolvedRoot).toBe(agentRoot)
|
||||
})
|
||||
|
||||
it('uses package Python without isolated mode when no source root is resolved', async () => {
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const captureFile = join(tempDir, 'capture-package.txt')
|
||||
const fakePython = join(binDir, 'python')
|
||||
const fakeHermes = join(binDir, 'hermes')
|
||||
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
writeFileSync(fakePython, [
|
||||
'#!/bin/sh',
|
||||
'printf "%s\\n%s\\n%s\\n%s\\n" "$0" "$1" "${PYTHONPATH-unset}" "${PYTHONHOME-unset}" > "$CAPTURE_FILE"',
|
||||
'printf "%s\\n" \'{"plugins":[],"warnings":[],"metadata":{"hermesAgentRoot":"","pythonExecutable":"","cwd":"","projectPluginsEnabled":false}}\'',
|
||||
'',
|
||||
].join('\n'))
|
||||
chmodSync(fakePython, 0o755)
|
||||
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||
chmodSync(fakeHermes, 0o755)
|
||||
|
||||
delete process.env.HERMES_AGENT_ROOT
|
||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
delete process.env.HERMES_AGENT_BRIDGE_UV
|
||||
delete process.env.UV
|
||||
delete process.env.HERMES_PYTHON
|
||||
process.env.HERMES_HOME = join(tempDir, 'home')
|
||||
process.env.HERMES_BIN = fakeHermes
|
||||
process.env.CAPTURE_FILE = captureFile
|
||||
process.env.PYTHONPATH = join(tempDir, 'shadow-path')
|
||||
process.env.PYTHONHOME = join(tempDir, 'shadow-home')
|
||||
|
||||
const { listHermesPlugins } = await import('../../packages/server/src/services/hermes/plugins')
|
||||
await expect(listHermesPlugins()).resolves.toMatchObject({ plugins: [] })
|
||||
|
||||
const [command, firstArg, pythonPath, pythonHome] = readFileSync(captureFile, 'utf8').trim().split('\n')
|
||||
expect(command).toBe(fakePython)
|
||||
expect(firstArg).toBe('-c')
|
||||
expect(pythonPath).toBe('unset')
|
||||
expect(pythonHome).toBe('unset')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
||||
const spawnCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
execFileCalls.push({ command, args, options })
|
||||
callback(null, 'ok\n', '')
|
||||
}),
|
||||
spawn: vi.fn((command: string, args: string[], options: any) => {
|
||||
spawnCalls.push({ command, args, options })
|
||||
return {} as any
|
||||
}),
|
||||
}))
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
execFileCalls.length = 0
|
||||
spawnCalls.length = 0
|
||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
delete process.env.HERMES_AGENT_CLI_PYTHON
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('Hermes process invocation', () => {
|
||||
it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => {
|
||||
setPlatform('win32')
|
||||
process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe'
|
||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
const result = await execHermesWithBin(
|
||||
'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe',
|
||||
['kanban', '--board', 'default', 'create', 'demo', '--json'],
|
||||
{ windowsHide: true },
|
||||
)
|
||||
|
||||
expect(result.stdout).toBe('ok\n')
|
||||
expect(execFileCalls[0]).toMatchObject({
|
||||
command: process.env.HERMES_AGENT_CLI_PYTHON,
|
||||
args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'],
|
||||
options: expect.objectContaining({ windowsHide: true }),
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => {
|
||||
setPlatform('win32')
|
||||
const root = mkdtempSync(join(tmpdir(), 'hermes-process-'))
|
||||
try {
|
||||
const scripts = join(root, 'Scripts')
|
||||
mkdirSync(scripts)
|
||||
writeFileSync(join(root, 'python.exe'), '')
|
||||
writeFileSync(join(scripts, 'hermes.exe'), '')
|
||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'])
|
||||
|
||||
expect(execFileCalls[0]).toMatchObject({
|
||||
command: join(root, 'python.exe'),
|
||||
args: ['-m', 'hermes_cli.main', '--version'],
|
||||
options: expect.objectContaining({ windowsHide: true }),
|
||||
})
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps normal Hermes command execution unchanged on non-Windows platforms', async () => {
|
||||
setPlatform('darwin')
|
||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
await execHermesWithBin('/opt/hermes/bin/hermes', ['--version'], { windowsHide: true })
|
||||
|
||||
expect(execFileCalls[0]).toMatchObject({
|
||||
command: '/opt/hermes/bin/hermes',
|
||||
args: ['--version'],
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults spawned Windows Hermes processes to hidden windows', async () => {
|
||||
setPlatform('win32')
|
||||
process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Hermes Studio\\resources\\python\\python.exe'
|
||||
const { spawnHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
spawnHermesWithBin('C:\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe', ['gateway', 'run'])
|
||||
|
||||
expect(spawnCalls[0]).toMatchObject({
|
||||
command: process.env.HERMES_AGENT_CLI_PYTHON,
|
||||
args: ['-m', 'hermes_cli.main', 'gateway', 'run'],
|
||||
options: expect.objectContaining({ windowsHide: true }),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Hermes schema initialization', () => {
|
||||
let db: any = null
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
db = new DatabaseSync(':memory:')
|
||||
vi.doMock('../../packages/server/src/db/index', () => ({
|
||||
getDb: () => db,
|
||||
getStoragePath: () => ':memory:',
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = null
|
||||
vi.doUnmock('../../packages/server/src/db/index')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('initializes all tables with correct schemas', async () => {
|
||||
const { initAllHermesTables, USAGE_TABLE, SESSIONS_TABLE, MESSAGES_TABLE, GC_ROOMS_TABLE, USERS_TABLE, USER_PROFILES_TABLE } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
expect(() => initAllHermesTables()).not.toThrow()
|
||||
|
||||
// Verify core tables exist
|
||||
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as Array<{ name: string }>
|
||||
expect(tables.map(t => t.name)).toContain(USAGE_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(SESSIONS_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(MESSAGES_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(GC_ROOMS_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(USERS_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(USER_PROFILES_TABLE)
|
||||
|
||||
// Verify USAGE_TABLE structure
|
||||
const usageCols = db.prepare(`PRAGMA table_info("${USAGE_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(usageCols.some(c => c.name === 'id')).toBe(true)
|
||||
expect(usageCols.some(c => c.name === 'session_id')).toBe(true)
|
||||
expect(usageCols.some(c => c.name === 'input_tokens')).toBe(true)
|
||||
expect(usageCols.some(c => c.name === 'output_tokens')).toBe(true)
|
||||
|
||||
const userCols = db.prepare(`PRAGMA table_info("${USERS_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(userCols.some(c => c.name === 'id')).toBe(true)
|
||||
expect(userCols.some(c => c.name === 'username')).toBe(true)
|
||||
expect(userCols.some(c => c.name === 'password_hash')).toBe(true)
|
||||
expect(userCols.some(c => c.name === 'role')).toBe(true)
|
||||
|
||||
const profileCols = db.prepare(`PRAGMA table_info("${USER_PROFILES_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(profileCols.some(c => c.name === 'user_id')).toBe(true)
|
||||
expect(profileCols.some(c => c.name === 'profile_name')).toBe(true)
|
||||
expect(profileCols.some(c => c.name === 'is_default')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves existing data when adding safe schema columns', async () => {
|
||||
const { initAllHermesTables, USAGE_TABLE, USAGE_SCHEMA } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
// Create table with minimal schema
|
||||
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL)`)
|
||||
|
||||
// Insert test data
|
||||
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run('test-session', Date.now())
|
||||
|
||||
// Run initialization (should add safe missing columns)
|
||||
expect(() => initAllHermesTables()).not.toThrow()
|
||||
|
||||
// Verify data is preserved
|
||||
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('test-session')
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.session_id).toBe('test-session')
|
||||
|
||||
// Verify safe new columns were added
|
||||
const cols = db.prepare(`PRAGMA table_info("${USAGE_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(cols.some(c => c.name === 'input_tokens')).toBe(true)
|
||||
expect(cols.some(c => c.name === 'output_tokens')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles single-column primary key tables correctly', async () => {
|
||||
const { initAllHermesTables, GC_ROOM_AGENTS_TABLE } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
expect(() => initAllHermesTables()).not.toThrow()
|
||||
|
||||
// Verify table has primary key and required columns
|
||||
const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`).get(GC_ROOM_AGENTS_TABLE) as { sql: string }
|
||||
expect(tableInfo.sql).toContain('PRIMARY KEY')
|
||||
expect(tableInfo.sql).toContain('id')
|
||||
expect(tableInfo.sql).toContain('roomId')
|
||||
expect(tableInfo.sql).toContain('agentId')
|
||||
|
||||
// Verify we can insert multiple entries with unique id
|
||||
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('agent-1', 'room-1', 'agent-1', 'default', 'Agent 1', '', 0)
|
||||
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('agent-2', 'room-1', 'agent-2', 'default', 'Agent 2', '', 0)
|
||||
|
||||
const count = db.prepare(`SELECT COUNT(*) as count FROM "${GC_ROOM_AGENTS_TABLE}"`).get() as { count: number }
|
||||
expect(count.count).toBe(2)
|
||||
|
||||
// Verify duplicate primary key is rejected
|
||||
expect(() => {
|
||||
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('agent-1', 'room-1', 'agent-1', 'default', 'Agent 1 Duplicate', '', 0)
|
||||
}).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
profileDir: '',
|
||||
execFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileName: () => 'default',
|
||||
getProfileDir: () => testState.profileDir || '/fake/home/.hermes',
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-path', () => ({
|
||||
getHermesBin: () => '/fake/bin/hermes',
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: testState.execFile,
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
import { update } from '../../packages/server/src/controllers/hermes/jobs'
|
||||
|
||||
function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
const ctx: any = {
|
||||
req: { method: 'PATCH' },
|
||||
request: { body: { name: 'renamed' } },
|
||||
params: { id: 'abc123abc123' },
|
||||
query: {},
|
||||
search: '',
|
||||
headers: {},
|
||||
status: 200,
|
||||
set: vi.fn(),
|
||||
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
|
||||
}
|
||||
|
||||
describe('Hermes jobs controller', () => {
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'hermes-web-ui-jobs-test-'))
|
||||
testState.profileDir = tempDir
|
||||
testState.execFile.mockImplementation((_bin, _args, _opts, cb) => {
|
||||
cb(null, { stdout: '', stderr: '' })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = ''
|
||||
testState.profileDir = ''
|
||||
})
|
||||
|
||||
it('returns 404 before editing when the local cron job does not exist', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve({ error: 'Prompt must be ≤ 5000 characters' }),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx()
|
||||
await update(ctx)
|
||||
|
||||
expect(ctx.status).toBe(404)
|
||||
expect(ctx.body).toEqual({ error: { message: 'Job not found' } })
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call the removed gateway proxy path for missing jobs', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
|
||||
const ctx = createMockCtx()
|
||||
await update(ctx)
|
||||
|
||||
expect(ctx.status).toBe(404)
|
||||
expect(ctx.body).toEqual({ error: { message: 'Job not found' } })
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears repeat by passing repeat 0 to Hermes CLI', async () => {
|
||||
const cronDir = join(tempDir, 'cron')
|
||||
mkdirSync(cronDir, { recursive: true })
|
||||
writeFileSync(join(cronDir, 'jobs.json'), JSON.stringify({
|
||||
jobs: [{
|
||||
job_id: 'abc123abc123',
|
||||
id: 'abc123abc123',
|
||||
name: 'daily',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *', display: '0 9 * * *' },
|
||||
schedule_display: '0 9 * * *',
|
||||
prompt: 'run daily',
|
||||
repeat: { times: 3, completed: 1 },
|
||||
}],
|
||||
}))
|
||||
|
||||
const ctx = createMockCtx({
|
||||
request: { body: { repeat: null } },
|
||||
})
|
||||
await update(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(testState.execFile).toHaveBeenCalledWith(
|
||||
'/fake/bin/hermes',
|
||||
['cron', 'edit', 'abc123abc123', '--repeat', '0'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ HERMES_HOME: tempDir }),
|
||||
windowsHide: true,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user