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:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7017 additions and 1838 deletions
+11 -11
View File
@@ -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()
+2 -2
View File
@@ -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) },
+1 -1
View File
@@ -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({})
})
})
+4 -3
View File
@@ -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 () => {
+15
View File
@@ -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()
})
})
+17
View File
@@ -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)
})
})
-12
View File
@@ -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')
}
+74 -14
View File
@@ -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),
)
})
})
+23
View File
@@ -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)
+5 -8
View File
@@ -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')
})
})
+27 -4
View File
@@ -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' }] }
+64
View File
@@ -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')
})
})
+2
View File
@@ -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 }),
}),
)
+34
View File
@@ -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',
})
})
})