mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
A Cursor-style MCP manager inside Capabilities, plus the backend it needs. - Server list with brand/favicon avatars + live status dot and a capability summary (N tools, M prompts, K resources); Servers | Catalog views. - Catalog: one-click install of Nous-approved servers with required-env prompts. - GUI OAuth: Authenticate opens the system browser from the TTY-less backend and verifies a token actually lands; header/API-key servers are never pushed down OAuth; a dirty mcp.json can't drop a freshly-persisted auth field. - Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent LogTail; probes cached 5m and keyed by (profile, config) so revisiting never respawns the fleet or shows a stale probe. - Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick (the generic /api/config deep-merge could not remove keys). - perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio spawn can't stall every other request into a 15s timeout. - per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
137 lines
3.7 KiB
TypeScript
137 lines
3.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
getCronJobs,
|
|
getGlobalModelInfo,
|
|
getGlobalModelOptions,
|
|
getHermesConfig,
|
|
getHermesConfigDefaults,
|
|
getProfiles,
|
|
getSessionMessages,
|
|
getStatus,
|
|
listAllProfileSessions,
|
|
listSessions
|
|
} from './hermes'
|
|
import { refreshActiveProfile } from './store/profile'
|
|
|
|
const emptySessionsResponse = {
|
|
limit: 0,
|
|
offset: 0,
|
|
sessions: [],
|
|
total: 0
|
|
}
|
|
|
|
describe('Hermes REST session helpers', () => {
|
|
let api: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
api = vi.fn().mockResolvedValue(emptySessionsResponse)
|
|
Object.defineProperty(window, 'hermesDesktop', {
|
|
configurable: true,
|
|
value: { api }
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
Reflect.deleteProperty(window, 'hermesDesktop')
|
|
})
|
|
|
|
it('uses a longer timeout for the single-profile session list', async () => {
|
|
await listSessions(50, 1)
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses a longer timeout for the all-profile session list', async () => {
|
|
await listAllProfileSessions(50, 1)
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses a longer timeout for profile listing during desktop startup', async () => {
|
|
api.mockResolvedValue({ profiles: [] })
|
|
|
|
await getProfiles()
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/profiles',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses a longer timeout for active profile refresh during desktop startup', async () => {
|
|
api.mockResolvedValueOnce({ current: 'default' }).mockResolvedValueOnce({ profiles: [] })
|
|
|
|
await refreshActiveProfile()
|
|
|
|
expect(api).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
path: '/api/profiles/active',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
expect(api).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
path: '/api/profiles',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('gives the whole startup data burst the long timeout, not just profiles', async () => {
|
|
api.mockResolvedValue({})
|
|
|
|
const bootCalls: [() => Promise<unknown>, string][] = [
|
|
[getHermesConfig, '/api/config'],
|
|
[getHermesConfigDefaults, '/api/config/defaults'],
|
|
[getGlobalModelInfo, '/api/model/info'],
|
|
[() => getGlobalModelOptions(), '/api/model/options'],
|
|
[getCronJobs, '/api/cron/jobs']
|
|
]
|
|
|
|
for (const [call, path] of bootCalls) {
|
|
api.mockClear()
|
|
await call()
|
|
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path, timeoutMs: 60_000 }))
|
|
}
|
|
})
|
|
|
|
it('keeps the liveness poll on the short default so a dead backend fails fast', async () => {
|
|
api.mockResolvedValue({})
|
|
api.mockClear()
|
|
|
|
await getStatus()
|
|
|
|
// /api/status must NOT carry the long startup timeout — it is the runtime
|
|
// liveness probe and has to fail quickly when the backend drops.
|
|
const call = api.mock.calls[0]?.[0] as { path: string; timeoutMs?: number }
|
|
expect(call.path).toBe('/api/status')
|
|
expect(call.timeoutMs).toBeUndefined()
|
|
})
|
|
|
|
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
|
|
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
|
|
|
|
await getSessionMessages('session-1', 'xiaoxuxu')
|
|
|
|
expect(api).toHaveBeenCalledWith({
|
|
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
|
|
profile: 'xiaoxuxu'
|
|
})
|
|
})
|
|
})
|