mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
Post-merge follow-ups + several review rounds + a hub-search rework, folded together. Merge-scuff restores (a stale-base refactor had reverted two live-on-main fixes): - gateway: SessionStore compression-tip healing + its regression test. - desktop: messaging session/transcript polling in desktop-controller (MESSAGING_POLL / ACTIVE_MESSAGING_SESSION_POLL, refreshMessagingSessions, refreshActiveMessagingTranscript, the richer sameCronSignature) so inbound platform traffic updates live again instead of freezing until manual refresh. Profile-switch isolation (epoch/close/guard on every profile-scoped async): - Hub store clears + in-flight runHubAction bails (and swallows the post-switch 404 instead of a phantom toast); hub preview/scan/search/sources profile-scoped. - MCP: probe/auth epoch guards, dirty-draft reset, sidebar mutations blocked until config resettles AND every persist re-checks the epoch post-await; profilePending clears on config settle incl. error; logs re-key on profile. - Model settings reload on switch and epoch-guard setModelAssignment / saveMoaModels / API-key activation. - Config draft resets + cancels its autosave on switch; skill editor/archive and star-map node dialogs close on switch; openSkillEditor / star-map openEdit discard stale fetches; tool-usage analytics loads are profile-guarded/keyed. Correctness + UX: - Unique per-skill action names for hub install AND uninstall; hub/catalog rows flip only on a clean exit_code; catalog install polls the background bootstrap to completion, reconciles the mcp.json draft (no dropped server), and fails loudly on non-zero exit; MCP catalog query keyed by profile. - /test reports needs-auth for anonymous auth:oauth servers; /auth snapshots + restores tokens on a failed re-auth and clears the full 300s callback window. - config-settings shows a retry on load failure; CodeEditor/JsonDocumentEditor go read-only while saving so edits typed mid-save aren't dropped. - Deep-link highlighter deletes its param only after a successful scroll. - Restored the PageSearchShell trailing slot → Artifacts refresh button/spinner. - /settings?tab=mcp redirect keeps server=. Progressive hub search: fan out one query per backend-searchable source (index-covered API sources stay unsearchable → no ~70-call GitHub re-hammer), merge/dedupe by trust as each lands, per-source spinner overlaid on the dimmed chip — results stream in without blocking on the slowest, no layout shift. test(web): /api/skills list carries usage + provenance (CI contract).
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
// downloadGatewayMediaFile drives an <a download> click, so these need a DOM.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { $connection } from '@/store/session'
|
|
|
|
import {
|
|
downloadGatewayMediaFile,
|
|
filePathFromMediaPath,
|
|
gatewayMediaDataUrl,
|
|
isRemoteGateway,
|
|
mediaExternalUrl
|
|
} from './media'
|
|
|
|
describe('isRemoteGateway', () => {
|
|
afterEach(() => {
|
|
$connection.set(null)
|
|
})
|
|
|
|
it('is false with no connection', () => {
|
|
$connection.set(null)
|
|
expect(isRemoteGateway()).toBe(false)
|
|
})
|
|
|
|
it('is false in local mode', () => {
|
|
$connection.set({ mode: 'local' } as never)
|
|
expect(isRemoteGateway()).toBe(false)
|
|
})
|
|
|
|
it('is true in remote mode', () => {
|
|
$connection.set({ mode: 'remote' } as never)
|
|
expect(isRemoteGateway()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('filePathFromMediaPath', () => {
|
|
it('passes through a plain path', () => {
|
|
expect(filePathFromMediaPath('/home/u/.hermes/images/a.png')).toBe('/home/u/.hermes/images/a.png')
|
|
})
|
|
|
|
it('decodes a file:// URL with encoded characters', () => {
|
|
expect(filePathFromMediaPath('file:///tmp/a%20b.png')).toBe('/tmp/a b.png')
|
|
})
|
|
})
|
|
|
|
describe('mediaExternalUrl', () => {
|
|
afterEach(() => {
|
|
$connection.set(null)
|
|
})
|
|
|
|
it('passes through http(s) URLs untouched', () => {
|
|
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never)
|
|
expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
|
|
})
|
|
|
|
it('keeps file:// form in local mode', () => {
|
|
$connection.set({ mode: 'local' } as never)
|
|
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
|
|
expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png')
|
|
})
|
|
|
|
it('rewrites gateway-local paths to an authenticated download URL', () => {
|
|
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
|
|
expect(mediaExternalUrl('file:///tmp/a b.png')).toBe(
|
|
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
|
|
)
|
|
expect(mediaExternalUrl('/tmp/a b.png')).toBe(
|
|
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
|
|
)
|
|
})
|
|
|
|
it('falls back to file:// when remote connection lacks a token', () => {
|
|
$connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never)
|
|
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
|
|
})
|
|
})
|
|
|
|
describe('gatewayMediaDataUrl', () => {
|
|
const api = vi.fn(async ({ path }: { path: string }) => {
|
|
if (path.startsWith('/api/fs/read-data-url?')) {
|
|
return { dataUrl: 'data:image/png;base64,ZHVtbXk=' }
|
|
}
|
|
|
|
throw new Error(`unexpected path ${path}`)
|
|
})
|
|
|
|
beforeEach(() => {
|
|
api.mockClear()
|
|
vi.stubGlobal('window', { hermesDesktop: { api } })
|
|
$connection.set({ mode: 'remote' } as never)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
$connection.set(null)
|
|
})
|
|
|
|
it('reads gateway media through the desktop fs bridge instead of /api/media roots', async () => {
|
|
const url = await gatewayMediaDataUrl('/home/u/.hermes/skills/demo/images/a b.png')
|
|
|
|
expect(url).toBe('data:image/png;base64,ZHVtbXk=')
|
|
expect(api).toHaveBeenCalledWith({
|
|
path: '/api/fs/read-data-url?path=%2Fhome%2Fu%2F.hermes%2Fskills%2Fdemo%2Fimages%2Fa%20b.png'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('downloadGatewayMediaFile', () => {
|
|
const api = vi.fn(async ({ path }: { path: string }) => {
|
|
if (path.startsWith('/api/fs/read-data-url?')) {
|
|
return { dataUrl: 'data:text/markdown;base64,IyByZXBvcnQ=' }
|
|
}
|
|
|
|
throw new Error(`unexpected path ${path}`)
|
|
})
|
|
|
|
let clickSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
api.mockClear()
|
|
vi.stubGlobal('window', { hermesDesktop: { api }, setTimeout: vi.fn() })
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => ({ blob: async () => new Blob(['# report'], { type: 'text/markdown' }) }))
|
|
)
|
|
URL.createObjectURL = vi.fn(() => 'blob:remote-artifact')
|
|
URL.revokeObjectURL = vi.fn()
|
|
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
$connection.set({ mode: 'remote' } as never)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.clearAllMocks()
|
|
clickSpy.mockRestore()
|
|
$connection.set(null)
|
|
})
|
|
|
|
it('downloads gateway files through the desktop fs bridge', async () => {
|
|
await downloadGatewayMediaFile('file:///Users/me/project/report.md')
|
|
|
|
expect(api).toHaveBeenCalledWith({
|
|
path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Freport.md'
|
|
})
|
|
expect(clickSpy).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('rejects when the gateway refuses the file read', async () => {
|
|
api.mockRejectedValueOnce(new Error('403 File is not readable'))
|
|
|
|
await expect(downloadGatewayMediaFile('/Users/me/project/report.md')).rejects.toThrow('403')
|
|
expect(clickSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|