Fix bridge history, profile models, and Windows gateway handling (#845)
* feat: support profile-aware group chat bridge flows * feat: route cron jobs through hermes cli * Fix group chat routing and isolate bridge tests * Add Grok image-to-video media skill * Default Grok videos to media directory * Fix bridge profile fallback and cron repeat clearing * Refine bridge chat and gateway platform handling * Filter bridge tool-call text deltas * Preserve structured bridge chat history * Prepare beta release build artifacts * Fix Windows run profile resolution * Fix Windows path compatibility checks * Fix profile-scoped model page display * Hide Windows subprocess windows for jobs and updates * Hide Windows file backend subprocess windows * Avoid Windows gateway restart lock conflicts * Treat Windows gateway lock as running on startup * Force release Windows gateway lock on restart * Tighten Windows gateway lock cleanup * Update chat e2e source expectation * Bump package version to 0.5.30 --------- Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -25,8 +25,8 @@ describe('Profiles Store', () => {
|
||||
|
||||
it('fetchProfiles loads profiles and sets active', async () => {
|
||||
const profiles = [
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
]
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue(profiles)
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('Profiles Store', () => {
|
||||
it('createProfile calls API and refreshes list', async () => {
|
||||
mockProfilesApi.createProfile.mockResolvedValue({ success: true })
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'new-profile', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'new-profile', active: false, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
@@ -69,11 +69,11 @@ describe('Profiles Store', () => {
|
||||
it('deleteProfile clears detail cache', async () => {
|
||||
mockProfilesApi.deleteProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', gateway: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
await store.deleteProfile('test')
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('Profiles Store', () => {
|
||||
})
|
||||
|
||||
it('fetchProfileDetail uses cache', async () => {
|
||||
const detail = { name: 'cached', path: '/tmp/cached', model: 'gpt-4', provider: 'openai', gateway: 'running', skills: 5, hasEnv: true, hasSoulMd: false }
|
||||
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
|
||||
|
||||
@@ -107,8 +107,8 @@ describe('Profiles Store', () => {
|
||||
it('switchProfile updates activeProfileName immediately', async () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
{ name: 'dev', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'default', active: false, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: true, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
@@ -164,8 +164,8 @@ describe('Profiles Store', () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
// Backend returns success, but active profile is still default (not the one we switched to)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('tool trace visibility', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('hides named live and history tool traces when the localStorage toggle is off', () => {
|
||||
it('hides named transcript traces when the toggle is off while keeping live tool stream visible', () => {
|
||||
useToolTraceVisibility().setToolTraceVisible(false)
|
||||
|
||||
const liveWrapper = mountLiveList()
|
||||
@@ -102,7 +102,7 @@ describe('tool trace visibility', () => {
|
||||
'user-1',
|
||||
'assistant-1',
|
||||
])
|
||||
expect(liveWrapper.findAll('.tool-call-name').map(node => node.text())).not.toContain('read_file')
|
||||
expect(liveWrapper.findAll('.tool-call-name').map(node => node.text())).toContain('read_file')
|
||||
|
||||
const historyWrapper = mount(HistoryMessageList, {
|
||||
props: { session: makeSession(sampleMessages) },
|
||||
|
||||
@@ -50,7 +50,7 @@ test('sends a chat run and renders streamed Socket.IO response events', async ({
|
||||
input: 'Summarize the queue',
|
||||
queue_id: expect.any(String),
|
||||
session_id: expect.any(String),
|
||||
source: 'api_server',
|
||||
source: 'cli',
|
||||
})
|
||||
expect(run.model).toBe('test-model')
|
||||
|
||||
|
||||
@@ -67,4 +67,11 @@ describe('agent bridge manager command resolution', () => {
|
||||
hermesHome: homeDir,
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ function writeAuthJson(auth: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function makeCtx(): any {
|
||||
return { params: {}, request: { body: {} }, body: undefined, status: 200 }
|
||||
return { params: {}, query: {}, request: { body: {} }, body: undefined, status: 200 }
|
||||
}
|
||||
|
||||
async function loadModelsController() {
|
||||
|
||||
@@ -4,16 +4,23 @@ import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import YAML from 'js-yaml'
|
||||
|
||||
const { mockGatewayManager } = vi.hoisted(() => ({
|
||||
mockGatewayManager: {
|
||||
getActiveProfile: vi.fn(() => 'default'),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
const { mockRestartGateway, mockDestroyProfile } = vi.hoisted(() => ({
|
||||
mockRestartGateway: vi.fn().mockResolvedValue('restarted'),
|
||||
mockDestroyProfile: vi.fn().mockResolvedValue({ destroyed: true }),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: () => mockGatewayManager,
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', async (importOriginal) => {
|
||||
const original = await importOriginal<any>()
|
||||
return {
|
||||
...original,
|
||||
restartGateway: mockRestartGateway,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
|
||||
AgentBridgeClient: class {
|
||||
destroyProfile = mockDestroyProfile
|
||||
},
|
||||
}))
|
||||
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
@@ -46,7 +53,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('config controller locked file updates', () => {
|
||||
it('deep merges a config section and restarts platform gateways', async () => {
|
||||
it('deep merges a config section and restarts the gateway through hermes-cli', async () => {
|
||||
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||
'telegram:',
|
||||
' enabled: false',
|
||||
@@ -62,8 +69,8 @@ describe('config controller locked file updates', () => {
|
||||
await updateConfig(ctx)
|
||||
|
||||
expect(ctx.body).toEqual({ success: true })
|
||||
expect(mockGatewayManager.stop).toHaveBeenCalledWith('default')
|
||||
expect(mockGatewayManager.start).toHaveBeenCalledWith('default')
|
||||
expect(mockRestartGateway).toHaveBeenCalledTimes(1)
|
||||
expect(mockDestroyProfile).toHaveBeenCalledWith('default')
|
||||
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' })
|
||||
|
||||
@@ -92,4 +92,55 @@ describe('config-helpers locked file updates', () => {
|
||||
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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,7 +107,8 @@ describe('prompts', () => {
|
||||
expect(result).toContain('AI coding assistant')
|
||||
expect(result).toContain('Alice')
|
||||
expect(result).toContain('Bob')
|
||||
expect(result).toContain('@Claude')
|
||||
expect(result).toContain('- Claude')
|
||||
expect(result).not.toContain('@Claude')
|
||||
})
|
||||
|
||||
it('builds agent instructions with empty member list', () => {
|
||||
@@ -389,9 +390,9 @@ describe('ContextEngine.buildContext', () => {
|
||||
expect(result.conversationHistory[0].role).toBe('user')
|
||||
expect(result.conversationHistory[0].content).toContain('[Alice]')
|
||||
|
||||
// Second message from agent → 'assistant' role, no prefix
|
||||
// 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('Hi there')
|
||||
expect(result.conversationHistory[1].content).toBe('[Claude]: Hi there')
|
||||
})
|
||||
|
||||
it('maps other messages to user role with name prefix', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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', () => {
|
||||
@@ -21,3 +22,17 @@ describe('file provider platform path normalization', () => {
|
||||
.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,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
gatewayStatusLooksRuntimeLocked,
|
||||
gatewayStatusLooksRunning,
|
||||
} 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)
|
||||
})
|
||||
})
|
||||
@@ -17,12 +17,6 @@ async function loadHealthControllerWithoutInjectedVersion() {
|
||||
getVersion: vi.fn().mockResolvedValue('Hermes Agent v0.11.0\n'),
|
||||
}))
|
||||
|
||||
vi.doMock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: vi.fn(() => ({
|
||||
getUpstream: () => 'http://127.0.0.1:9999',
|
||||
})),
|
||||
}))
|
||||
|
||||
return import('../../packages/server/src/controllers/health')
|
||||
}
|
||||
|
||||
@@ -34,12 +28,6 @@ async function loadHealthControllerWithInjectedVersion(version: string) {
|
||||
getVersion: vi.fn().mockResolvedValue('Hermes Agent v0.11.0\n'),
|
||||
}))
|
||||
|
||||
vi.doMock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: vi.fn(() => ({
|
||||
getUpstream: () => 'http://127.0.0.1:9999',
|
||||
})),
|
||||
}))
|
||||
|
||||
return import('../../packages/server/src/controllers/health')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: () => ({
|
||||
getUpstream: () => 'http://127.0.0.1:8642',
|
||||
getApiKey: () => null,
|
||||
}),
|
||||
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()
|
||||
@@ -33,12 +47,25 @@ function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Hermes jobs controller proxy', () => {
|
||||
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: '' })
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through upstream validation status and body instead of masking it as 502', async () => {
|
||||
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,
|
||||
@@ -50,18 +77,51 @@ describe('Hermes jobs controller proxy', () => {
|
||||
const ctx = createMockCtx()
|
||||
await update(ctx)
|
||||
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'Prompt must be ≤ 5000 characters' })
|
||||
expect(ctx.set).toHaveBeenCalledWith('Content-Type', 'application/json')
|
||||
expect(ctx.status).toBe(404)
|
||||
expect(ctx.body).toEqual({ error: { message: 'Job not found' } })
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps real proxy connection failures as 502', async () => {
|
||||
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(502)
|
||||
expect(ctx.body).toEqual({ error: { message: 'Proxy error: ECONNREFUSED' } })
|
||||
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),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalWebUiHome = process.env.HERMES_WEB_UI_HOME
|
||||
const originalWebuiStateDir = process.env.HERMES_WEBUI_STATE_DIR
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME
|
||||
else process.env.HERMES_WEB_UI_HOME = originalWebUiHome
|
||||
if (originalWebuiStateDir === undefined) delete process.env.HERMES_WEBUI_STATE_DIR
|
||||
else process.env.HERMES_WEBUI_STATE_DIR = originalWebuiStateDir
|
||||
})
|
||||
|
||||
describe('media controller', () => {
|
||||
it('uses Hermes Web UI media directory as the default generated video output path', async () => {
|
||||
process.env.HERMES_WEB_UI_HOME = '/tmp/hermes-web-ui-test-home'
|
||||
const { defaultMediaOutputPath } = await import('../../packages/server/src/controllers/hermes/media')
|
||||
|
||||
expect(defaultMediaOutputPath('req_123')).toBe(join('/tmp/hermes-web-ui-test-home', 'media', 'req_123.mp4'))
|
||||
expect(defaultMediaOutputPath('bad/request:id')).toBe(join('/tmp/hermes-web-ui-test-home', 'media', 'bad_request_id.mp4'))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockReadFile, mockReadConfigYaml, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
||||
const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockReadConfigYamlForProfile: vi.fn(),
|
||||
mockFetchProviderModels: vi.fn(),
|
||||
mockBuildModelGroups: vi.fn(() => ({ default: '', groups: [] })),
|
||||
mockReadAppConfig: vi.fn(),
|
||||
@@ -23,10 +24,14 @@ vi.mock('fs', () => ({
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveEnvPath: () => '/fake/home/.hermes/.env',
|
||||
getActiveAuthPath: () => '/fake/home/.hermes/auth.json',
|
||||
getActiveProfileName: () => 'default',
|
||||
getProfileDir: () => '/fake/home/.hermes',
|
||||
listProfileNamesFromDisk: () => ['default'],
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
readConfigYaml: mockReadConfigYaml,
|
||||
readConfigYamlForProfile: mockReadConfigYamlForProfile,
|
||||
writeConfigYaml: vi.fn(),
|
||||
fetchProviderModels: mockFetchProviderModels,
|
||||
buildModelGroups: mockBuildModelGroups,
|
||||
@@ -93,6 +98,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
mockWriteAppConfig.mockImplementation(async patch => patch)
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: () => ({
|
||||
getUpstream: () => 'http://127.0.0.1:8642',
|
||||
getApiKey: () => null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock updateUsage so we can assert calls without real DB
|
||||
const { mockUpdateUsage } = vi.hoisted(() => ({
|
||||
mockUpdateUsage: vi.fn(),
|
||||
@@ -18,7 +11,7 @@ vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
import { proxy, setRunSession } from '../../packages/server/src/routes/hermes/proxy-handler'
|
||||
import { proxy, setGatewayManagerForTest, setRunSession } from '../../packages/server/src/routes/hermes/proxy-handler'
|
||||
|
||||
function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
const ctx: any = {
|
||||
@@ -70,6 +63,10 @@ function createSSEBody(events: string[]): ReadableStream<Uint8Array> {
|
||||
describe('Proxy Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setGatewayManagerForTest({
|
||||
getUpstream: () => 'http://127.0.0.1:8642',
|
||||
getApiKey: () => null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rewrites /api/hermes/v1/* to /v1/*', async () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterBridgeToolCallMarkupDelta } from '../../packages/server/src/services/hermes/run-chat/bridge-delta'
|
||||
|
||||
describe('run-chat bridge delta filtering', () => {
|
||||
it('keeps ordinary assistant text', () => {
|
||||
const state = {}
|
||||
|
||||
expect(filterBridgeToolCallMarkupDelta(state, 'hello')).toBe('hello')
|
||||
expect(filterBridgeToolCallMarkupDelta(state, ' world')).toBe(' world')
|
||||
})
|
||||
|
||||
it('removes complete textual tool-call markup from bridge deltas', () => {
|
||||
const state = {}
|
||||
const delta = 'Before\n[Calling tool: terminal with arguments: {"cmd":"pwd"}]\nAfter'
|
||||
|
||||
expect(filterBridgeToolCallMarkupDelta(state, delta)).toBe('Before\nAfter')
|
||||
})
|
||||
|
||||
it('removes tool-call markup split across multiple chunks', () => {
|
||||
const state = {}
|
||||
|
||||
expect(filterBridgeToolCallMarkupDelta(state, '[Calling tool: terminal with arguments: {"cmd"')).toBe('')
|
||||
expect(filterBridgeToolCallMarkupDelta(state, ':"pwd"}]\nDone')).toBe('Done')
|
||||
})
|
||||
|
||||
it('keeps json arrays and brackets inside tool arguments from leaking', () => {
|
||||
const state = {}
|
||||
const delta = '[Calling tool: terminal with arguments: {"cmd":"printf \\"[x]\\"","items":["a","b"]}]\nDone'
|
||||
|
||||
expect(filterBridgeToolCallMarkupDelta(state, delta)).toBe('Done')
|
||||
})
|
||||
|
||||
it('holds a partial marker suffix until the next chunk', () => {
|
||||
const state = {}
|
||||
|
||||
expect(filterBridgeToolCallMarkupDelta(state, 'Text [Call')).toBe('Text ')
|
||||
expect(filterBridgeToolCallMarkupDelta(state, 'ing tool: terminal with arguments: {}]\nDone')).toBe('Done')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { convertContentBlocks, convertContentBlocksForAgent } from '../../packages/server/src/services/hermes/run-chat/content-blocks'
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
describe('run chat content blocks', () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'hermes-content-blocks-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps API image conversion as base64 input_image only', async () => {
|
||||
const imagePath = join(tempDir, 'image.png')
|
||||
await writeFile(imagePath, Buffer.from([1, 2, 3]))
|
||||
|
||||
const parts = await convertContentBlocks([
|
||||
{ type: 'text', text: 'animate this' },
|
||||
{ type: 'image', name: 'image.png', path: imagePath, media_type: 'image/png' },
|
||||
])
|
||||
|
||||
expect(parts).toHaveLength(2)
|
||||
expect(parts[0]).toEqual({ type: 'input_text', text: 'animate this' })
|
||||
expect(parts[1].type).toBe('input_image')
|
||||
expect(parts[1].image_url).toMatch(/^data:image\/png;base64,/)
|
||||
expect(JSON.stringify(parts)).not.toContain('Local image path for tools')
|
||||
})
|
||||
|
||||
it('adds local file path text for bridge agents while preserving the image data', async () => {
|
||||
const imagePath = join(tempDir, 'image.png')
|
||||
await writeFile(imagePath, Buffer.from([1, 2, 3]))
|
||||
|
||||
const parts = await convertContentBlocksForAgent([
|
||||
{ type: 'text', text: 'animate this' },
|
||||
{ type: 'image', name: 'image.png', path: imagePath, media_type: 'image/png' },
|
||||
])
|
||||
|
||||
expect(parts).toHaveLength(3)
|
||||
expect(parts[0]).toEqual({ type: 'text', text: 'animate this' })
|
||||
expect(parts[1]).toEqual({
|
||||
type: 'text',
|
||||
text: `[Attached image: image.png]\nLocal image path for tools: ${imagePath}`,
|
||||
})
|
||||
expect(parts[2].type).toBe('image_url')
|
||||
expect(parts[2].image_url?.url).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const readConfigYamlForProfileMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
readConfigYamlForProfile: readConfigYamlForProfileMock,
|
||||
}))
|
||||
|
||||
describe('run chat model config', () => {
|
||||
beforeEach(() => {
|
||||
readConfigYamlForProfileMock.mockReset()
|
||||
readConfigYamlForProfileMock.mockResolvedValue({
|
||||
model: { default: 'default-model', provider: 'default-provider' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the requested model for a new bridge session before falling back to profile default', async () => {
|
||||
const { resolveBridgeRunModelConfig } = await import('../../packages/server/src/services/hermes/run-chat/model-config')
|
||||
|
||||
const result = await resolveBridgeRunModelConfig({
|
||||
profile: 'default',
|
||||
requestedModel: 'gpt-5.2',
|
||||
requestedProvider: 'openai',
|
||||
modelGroups: [{ provider: 'openai', models: ['gpt-5.2'] }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: 'gpt-5.2', provider: 'openai' })
|
||||
expect(readConfigYamlForProfileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an existing session model ahead of a requested model', async () => {
|
||||
const { resolveBridgeRunModelConfig } = await import('../../packages/server/src/services/hermes/run-chat/model-config')
|
||||
|
||||
const result = await resolveBridgeRunModelConfig({
|
||||
profile: 'default',
|
||||
sessionModel: 'claude-sonnet-4.5',
|
||||
sessionProvider: 'anthropic',
|
||||
requestedModel: 'gpt-5.2',
|
||||
requestedProvider: 'openai',
|
||||
modelGroups: [
|
||||
{ provider: 'anthropic', models: ['claude-sonnet-4.5'] },
|
||||
{ provider: 'openai', models: ['gpt-5.2'] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: 'claude-sonnet-4.5', provider: 'anthropic' })
|
||||
expect(readConfigYamlForProfileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the profile default when the candidate model is unavailable', async () => {
|
||||
const { resolveBridgeRunModelConfig } = await import('../../packages/server/src/services/hermes/run-chat/model-config')
|
||||
|
||||
const result = await resolveBridgeRunModelConfig({
|
||||
profile: 'default',
|
||||
requestedModel: 'missing-model',
|
||||
requestedProvider: 'openai',
|
||||
modelGroups: [{ provider: 'openai', models: ['gpt-5.2'] }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: 'default-model', provider: 'default-provider' })
|
||||
expect(readConfigYamlForProfileMock).toHaveBeenCalledWith('default')
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,10 @@ const getConversationDetailFromDbMock = vi.fn()
|
||||
const listConversationSummariesMock = vi.fn()
|
||||
const getConversationDetailMock = vi.fn()
|
||||
const getSessionDetailFromDbMock = vi.fn()
|
||||
const getExactSessionDetailFromDbWithProfileMock = vi.fn()
|
||||
const getUsageStatsFromDbMock = vi.fn()
|
||||
const getSessionMock = vi.fn()
|
||||
const deleteHermesSessionForProfileMock = vi.fn()
|
||||
const localListSessionsMock = vi.fn()
|
||||
const localGetSessionDetailMock = vi.fn()
|
||||
const localSearchSessionsMock = vi.fn()
|
||||
@@ -41,6 +43,7 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
listSessions: vi.fn(),
|
||||
getSession: getSessionMock,
|
||||
deleteSession: vi.fn(),
|
||||
deleteSessionForProfile: deleteHermesSessionForProfileMock,
|
||||
renameSession: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -48,6 +51,7 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: vi.fn(),
|
||||
searchSessionSummaries: vi.fn(),
|
||||
getSessionDetailFromDb: getSessionDetailFromDbMock,
|
||||
getExactSessionDetailFromDbWithProfile: getExactSessionDetailFromDbWithProfileMock,
|
||||
getUsageStatsFromDb: getUsageStatsFromDbMock,
|
||||
}))
|
||||
|
||||
@@ -79,6 +83,7 @@ vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileName: getActiveProfileNameMock,
|
||||
listProfileNamesFromDisk: () => ['default', 'travel'],
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
||||
@@ -96,10 +101,6 @@ vi.mock('../../packages/server/src/lib/context-compressor/export-compressor', ()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: () => null,
|
||||
}))
|
||||
|
||||
describe('session conversations controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -108,8 +109,10 @@ describe('session conversations controller', () => {
|
||||
listConversationSummariesMock.mockReset()
|
||||
getConversationDetailMock.mockReset()
|
||||
getSessionDetailFromDbMock.mockReset()
|
||||
getExactSessionDetailFromDbWithProfileMock.mockReset()
|
||||
getUsageStatsFromDbMock.mockReset()
|
||||
getSessionMock.mockReset()
|
||||
deleteHermesSessionForProfileMock.mockReset()
|
||||
localListSessionsMock.mockReset()
|
||||
localGetSessionDetailMock.mockReset()
|
||||
localSearchSessionsMock.mockReset()
|
||||
@@ -319,6 +322,26 @@ describe('session conversations controller', () => {
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('deletes a current-profile Hermes history session even when no local Web UI session exists', async () => {
|
||||
getActiveProfileNameMock.mockReturnValue('travel')
|
||||
getSessionMock.mockReturnValue(null)
|
||||
getExactSessionDetailFromDbWithProfileMock.mockResolvedValue({ id: 'history-only', messages: [] })
|
||||
deleteHermesSessionForProfileMock.mockResolvedValue(true)
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = { params: { id: 'history-only' }, body: null }
|
||||
await mod.remove(ctx)
|
||||
|
||||
expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('history-only', 'travel')
|
||||
expect(deleteHermesSessionForProfileMock).toHaveBeenCalledWith('history-only', 'travel')
|
||||
expect(localDeleteSessionMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({
|
||||
ok: true,
|
||||
deleted: false,
|
||||
hermes: { attempted: true, deleted: true, profile: 'travel', error: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportSession', () => {
|
||||
it('returns session as JSON download with correct headers (full mode)', async () => {
|
||||
const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const originalSkillsDir = process.env.HERMES_WEB_UI_SKILLS_DIR
|
||||
|
||||
async function tempDir(prefix: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
if (originalSkillsDir === undefined) delete process.env.HERMES_WEB_UI_SKILLS_DIR
|
||||
else process.env.HERMES_WEB_UI_SKILLS_DIR = originalSkillsDir
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('HermesSkillInjector', () => {
|
||||
it('resolves source directories for override, production bundle, and development layouts', async () => {
|
||||
const root = await tempDir('hermes-skill-injector-paths-')
|
||||
const override = join(root, 'override-skills')
|
||||
const distSkills = join(root, 'dist', 'skills')
|
||||
const devSkills = join(root, 'packages', 'skills')
|
||||
await mkdir(override, { recursive: true })
|
||||
await mkdir(distSkills, { recursive: true })
|
||||
await mkdir(devSkills, { recursive: true })
|
||||
|
||||
const { HermesSkillInjector } = await import('../../packages/server/src/services/hermes/skill-injector')
|
||||
|
||||
expect(HermesSkillInjector.resolveSourceDir({ HERMES_WEB_UI_SKILLS_DIR: override } as any, join(root, 'dist', 'server'))).toBe(override)
|
||||
expect(HermesSkillInjector.resolveSourceDir({} as any, join(root, 'dist', 'server'))).toBe(distSkills)
|
||||
expect(HermesSkillInjector.resolveSourceDir({} as any, join(root, 'packages', 'server', 'src', 'services', 'hermes'))).toBe(devSkills)
|
||||
})
|
||||
|
||||
it('syncs bundled skills and replaces existing bundled copies', async () => {
|
||||
const source = await tempDir('hermes-skill-source-')
|
||||
const hermesHome = await tempDir('hermes-skill-home-')
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
|
||||
await mkdir(join(source, 'new-skill'), { recursive: true })
|
||||
await writeFile(join(source, 'new-skill', 'SKILL.md'), '# New Skill\n', 'utf-8')
|
||||
await mkdir(join(source, 'existing-skill'), { recursive: true })
|
||||
await writeFile(join(source, 'existing-skill', 'SKILL.md'), '# Bundled Existing\n', 'utf-8')
|
||||
|
||||
await mkdir(join(hermesHome, 'skills', 'existing-skill'), { recursive: true })
|
||||
await writeFile(join(hermesHome, 'skills', 'existing-skill', 'SKILL.md'), '# User Existing\n', 'utf-8')
|
||||
|
||||
const { HermesSkillInjector } = await import('../../packages/server/src/services/hermes/skill-injector')
|
||||
const result = await new HermesSkillInjector(source).injectMissingSkills()
|
||||
|
||||
expect(result.injected).toEqual(['new-skill'])
|
||||
expect(result.updated).toEqual(['existing-skill'])
|
||||
expect(result.skipped).toEqual([])
|
||||
await expect(readFile(join(hermesHome, 'skills', 'new-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# New Skill\n')
|
||||
await expect(readFile(join(hermesHome, 'skills', 'existing-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Bundled Existing\n')
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,7 @@ describe('update controller', () => {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10 * 60 * 1000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: expect.objectContaining({
|
||||
npm_node_execpath: process.execPath,
|
||||
PATH: expect.stringContaining(`${nodeBinDir}${delimiter}`),
|
||||
@@ -115,6 +116,7 @@ describe('update controller', () => {
|
||||
expect.objectContaining({
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: expect.objectContaining({ npm_node_execpath: process.execPath }),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyXaiOAuthDefaultModel } from '../../packages/server/src/controllers/hermes/xai-auth'
|
||||
|
||||
describe('xAI auth controller', () => {
|
||||
it('does not keep a non-xAI model when switching the default provider to xai-oauth', () => {
|
||||
const config = applyXaiOAuthDefaultModel({
|
||||
model: {
|
||||
default: 'glm-5-turbo',
|
||||
provider: 'custom:glm-coding-plan',
|
||||
base_url: 'https://api.z.ai/api/anthropic',
|
||||
api_key: 'secret',
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.model).toEqual({
|
||||
default: 'grok-4.3',
|
||||
provider: 'xai-oauth',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an existing Grok model when refreshing xai-oauth credentials', () => {
|
||||
const config = applyXaiOAuthDefaultModel({
|
||||
model: {
|
||||
default: 'grok-4.20-reasoning',
|
||||
provider: 'xai-oauth',
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.model).toEqual({
|
||||
default: 'grok-4.20-reasoning',
|
||||
provider: 'xai-oauth',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user