feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
@@ -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',
})
})
})
+194
View File
@@ -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
`)
})
})
+215
View File
@@ -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' })
})
})
})
+56
View File
@@ -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)
})
})
+315
View File
@@ -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.'])
})
})
+180
View File
@@ -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')
})
})
+680
View File
@@ -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'])
})
})
+35
View File
@@ -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)
})
})
+502
View File
@@ -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)
})
})
+587
View File
@@ -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')
})
})
+431
View File
@@ -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,
})
})
})
+263
View File
@@ -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')
})
})
+139
View File
@@ -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')
})
})
+364
View File
@@ -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('过滤掉噪音 IDaccounts/、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()
// 账号 Atoken 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')
})
})
+116
View File
@@ -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()
})
})
+38
View File
@@ -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()
})
})
+85
View File
@@ -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',
})
})
})
+86
View File
@@ -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')
})
})
+189
View File
@@ -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()
})
})
+319
View File
@@ -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')
})
})
+103
View File
@@ -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()
})
})
+269
View File
@@ -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()
})
})
+61
View File
@@ -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))
})
})
+98
View File
@@ -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')
})
})
+102
View File
@@ -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 }),
})
})
})
+109
View File
@@ -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()
})
})
+127
View File
@@ -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),
)
})
})
+539
View File
@@ -0,0 +1,539 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockReadFile = vi.hoisted(() => vi.fn())
const mockListBoards = vi.hoisted(() => vi.fn())
const mockCreateBoard = vi.hoisted(() => vi.fn())
const mockArchiveBoard = vi.hoisted(() => vi.fn())
const mockGetCapabilities = vi.hoisted(() => vi.fn())
const mockListTasks = vi.hoisted(() => vi.fn())
const mockGetTask = vi.hoisted(() => vi.fn())
const mockCreateTask = 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 mockAddComment = vi.hoisted(() => vi.fn())
const mockLinkTasks = vi.hoisted(() => vi.fn())
const mockUnlinkTasks = vi.hoisted(() => vi.fn())
const mockBulkUpdateTasks = vi.hoisted(() => vi.fn())
const mockGetTaskLog = vi.hoisted(() => vi.fn())
const mockGetDiagnostics = vi.hoisted(() => vi.fn())
const mockReclaimTask = vi.hoisted(() => vi.fn())
const mockReassignTask = vi.hoisted(() => vi.fn())
const mockSpecifyTask = vi.hoisted(() => vi.fn())
const mockDispatch = vi.hoisted(() => vi.fn())
const mockGetStats = vi.hoisted(() => vi.fn())
const mockGetAssignees = vi.hoisted(() => vi.fn())
const mockSearchSessions = vi.hoisted(() => vi.fn())
const mockGetSessionDetail = vi.hoisted(() => vi.fn())
const mockGetExactSessionDetail = vi.hoisted(() => vi.fn())
const mockFindLatestExactSessionId = vi.hoisted(() => vi.fn())
const mockListUserProfiles = vi.hoisted(() => vi.fn())
vi.mock('fs/promises', () => ({
readFile: mockReadFile,
}))
vi.mock('os', () => ({
homedir: () => '/Users/tester',
}))
vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
normalizeBoardSlug: (board?: string | null) => {
const value = board?.trim().toLowerCase() || 'default'
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error('Invalid kanban board slug')
return value
},
listBoards: mockListBoards,
createBoard: mockCreateBoard,
archiveBoard: mockArchiveBoard,
getCapabilities: mockGetCapabilities,
listTasks: mockListTasks,
getTask: mockGetTask,
createTask: mockCreateTask,
completeTasks: mockCompleteTasks,
blockTask: mockBlockTask,
unblockTasks: mockUnblockTasks,
assignTask: mockAssignTask,
addComment: mockAddComment,
linkTasks: mockLinkTasks,
unlinkTasks: mockUnlinkTasks,
bulkUpdateTasks: mockBulkUpdateTasks,
getTaskLog: mockGetTaskLog,
getDiagnostics: mockGetDiagnostics,
reclaimTask: mockReclaimTask,
reassignTask: mockReassignTask,
specifyTask: mockSpecifyTask,
dispatch: mockDispatch,
getStats: mockGetStats,
getAssignees: mockGetAssignees,
}))
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
searchSessionSummariesWithProfile: mockSearchSessions,
getSessionDetailFromDbWithProfile: mockGetSessionDetail,
getExactSessionDetailFromDbWithProfile: mockGetExactSessionDetail,
findLatestExactSessionIdWithProfile: mockFindLatestExactSessionId,
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
listUserProfiles: mockListUserProfiles,
}))
import * as ctrl from '../../packages/server/src/controllers/hermes/kanban'
function ctx(overrides: Record<string, any> = {}) {
return {
query: {},
params: {},
request: { body: {} },
status: 200,
body: null,
...overrides,
} as any
}
describe('kanban controller', () => {
beforeEach(() => {
vi.clearAllMocks()
mockListUserProfiles.mockReturnValue([{ profile_name: 'research' }])
})
it('lists boards and tasks with explicit/default board context', async () => {
mockListBoards.mockResolvedValue([{ slug: 'default' }])
mockListTasks.mockResolvedValue([{ id: 'task-1' }])
const boardsCtx = ctx({ query: { includeArchived: 'true' } })
await ctrl.listBoards(boardsCtx)
expect(mockListBoards).toHaveBeenCalledWith({ includeArchived: true })
expect(boardsCtx.body).toEqual({ boards: [{ slug: 'default' }] })
const c = ctx({ query: { board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: 'true' } })
await ctrl.list(c)
expect(mockListTasks).toHaveBeenCalledWith({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: true })
expect(c.body).toEqual({ tasks: [{ id: 'task-1' }] })
mockCreateBoard.mockResolvedValue({ slug: 'project-b' })
const createBoardCtx = ctx({ request: { body: { slug: 'project-b', name: 'Project B', switchCurrent: false } } })
await ctrl.createBoard(createBoardCtx)
expect(mockCreateBoard).toHaveBeenCalledWith({ slug: 'project-b', name: 'Project B', description: undefined, icon: undefined, color: undefined, switchCurrent: false })
expect(createBoardCtx.body).toEqual({ board: { slug: 'project-b' } })
mockArchiveBoard.mockResolvedValue(undefined)
const archiveCtx = ctx({ params: { slug: 'project-b' } })
await ctrl.archiveBoard(archiveCtx)
expect(mockArchiveBoard).toHaveBeenCalledWith('project-b')
expect(archiveCtx.body).toEqual({ ok: true })
mockGetCapabilities.mockResolvedValue({ source: 'hermes-cli', supports: {}, missing: [] })
const capabilitiesCtx = ctx()
await ctrl.capabilities(capabilitiesCtx)
expect(capabilitiesCtx.body).toEqual({ capabilities: { source: 'hermes-cli', supports: {}, missing: [] } })
const defaultCtx = ctx({ query: { status: 'ready' } })
await ctrl.list(defaultCtx)
expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false })
})
it('filters kanban tasks, stats, and assignees to the user-bound profiles', async () => {
const tasks = [
{ id: 'task-1', assignee: 'research', status: 'todo' },
{ id: 'task-2', assignee: 'travel', status: 'done' },
{ id: 'task-3', assignee: null, status: 'blocked' },
]
mockListTasks.mockResolvedValue(tasks)
mockGetAssignees.mockResolvedValue([
{ name: 'research', on_disk: true, counts: { todo: 1 } },
{ name: 'travel', on_disk: true, counts: { done: 1 } },
{ name: 'default', on_disk: true, counts: { blocked: 1 } },
])
const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } }
const listCtx = ctx({ state, query: { board: 'default', includeArchived: 'true' } })
await ctrl.list(listCtx)
expect(listCtx.body).toEqual({ tasks: [tasks[0]] })
const statsCtx = ctx({ state, query: { board: 'default' } })
await ctrl.stats(statsCtx)
expect(statsCtx.body).toEqual({ stats: { by_status: { todo: 1 }, by_assignee: { research: 1 }, total: 1 } })
const assigneesCtx = ctx({ state, query: { board: 'default' } })
await ctrl.assignees(assigneesCtx)
expect(assigneesCtx.body).toEqual({ assignees: [{ name: 'research', on_disk: true, counts: { todo: 1 } }] })
})
it('loads kanban data for every profile bound to the user instead of only the active header profile', async () => {
mockListUserProfiles.mockReturnValue([{ profile_name: 'research' }, { profile_name: 'travel' }])
const tasks = [
{ id: 'task-1', assignee: 'research', status: 'todo' },
{ id: 'task-2', assignee: 'travel', status: 'done' },
{ id: 'task-3', assignee: 'default', status: 'blocked' },
]
mockListTasks.mockResolvedValue(tasks)
mockGetAssignees.mockResolvedValue([
{ name: 'research', on_disk: true, counts: { todo: 1 } },
])
const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } }
const listCtx = ctx({ state, query: { board: 'default', includeArchived: 'true' } })
await ctrl.list(listCtx)
expect(listCtx.body).toEqual({ tasks: [tasks[0], tasks[1]] })
const statsCtx = ctx({ state, query: { board: 'default' } })
await ctrl.stats(statsCtx)
expect(statsCtx.body).toEqual({
stats: {
by_status: { todo: 1, done: 1 },
by_assignee: { research: 1, travel: 1 },
total: 2,
},
})
const assigneesCtx = ctx({ state, query: { board: 'default' } })
await ctrl.assignees(assigneesCtx)
expect(assigneesCtx.body).toEqual({
assignees: [
{ name: 'research', on_disk: true, counts: { todo: 1 } },
{ name: 'travel', on_disk: true, counts: null },
],
})
})
it('defaults created kanban tasks to the requested profile and rejects unauthorized assignees', async () => {
mockCreateTask.mockResolvedValue({ id: 'task-1', assignee: 'research' })
const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } }
const createCtx = ctx({ state, query: { board: 'default' }, request: { body: { title: 'Ship it' } } })
await ctrl.create(createCtx)
expect(mockCreateTask).toHaveBeenCalledWith('Ship it', { board: 'default', body: undefined, assignee: 'research', priority: undefined, tenant: undefined })
expect(createCtx.body).toEqual({ task: { id: 'task-1', assignee: 'research' } })
const assignCtx = ctx({ state, query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'travel' } } })
await ctrl.assign(assignCtx)
expect(assignCtx.status).toBe(403)
expect(mockAssignTask).not.toHaveBeenCalled()
})
it('proxies comment/log/diagnostics with explicit board context', async () => {
const taskLog = { task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false }
mockAddComment.mockResolvedValue({ ok: true, output: 'commented' })
mockGetTaskLog.mockResolvedValue(taskLog)
mockGetDiagnostics.mockResolvedValue([{ task_id: 'task-1' }])
const commentCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { body: 'needs review', author: 'han' } } })
await ctrl.addComment(commentCtx)
expect(mockAddComment).toHaveBeenCalledWith('task-1', 'needs review', { board: 'project-a', author: 'han' })
expect(commentCtx.body).toEqual({ ok: true, output: 'commented' })
const logCtx = ctx({ query: { board: 'default', tail: '4000' }, params: { id: 'task-1' } })
await ctrl.taskLog(logCtx)
expect(mockGetTaskLog).toHaveBeenCalledWith('task-1', { board: 'default', tail: 4000 })
expect(logCtx.body).toEqual(taskLog)
const diagnosticsCtx = ctx({ query: { board: 'default', task: 'task-1', severity: 'warning' } })
await ctrl.diagnostics(diagnosticsCtx)
expect(mockGetDiagnostics).toHaveBeenCalledWith({ board: 'default', task: 'task-1', severity: 'warning' })
expect(diagnosticsCtx.body).toEqual({ diagnostics: [{ task_id: 'task-1' }] })
})
it('proxies links and bulk actions with explicit board context', async () => {
mockLinkTasks.mockResolvedValue({ ok: true, output: 'linked' })
mockUnlinkTasks.mockResolvedValue({ ok: true, output: 'unlinked' })
mockBulkUpdateTasks.mockResolvedValue({ results: [{ id: 'task-1', ok: true }] })
const linkCtx = ctx({ query: { board: 'project-a' }, request: { body: { parent_id: 'task-1', child_id: 'task-2' } } })
await ctrl.linkTasks(linkCtx)
expect(mockLinkTasks).toHaveBeenCalledWith('task-1', 'task-2', { board: 'project-a' })
expect(linkCtx.body).toEqual({ ok: true, output: 'linked' })
const unlinkCtx = ctx({ query: { board: 'project-a', parent_id: 'task-1', child_id: 'task-2' } })
await ctrl.unlinkTasks(unlinkCtx)
expect(mockUnlinkTasks).toHaveBeenCalledWith('task-1', 'task-2', { board: 'project-a' })
expect(unlinkCtx.body).toEqual({ ok: true, output: 'unlinked' })
const bulkCtx = ctx({ query: { board: 'project-a' }, request: { body: { ids: ['task-1'], status: 'done', assignee: null, summary: 'closed' } } })
await ctrl.bulkUpdateTasks(bulkCtx)
expect(mockBulkUpdateTasks).toHaveBeenCalledWith({ board: 'project-a', ids: ['task-1'], status: 'done', assignee: null, archive: undefined, summary: 'closed', reason: undefined })
expect(bulkCtx.body).toEqual({ results: [{ id: 'task-1', ok: true }] })
})
it('validates canonical parity endpoint inputs before shelling out', async () => {
const invalidTailCtx = ctx({ query: { board: 'default', tail: '0' }, params: { id: 'task-1' } })
await ctrl.taskLog(invalidTailCtx)
expect(invalidTailCtx.status).toBe(400)
expect(mockGetTaskLog).not.toHaveBeenCalled()
const oversizedTailCtx = ctx({ query: { board: 'default', tail: '1000001' }, params: { id: 'task-1' } })
await ctrl.taskLog(oversizedTailCtx)
expect(oversizedTailCtx.status).toBe(400)
expect(mockGetTaskLog).not.toHaveBeenCalled()
const invalidSeverityCtx = ctx({ query: { board: 'default', severity: 'info' } })
await ctrl.diagnostics(invalidSeverityCtx)
expect(invalidSeverityCtx.status).toBe(400)
expect(mockGetDiagnostics).not.toHaveBeenCalled()
const emptyBoardCtx = ctx({ query: { board: ' ' } })
await ctrl.list(emptyBoardCtx)
expect(emptyBoardCtx.status).toBe(400)
expect(mockListTasks).not.toHaveBeenCalled()
const invalidDispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: 'yes', max: -1, failureLimit: 0 } } })
await ctrl.dispatch(invalidDispatchCtx)
expect(invalidDispatchCtx.status).toBe(400)
expect(mockDispatch).not.toHaveBeenCalled()
const oversizedDispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: false, max: 999999999 } } })
await ctrl.dispatch(oversizedDispatchCtx)
expect(oversizedDispatchCtx.status).toBe(400)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('rejects malformed parity action bodies before shelling out', async () => {
const cases: Array<{ name: string; invoke: (c: any) => Promise<void>; context: any; mock: ReturnType<typeof vi.fn> }> = [
{ name: 'comment body object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: {}, author: 'han' } } }), mock: mockAddComment },
{ name: 'comment request body array', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: [] } }), mock: mockAddComment },
{ name: 'comment author object', invoke: ctrl.addComment, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { body: 'ok', author: {} } } }), mock: mockAddComment },
{ name: 'link missing child', invoke: ctrl.linkTasks, context: ctx({ query: { board: 'default' }, request: { body: { parent_id: 'task-1' } } }), mock: mockLinkTasks },
{ name: 'unlink missing parent', invoke: ctrl.unlinkTasks, context: ctx({ query: { board: 'default', child_id: 'task-2' } }), mock: mockUnlinkTasks },
{ name: 'bulk empty ids', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: [], status: 'done' } } }), mock: mockBulkUpdateTasks },
{ name: 'bulk invalid status', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'], status: 'invalid' } } }), mock: mockBulkUpdateTasks },
{ name: 'bulk archive with status', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'], archive: true, status: 'done' } } }), mock: mockBulkUpdateTasks },
{ name: 'bulk no action', invoke: ctrl.bulkUpdateTasks, context: ctx({ query: { board: 'default' }, request: { body: { ids: ['task-1'] } } }), mock: mockBulkUpdateTasks },
{ name: 'reclaim request body string', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 'bad' } }), mock: mockReclaimTask },
{ name: 'reclaim reason array', invoke: ctrl.reclaim, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { reason: [] } } }), mock: mockReclaimTask },
{ name: 'reassign reclaim string', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 'false' } } }), mock: mockReassignTask },
{ name: 'reassign reclaim number', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: 1 } } }), mock: mockReassignTask },
{ name: 'reassign profile number', invoke: ctrl.reassign, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 123 } } }), mock: mockReassignTask },
{ name: 'specify request body number', invoke: ctrl.specify, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: 123 } }), mock: mockSpecifyTask },
{ name: 'specify author object', invoke: ctrl.specify, context: ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { author: {} } } }), mock: mockSpecifyTask },
{ name: 'dispatch request body array', invoke: ctrl.dispatch, context: ctx({ query: { board: 'default' }, request: { body: [] } }), mock: mockDispatch },
]
for (const testCase of cases) {
vi.clearAllMocks()
await testCase.invoke(testCase.context)
expect(testCase.context.status, testCase.name).toBe(400)
expect(testCase.mock, testCase.name).not.toHaveBeenCalled()
}
})
it('proxies recovery and dispatch actions with explicit board context', async () => {
mockReclaimTask.mockResolvedValue({ ok: true, output: 'reclaimed' })
mockReassignTask.mockResolvedValue({ ok: true, output: 'reassigned' })
mockSpecifyTask.mockResolvedValue([{ task_id: 'task-1' }])
mockDispatch.mockResolvedValue({ spawned: 1 })
const reclaimCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { reason: 'stale' } } })
await ctrl.reclaim(reclaimCtx)
expect(mockReclaimTask).toHaveBeenCalledWith('task-1', { board: 'project-a', reason: 'stale' })
const reassignCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { profile: 'bob', reclaim: true, reason: 'handoff' } } })
await ctrl.reassign(reassignCtx)
expect(mockReassignTask).toHaveBeenCalledWith('task-1', 'bob', { board: 'project-a', reclaim: true, reason: 'handoff' })
const specifyCtx = ctx({ query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { author: 'han' } } })
await ctrl.specify(specifyCtx)
expect(mockSpecifyTask).toHaveBeenCalledWith('task-1', { board: 'default', author: 'han' })
expect(specifyCtx.body).toEqual({ results: [{ task_id: 'task-1' }] })
const dispatchCtx = ctx({ query: { board: 'default' }, request: { body: { dryRun: true, max: 2, failureLimit: 3 } } })
await ctrl.dispatch(dispatchCtx)
expect(mockDispatch).toHaveBeenCalledWith({ board: 'default', dryRun: true, max: 2, failureLimit: 3 })
expect(dispatchCtx.body).toEqual({ result: { spawned: 1 } })
})
it('enriches completed task details using the latest run profile', async () => {
mockGetTask.mockResolvedValue({
task: { id: 'task-1', status: 'done' },
runs: [{ profile: 'stale' }, { profile: 'fresh' }],
comments: [],
events: [],
})
mockFindLatestExactSessionId.mockResolvedValue('session-1')
mockGetExactSessionDetail.mockResolvedValue({
title: 'Session one',
source: 'codex',
model: 'gpt-5.5',
started_at: 1,
ended_at: 2,
messages: [],
})
const c = ctx({ params: { id: 'task-1' }, query: { board: 'project-a' } })
await ctrl.get(c)
expect(mockFindLatestExactSessionId).toHaveBeenCalledWith('task-1', 'fresh')
expect(mockGetExactSessionDetail).toHaveBeenCalledWith('session-1', 'fresh')
expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' })
})
it('enriches archived task details using the latest run profile', async () => {
mockGetTask.mockResolvedValue({
task: { id: 'task-archived', status: 'archived' },
runs: [{ profile: 'reviewer' }],
comments: [],
events: [],
})
mockFindLatestExactSessionId.mockResolvedValue('session-archived')
mockGetExactSessionDetail.mockResolvedValue({
title: 'Archived session',
source: 'codex',
model: 'gpt-5.5',
started_at: 1,
ended_at: 2,
messages: [],
})
const c = ctx({ params: { id: 'task-archived' }, query: { board: 'project-a' } })
await ctrl.get(c)
expect(mockFindLatestExactSessionId).toHaveBeenCalledWith('task-archived', 'reviewer')
expect(mockGetExactSessionDetail).toHaveBeenCalledWith('session-archived', 'reviewer')
expect(c.body.session).toMatchObject({ id: 'session-archived', title: 'Archived session' })
})
it('prefers exact kanban-task session matches over later sessions that merely reference the task id', async () => {
mockGetTask.mockResolvedValue({
task: { id: 't_348bfaaf', status: 'done' },
runs: [{ profile: 'default' }],
comments: [],
events: [],
})
mockFindLatestExactSessionId.mockResolvedValue('session_20260508_110903_58e664')
mockGetExactSessionDetail.mockResolvedValue({
title: 'work kanban task t_348bfaaf',
source: 'codex',
model: 'gpt-5.5',
started_at: 1,
ended_at: 2,
messages: [{ id: 'm1', role: 'user', content: 'work kanban task t_348bfaaf', timestamp: 1 }],
})
const c = ctx({ params: { id: 't_348bfaaf' }, query: { board: 'project-a' } })
await ctrl.get(c)
expect(c.body.session).toMatchObject({
id: 'session_20260508_110903_58e664',
title: 'work kanban task t_348bfaaf',
})
expect(c.body.session.messages[0].content).toBe('work kanban task t_348bfaaf')
})
it('validates create/search/readArtifact requests', async () => {
const createCtx = ctx({ request: { body: {} } })
await ctrl.create(createCtx)
expect(createCtx.status).toBe(400)
expect(mockCreateTask).not.toHaveBeenCalled()
const invalidCompleteCtx = ctx({ request: { body: { task_ids: ['task-1', 123] } } })
await ctrl.complete(invalidCompleteCtx)
expect(invalidCompleteCtx.status).toBe(400)
expect(mockCompleteTasks).not.toHaveBeenCalled()
const invalidBlockCtx = ctx({ params: { id: 'task-1' }, request: { body: { reason: [] } } })
await ctrl.block(invalidBlockCtx)
expect(invalidBlockCtx.status).toBe(400)
expect(mockBlockTask).not.toHaveBeenCalled()
const invalidUnblockCtx = ctx({ request: { body: [] } })
await ctrl.unblock(invalidUnblockCtx)
expect(invalidUnblockCtx.status).toBe(400)
expect(mockUnblockTasks).not.toHaveBeenCalled()
const invalidAssignCtx = ctx({ params: { id: 'task-1' }, request: { body: { profile: 123 } } })
await ctrl.assign(invalidAssignCtx)
expect(invalidAssignCtx.status).toBe(400)
expect(mockAssignTask).not.toHaveBeenCalled()
const searchCtx = ctx({ query: { task_id: 'task-1' } })
await ctrl.searchSessions(searchCtx)
expect(searchCtx.status).toBe(400)
const fileCtx = ctx({ query: { path: '/tmp/outside.txt' } })
await ctrl.readArtifact(fileCtx)
expect(fileCtx.status).toBe(403)
})
it('reads workspace artifacts and proxies action routes', async () => {
mockReadFile.mockResolvedValue('artifact-content')
mockCreateTask.mockResolvedValue({ id: 'task-2' })
mockCompleteTasks.mockResolvedValue(undefined)
mockBlockTask.mockResolvedValue(undefined)
mockUnblockTasks.mockResolvedValue(undefined)
mockAssignTask.mockResolvedValue(undefined)
mockGetStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
mockGetAssignees.mockResolvedValue([{ name: 'alice' }])
mockSearchSessions.mockResolvedValue([{ id: 'session-2' }])
mockFindLatestExactSessionId.mockResolvedValue('session-2')
mockGetExactSessionDetail.mockResolvedValue({
id: 'session-2',
source: 'codex',
title: 'Matched session',
preview: 'task-id matched',
model: 'gpt-5.5',
started_at: 100,
ended_at: 101,
last_active: 101,
message_count: 2,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: null,
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
messages: [],
thread_session_count: 1,
})
const fileCtx = ctx({ query: { path: '/Users/tester/.hermes/kanban/workspaces/task/out.txt' } })
await ctrl.readArtifact(fileCtx)
expect(fileCtx.body).toEqual({
content: 'artifact-content',
path: '/Users/tester/.hermes/kanban/workspaces/task/out.txt',
})
const createCtx = ctx({ query: { board: 'project-a' }, request: { body: { title: 'Ship', body: 'x' } } })
await ctrl.create(createCtx)
expect(mockCreateTask).toHaveBeenCalledWith('Ship', { board: 'project-a', body: 'x', assignee: undefined, priority: undefined, tenant: undefined })
expect(createCtx.body).toEqual({ task: { id: 'task-2' } })
const completeCtx = ctx({ query: { board: 'project-a' }, request: { body: { task_ids: ['task-1'], summary: 'done' } } })
await ctrl.complete(completeCtx)
expect(mockCompleteTasks).toHaveBeenCalledWith(['task-1'], 'done', { board: 'project-a' })
const blockCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { reason: 'wait' } } })
await ctrl.block(blockCtx)
expect(mockBlockTask).toHaveBeenCalledWith('task-1', 'wait', { board: 'project-a' })
const unblockCtx = ctx({ query: { board: 'project-a' }, request: { body: { task_ids: ['task-1'] } } })
await ctrl.unblock(unblockCtx)
expect(mockUnblockTasks).toHaveBeenCalledWith(['task-1'], { board: 'project-a' })
const assignCtx = ctx({ query: { board: 'project-a' }, params: { id: 'task-1' }, request: { body: { profile: 'alice' } } })
await ctrl.assign(assignCtx)
expect(mockAssignTask).toHaveBeenCalledWith('task-1', 'alice', { board: 'project-a' })
const statsCtx = ctx({ query: { board: 'project-a' } })
await ctrl.stats(statsCtx)
expect(mockGetStats).toHaveBeenCalledWith({ board: 'project-a' })
expect(statsCtx.body).toEqual({ stats: { total: 1, by_status: {}, by_assignee: {} } })
const assigneesCtx = ctx({ query: { board: 'project-a' } })
await ctrl.assignees(assigneesCtx)
expect(mockGetAssignees).toHaveBeenCalledWith({ board: 'project-a' })
expect(assigneesCtx.body).toEqual({ assignees: [{ name: 'alice' }] })
const searchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice', q: 'custom' } })
await ctrl.searchSessions(searchCtx)
expect(mockSearchSessions).toHaveBeenCalledWith('custom', 'alice', undefined, 10)
const exactSearchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice' } })
await ctrl.searchSessions(exactSearchCtx)
expect(exactSearchCtx.body.results[0]).toMatchObject({ id: 'session-2', title: 'Matched session' })
})
})
+79
View File
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = {
listBoards: vi.fn(async (ctx: any) => { ctx.body = { boards: [] } }),
createBoard: vi.fn(async (ctx: any) => { ctx.body = { board: {} } }),
archiveBoard: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
capabilities: vi.fn(async (ctx: any) => { ctx.body = { capabilities: {} } }),
stats: vi.fn(async (ctx: any) => { ctx.body = { stats: {} } }),
assignees: vi.fn(async (ctx: any) => { ctx.body = { assignees: [] } }),
readArtifact: vi.fn(async (ctx: any) => { ctx.body = { content: 'x' } }),
searchSessions: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
linkTasks: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
unlinkTasks: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
bulkUpdateTasks: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
list: vi.fn(async (ctx: any) => { ctx.body = { tasks: [] } }),
get: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }),
create: vi.fn(async (ctx: any) => { ctx.body = { task: {} } }),
complete: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
unblock: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
block: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
assign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
addComment: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
taskLog: vi.fn(async (ctx: any) => { ctx.body = { log: '' } }),
diagnostics: vi.fn(async (ctx: any) => { ctx.body = { diagnostics: [] } }),
reclaim: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
reassign: vi.fn(async (ctx: any) => { ctx.body = { ok: true } }),
specify: vi.fn(async (ctx: any) => { ctx.body = { results: [] } }),
dispatch: vi.fn(async (ctx: any) => { ctx.body = { result: {} } }),
}
vi.mock('../../packages/server/src/controllers/hermes/kanban', () => handlers)
describe('kanban routes', () => {
beforeEach(() => {
vi.resetModules()
Object.values(handlers).forEach(fn => fn.mockClear())
})
it('registers all kanban routes', async () => {
const { kanbanRoutes } = await import('../../packages/server/src/routes/hermes/kanban')
const paths = kanbanRoutes.stack.map((entry: any) => entry.path)
expect(paths).toEqual(expect.arrayContaining([
'/api/hermes/kanban/boards',
'/api/hermes/kanban/boards/:slug',
'/api/hermes/kanban/capabilities',
'/api/hermes/kanban/stats',
'/api/hermes/kanban/assignees',
'/api/hermes/kanban/diagnostics',
'/api/hermes/kanban/dispatch',
'/api/hermes/kanban/artifact',
'/api/hermes/kanban/search-sessions',
'/api/hermes/kanban/links',
'/api/hermes/kanban/tasks/bulk',
'/api/hermes/kanban',
'/api/hermes/kanban/:id',
'/api/hermes/kanban/complete',
'/api/hermes/kanban/unblock',
'/api/hermes/kanban/:id/block',
'/api/hermes/kanban/:id/assign',
'/api/hermes/kanban/:id/comments',
'/api/hermes/kanban/:id/log',
'/api/hermes/kanban/:id/reclaim',
'/api/hermes/kanban/:id/reassign',
'/api/hermes/kanban/:id/specify',
]))
})
it('delegates search-sessions to the controller', async () => {
const { kanbanRoutes } = await import('../../packages/server/src/routes/hermes/kanban')
const layer = kanbanRoutes.stack.find((entry: any) => entry.path === '/api/hermes/kanban/search-sessions')
const ctx: any = { query: { task_id: 'task-1', profile: 'alice' }, body: null, params: {} }
await layer.stack[0](ctx)
expect(handlers.searchSessions).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [] })
})
})
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
async function loadLimiter() {
vi.resetModules()
vi.doMock('fs/promises', () => ({
readFile: vi.fn().mockRejectedValue(new Error('ENOENT')),
writeFile: vi.fn(),
mkdir: vi.fn(),
}))
vi.doMock('fs', () => ({ writeFileSync: vi.fn() }))
vi.doMock('../../packages/server/src/config', () => ({
config: { appHome: '/tmp/hermes-web-ui-test' },
}))
return import('../../packages/server/src/services/login-limiter')
}
describe('login limiter', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T00:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.doUnmock('fs/promises')
vi.doUnmock('fs')
vi.doUnmock('../../packages/server/src/config')
vi.resetModules()
})
it('locks password login on the tenth failed attempt from the same IP', async () => {
const limiter = await loadLimiter()
const ip = '192.0.2.10'
for (let i = 0; i < 9; i++) {
expect(limiter.checkPassword(ip)).toEqual({ allowed: true })
limiter.recordPasswordFailure(ip)
}
expect(limiter.checkPassword(ip)).toEqual({ allowed: true })
limiter.recordPasswordFailure(ip)
expect(limiter.checkPassword(ip)).toEqual({ allowed: false, status: 429 })
expect(limiter.getLockedIps()).toEqual([
expect.objectContaining({ ip, type: 'password', failures: 10 }),
])
})
it('locks token auth on the tenth failed attempt from the same IP', async () => {
const limiter = await loadLimiter()
const ip = '192.0.2.20'
for (let i = 0; i < 9; i++) {
expect(limiter.checkToken(ip)).toEqual({ allowed: true })
limiter.recordTokenFailure(ip)
}
expect(limiter.checkToken(ip)).toEqual({ allowed: true })
limiter.recordTokenFailure(ip)
expect(limiter.checkToken(ip)).toEqual({ allowed: false, status: 429 })
expect(limiter.getLockedIps()).toEqual([
expect.objectContaining({ ip, type: 'token', failures: 10 }),
])
})
})
+341
View File
@@ -0,0 +1,341 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mocks ──────────────────────────────────────────────────
const mcpListMock = vi.fn()
const mcpAddMock = vi.fn()
const mcpUpdateMock = vi.fn()
const mcpRemoveMock = vi.fn()
const mcpTestMock = vi.fn()
const mcpToolsMock = vi.fn()
const mcpReloadMock = vi.fn()
vi.mock('../../packages/server/src/services/hermes/agent-bridge/client', () => ({
AgentBridgeClient: vi.fn().mockImplementation(() => ({
mcpList: mcpListMock,
mcpAdd: mcpAddMock,
mcpUpdate: mcpUpdateMock,
mcpRemove: mcpRemoveMock,
mcpTest: mcpTestMock,
mcpTools: mcpToolsMock,
mcpReload: mcpReloadMock,
})),
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}))
// ── Helpers ────────────────────────────────────────────────
function createCtx(overrides: Record<string, any> = {}) {
const ctx: any = {
state: { profile: { name: 'test-profile' } },
request: { body: {} },
params: {},
query: {},
status: 200,
body: null,
...overrides,
}
return ctx
}
const SAMPLE_SERVERS_RESPONSE = {
ok: true,
servers: [
{
name: 'github',
transport: 'stdio',
connected: true,
tools: 26,
tools_registered: 3,
tool_names: ['create_repository', 'search_repositories'],
tool_names_registered: ['mcp_github_create_repository', 'mcp_github_search_repositories'],
error: null,
raw_config: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
tools: { include: ['create_repository', 'search_repositories'] },
prompts: null,
resources: null,
enabled: true,
},
tool_details: [
{ name: 'create_repository', description: 'Create a repo' },
{ name: 'search_repositories', description: 'Search repos' },
],
},
],
total_tools: 3,
}
const SAMPLE_TOOLS_RESPONSE = {
ok: true,
results: [
{
server: 'github',
tools: [
{ name: 'create_repository', description: 'Create a repo', input_schema: {} },
{ name: 'search_repositories', description: 'Search repos', input_schema: {} },
],
},
],
}
// ── Tests ──────────────────────────────────────────────────
describe('MCP Controller', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('listServers', () => {
it('returns servers list from bridge', async () => {
mcpListMock.mockResolvedValue(SAMPLE_SERVERS_RESPONSE)
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx()
await listServers(ctx)
expect(ctx.body).toEqual(SAMPLE_SERVERS_RESPONSE)
expect(mcpListMock).toHaveBeenCalledWith('test-profile')
})
it('returns 503 on bridge error', async () => {
mcpListMock.mockRejectedValue(new Error('bridge down'))
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx()
await listServers(ctx)
expect(ctx.status).toBe(503)
expect(ctx.body).toEqual({ error: 'bridge down' })
})
})
describe('addServer', () => {
it('sends name and config to bridge', async () => {
mcpAddMock.mockResolvedValue({ ok: true, name: 'my-server' })
const { addServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ request: { body: { name: 'my-server', config: { command: 'node', args: ['srv.js'] } } } })
await addServer(ctx)
expect(mcpAddMock).toHaveBeenCalledWith('my-server', { command: 'node', args: ['srv.js'] }, 'test-profile')
expect(ctx.body).toEqual({ ok: true, name: 'my-server' })
})
it('returns 400 when name is missing', async () => {
const { addServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ request: { body: { config: { command: 'x' } } } })
await addServer(ctx)
expect(ctx.status).toBe(400)
expect(mcpAddMock).not.toHaveBeenCalled()
})
it('returns 400 when config is missing', async () => {
const { addServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ request: { body: { name: 'x' } } })
await addServer(ctx)
expect(ctx.status).toBe(400)
})
})
describe('updateServer', () => {
it('sends name from params and config to bridge', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { tools: { include: ['a', 'b'] } } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', { tools: { include: ['a', 'b'] } }, 'test-profile')
})
it('returns 400 when config is missing', async () => {
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ params: { name: 'github' }, request: { body: {} } })
await updateServer(ctx)
expect(ctx.status).toBe(400)
})
it('sends tools.include config for include mode', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { command: 'npx', args: ['-y', 'server'], tools: { include: ['read_file', 'write_file'] } } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
command: 'npx',
args: ['-y', 'server'],
tools: { include: ['read_file', 'write_file'] },
}, 'test-profile')
expect(ctx.body).toEqual({ ok: true })
})
it('sends tools.exclude config for exclude mode', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { command: 'npx', args: ['-y', 'server'], tools: { exclude: ['delete_file'] } } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
command: 'npx',
args: ['-y', 'server'],
tools: { exclude: ['delete_file'] },
}, 'test-profile')
expect(ctx.body).toEqual({ ok: true })
})
it('sends config without tools field for all mode', async () => {
mcpUpdateMock.mockResolvedValue({ ok: true })
const { updateServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({
params: { name: 'github' },
request: { body: { config: { command: 'npx', args: ['-y', 'server'] } } },
})
await updateServer(ctx)
expect(mcpUpdateMock).toHaveBeenCalledWith('github', {
command: 'npx',
args: ['-y', 'server'],
}, 'test-profile')
expect(ctx.body).toEqual({ ok: true })
})
})
describe('removeServer', () => {
it('sends name to bridge', async () => {
mcpRemoveMock.mockResolvedValue({ ok: true })
const { removeServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ params: { name: 'github' } })
await removeServer(ctx)
expect(mcpRemoveMock).toHaveBeenCalledWith('github', 'test-profile')
})
})
describe('testServer', () => {
it('returns tool list from bridge', async () => {
mcpTestMock.mockResolvedValue({ ok: true, tools: ['create_repository', 'search_repositories'] })
const { testServer } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ params: { name: 'github' } })
await testServer(ctx)
expect(mcpTestMock).toHaveBeenCalledWith('github', 'test-profile')
expect(ctx.body).toEqual({ ok: true, tools: ['create_repository', 'search_repositories'] })
})
})
describe('listTools', () => {
it('returns tools without server filter', async () => {
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: {} })
await listTools(ctx)
expect(mcpToolsMock).toHaveBeenCalledWith(undefined, 'test-profile', undefined)
expect(ctx.body).toEqual(SAMPLE_TOOLS_RESPONSE)
})
it('passes server filter to bridge', async () => {
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: { server: 'github' } })
await listTools(ctx)
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', undefined)
})
it('passes raw=true to get unfiltered tools', async () => {
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: { server: 'github', raw: '1' } })
await listTools(ctx)
expect(mcpToolsMock).toHaveBeenCalledWith('github', 'test-profile', true)
})
it('returns 503 on bridge error', async () => {
mcpToolsMock.mockRejectedValue(new Error('timeout'))
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx()
await listTools(ctx)
expect(ctx.status).toBe(503)
})
})
describe('reloadMcp', () => {
it('reloads all servers when no filter', async () => {
mcpReloadMock.mockResolvedValue({ ok: true, message: 'MCP servers reloaded' })
const { reloadMcp } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: {} })
await reloadMcp(ctx)
expect(mcpReloadMock).toHaveBeenCalledWith(undefined, 'test-profile')
})
it('reloads specific server', async () => {
mcpReloadMock.mockResolvedValue({ ok: true, message: 'MCP servers reloaded' })
const { reloadMcp } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ query: { server: 'github' } })
await reloadMcp(ctx)
expect(mcpReloadMock).toHaveBeenCalledWith('github', 'test-profile')
})
it('returns 503 on bridge error', async () => {
mcpReloadMock.mockRejectedValue(new Error('reload failed'))
const { reloadMcp } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx()
await reloadMcp(ctx)
expect(ctx.status).toBe(503)
})
})
describe('profile handling', () => {
it('passes undefined profile when ctx.state.profile is missing', async () => {
mcpListMock.mockResolvedValue({ ok: true, servers: [], total_tools: 0 })
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ state: {} })
await listServers(ctx)
expect(mcpListMock).toHaveBeenCalledWith(undefined)
})
it('passes undefined profile when profile.name is empty', async () => {
mcpListMock.mockResolvedValue({ ok: true, servers: [], total_tools: 0 })
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx({ state: { profile: { name: '' } } })
await listServers(ctx)
expect(mcpListMock).toHaveBeenCalledWith(undefined)
})
})
describe('response structure', () => {
it('mcp_list response has all required fields', async () => {
mcpListMock.mockResolvedValue(SAMPLE_SERVERS_RESPONSE)
const { listServers } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx()
await listServers(ctx)
const body = ctx.body as any
expect(body.ok).toBe(true)
expect(body.servers).toBeDefined()
expect(body.total_tools).toBeDefined()
const server = body.servers[0]
expect(server).toHaveProperty('name')
expect(server).toHaveProperty('transport')
expect(server).toHaveProperty('connected')
expect(server).toHaveProperty('tools')
expect(server).toHaveProperty('tools_registered')
expect(server).toHaveProperty('tool_names')
expect(server).toHaveProperty('tool_names_registered')
expect(server).toHaveProperty('raw_config')
expect(server).toHaveProperty('tool_details')
expect(server.raw_config).toHaveProperty('command')
expect(server.raw_config).toHaveProperty('enabled')
})
it('mcp_tools_list response has tools with name/description/schema', async () => {
mcpToolsMock.mockResolvedValue(SAMPLE_TOOLS_RESPONSE)
const { listTools } = await import('../../packages/server/src/controllers/hermes/mcp')
const ctx = createCtx()
await listTools(ctx)
const body = ctx.body as any
expect(body.ok).toBe(true)
expect(body.results).toHaveLength(1)
const tool = body.results[0].tools[0]
expect(tool).toHaveProperty('name')
expect(tool).toHaveProperty('description')
expect(tool).toHaveProperty('input_schema')
})
})
})
+25
View File
@@ -0,0 +1,25 @@
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 { defaultImageOutputPath, 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'))
expect(defaultImageOutputPath('img_123')).toBe(join('/tmp/hermes-web-ui-test-home', 'media', 'img_123.png'))
expect(defaultImageOutputPath('bad/request:id', 1)).toBe(join('/tmp/hermes-web-ui-test-home', 'media', 'bad_request_id-2.png'))
})
})
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
normalizeMessageContentForStorage,
normalizeMessageContentForStorageRole,
} from '../../packages/server/src/db/hermes/message-content'
describe('message content normalization', () => {
it('summarizes multimodal envelopes without persisting base64 images', () => {
const content = {
_multimodal: true,
content: [
{ type: 'text', text: 'Image loaded into context.' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
],
}
const normalized = normalizeMessageContentForStorage(JSON.stringify(content))
expect(normalized).toBe('Image loaded into context.\n[screenshot]')
expect(normalized).not.toContain('data:image/')
expect(normalized).not.toContain('AAAA')
})
it('summarizes OpenAI-style content part arrays', () => {
const normalized = normalizeMessageContentForStorage([
{ type: 'text', text: 'Question: what is shown?' },
{ type: 'input_image', image_url: 'data:image/jpeg;base64,BBBB' },
])
expect(normalized).toBe('Question: what is shown?\n[screenshot]')
})
it('redacts nested data images in non-envelope JSON without dropping other fields', () => {
const normalized = normalizeMessageContentForStorage(JSON.stringify({
output: {
url: 'data:image/png;base64,CCCC',
status: 'ok',
},
}))
expect(JSON.parse(normalized)).toEqual({
output: {
url: '[screenshot]',
status: 'ok',
},
})
})
it('does not parse or rewrite unrelated JSON strings', () => {
const content = '{\n "type": "event",\n "payload": "ok"\n}'
expect(normalizeMessageContentForStorage(content)).toBe(content)
})
it('keeps user-authored image data untouched and only cleans non-user messages', () => {
const content = '{"content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,DDDD"}}]}'
expect(normalizeMessageContentForStorageRole('user', content)).toBe(content)
expect(normalizeMessageContentForStorageRole('tool', content)).not.toContain('data:image/')
})
})
+130
View File
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
mockReadAppConfig: vi.fn(),
mockWriteAppConfig: vi.fn(),
}))
vi.mock('../../packages/server/src/services/app-config', () => ({
readAppConfig: mockReadAppConfig,
writeAppConfig: mockWriteAppConfig,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYaml: vi.fn(),
writeConfigYaml: vi.fn(),
fetchProviderModels: vi.fn(),
buildModelGroups: vi.fn(() => ({ default: '', default_provider: '', groups: [] })),
PROVIDER_ENV_MAP: {},
}))
vi.mock('../../packages/server/src/shared/providers', () => ({
buildProviderModelMap: vi.fn(() => ({})),
PROVIDER_PRESETS: [],
}))
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
getCopilotModelsDetailed: vi.fn(),
resolveCopilotOAuthToken: vi.fn(),
}))
vi.mock('../../packages/server/src/db', () => ({
getDb: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
MODEL_CONTEXT_TABLE: 'model_context',
}))
import { setModelAlias } from '../../packages/server/src/controllers/hermes/models'
describe('model alias controller', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWriteAppConfig.mockResolvedValue({})
})
function createCtx(body: unknown) {
return {
request: { body },
status: 200,
body: undefined as unknown,
}
}
it('saves a trimmed alias in Web UI app config', async () => {
mockReadAppConfig.mockResolvedValue({
modelAliases: {
deepseek: { old: 'Old Alias' },
},
})
const ctx = createCtx({ provider: 'deepseek', model: 'deepseek-v4-flash', alias: ' Flash Alias ' })
await setModelAlias(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
modelAliases: {
deepseek: {
old: 'Old Alias',
'deepseek-v4-flash': 'Flash Alias',
},
},
})
expect(ctx.body).toEqual({
success: true,
model_aliases: {
deepseek: {
old: 'Old Alias',
'deepseek-v4-flash': 'Flash Alias',
},
},
})
})
it('deletes an alias when alias is blank and removes empty provider entries', async () => {
mockReadAppConfig.mockResolvedValue({
modelAliases: {
deepseek: { 'deepseek-v4-flash': 'Flash Alias' },
},
})
const ctx = createCtx({ provider: 'deepseek', model: 'deepseek-v4-flash', alias: ' ' })
await setModelAlias(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({ modelAliases: {} })
expect(ctx.body).toEqual({ success: true, model_aliases: {} })
})
it('rejects missing provider or model', async () => {
const ctx = createCtx({ provider: 'deepseek', alias: 'Alias' })
await setModelAlias(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Invalid provider, model, or alias' })
expect(mockWriteAppConfig).not.toHaveBeenCalled()
})
it('stores inherited Object.prototype names as own alias keys', async () => {
mockReadAppConfig.mockResolvedValue({})
const ctx = createCtx({ provider: 'toString', model: 'valueOf', alias: 'Safe Alias' })
await setModelAlias(ctx)
const written = mockWriteAppConfig.mock.calls[0][0]
expect(written.modelAliases.toString.valueOf).toBe('Safe Alias')
expect(Object.prototype.hasOwnProperty.call(written.modelAliases, 'toString')).toBe(true)
expect(Object.prototype.hasOwnProperty.call(written.modelAliases.toString, 'valueOf')).toBe(true)
})
it('rejects reserved object keys to avoid prototype pollution', async () => {
const ctx = createCtx({ provider: '__proto__', model: 'deepseek-v4-flash', alias: 'Alias' })
await setModelAlias(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Invalid provider or model' })
expect(mockWriteAppConfig).not.toHaveBeenCalled()
expect(({} as Record<string, unknown>).deepseek).toBeUndefined()
})
})
+362
View File
@@ -0,0 +1,362 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
let homeDir = ''
const originalHermesHome = process.env.HERMES_HOME
const originalLocalAppData = process.env.LOCALAPPDATA
const originalAppData = process.env.APPDATA
function hermesPath(...parts: string[]) {
return join(homeDir, '.hermes', ...parts)
}
function writeConfig(content: string) {
mkdirSync(hermesPath(), { recursive: true })
writeFileSync(hermesPath('config.yaml'), content)
}
function writeModelsCache(data: Record<string, unknown>) {
mkdirSync(hermesPath(), { recursive: true })
writeFileSync(hermesPath('models_dev_cache.json'), JSON.stringify(data))
}
async function loadModelContext() {
process.env.HERMES_HOME = hermesPath()
delete process.env.LOCALAPPDATA
delete process.env.APPDATA
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof import('os')>('os')),
homedir: () => homeDir,
}))
// Mock getDb to return null to avoid "database is locked" errors in parallel tests
vi.doMock('../../packages/server/src/db/index', async () => {
const actual = await vi.importActual<typeof import('../../packages/server/src/db/index')>('../../packages/server/src/db/index')
return {
...actual,
getDb: () => null,
}
})
return import('../../packages/server/src/services/hermes/model-context')
}
describe('getModelContextLength', () => {
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'hwui-model-context-'))
})
afterEach(() => {
vi.doUnmock('os')
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = originalHermesHome
if (originalLocalAppData === undefined) delete process.env.LOCALAPPDATA
else process.env.LOCALAPPDATA = originalLocalAppData
if (originalAppData === undefined) delete process.env.APPDATA
else process.env.APPDATA = originalAppData
if (homeDir) rmSync(homeDir, { recursive: true, force: true })
homeDir = ''
})
it('does not borrow a same-named model context from another provider when the configured provider is uncached', async () => {
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n`)
writeModelsCache({
openai: {
models: {
'gpt-5.5': { limit: { context: 1_050_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
it('does not scan other providers when the configured provider exists without that model', async () => {
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n`)
writeModelsCache({
'openai-codex': {
models: {
'gpt-5.4': { limit: { context: 256_000 } },
},
},
openai: {
models: {
'gpt-5.5': { limit: { context: 1_050_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
it('uses the configured provider cache entry when the provider matches', async () => {
writeConfig(`model:\n default: gpt-5.5\n provider: openai\n`)
writeModelsCache({
openai: {
models: {
'gpt-5.5': { limit: { context: 1_050_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_050_000)
})
it('prefers requested provider model context_length over top-level default context_length', async () => {
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n context_length: 272000\n\nproviders:\n qwen:\n name: Qwen\n default_model: qwen3.6-plus\n models:\n qwen3.6-plus:\n context_length: 1048576\n`)
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength({ provider: 'qwen', model: 'qwen3.6-plus' })).toBe(1_048_576)
})
it('uses provider-level context_length when the requested model belongs to that provider', async () => {
writeConfig(`model:\n default: gpt-5.5\n provider: openai-codex\n context_length: 272000\n\nproviders:\n qwen:\n name: Qwen\n default_model: qwen3.6-plus\n models:\n - qwen3.6-plus\n context_length: 1048576\n`)
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength({ provider: 'qwen', model: 'qwen3.6-plus' })).toBe(1_048_576)
})
it('keeps legacy model-name cache lookup when no provider is configured', async () => {
writeConfig(`model:\n default: gpt-5.5\n`)
writeModelsCache({
openai: {
models: {
'gpt-5.5': { limit: { context: 1_050_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_050_000)
})
it('keeps providerless legacy lookup on global exact matches before prefixed suffix matches', async () => {
writeConfig(`model:\n default: gpt-5\n`)
writeModelsCache({
vercel: {
models: {
'openai/gpt-5': { limit: { context: 1_000_000 } },
},
},
openai: {
models: {
'gpt-5': { limit: { context: 400_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(400_000)
})
it('maps WUI provider keys to model-cache provider keys before looking up limits', async () => {
writeConfig(`model:\n default: gemini-3.1-pro-preview\n provider: gemini\n`)
writeModelsCache({
google: {
models: {
'gemini-3.1-pro-preview': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('uses gateway provider aliases with prefixed model names inside the aliased provider only', async () => {
writeConfig(`model:\n default: openai/gpt-5\n provider: ai-gateway\n`)
writeModelsCache({
vercel: {
models: {
'openai/gpt-5': { limit: { context: 1_000_000 } },
},
},
openai: {
models: {
'gpt-5': { limit: { context: 400_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('resolves provider: custom through model.base_url before falling back to the default context length', async () => {
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom\n base_url: https://api.deepseek.com\n`)
writeModelsCache({
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('resolves custom:name providers when the matched custom provider base_url points at a builtin provider', async () => {
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom:deepseek\n\ncustom_providers:\n - name: deepseek\n base_url: https://api.deepseek.com\n model: deepseek-v4-pro\n`)
writeModelsCache({
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('prefers the builtin provider inferred from a matched custom provider base_url over an arbitrary custom provider name', async () => {
writeConfig(`model:\n default: shared-model\n provider: custom:corp-proxy\n\ncustom_providers:\n - name: corp-proxy\n base_url: https://api.deepseek.com\n model: shared-model\n`)
writeModelsCache({
deepseek: {
models: {
'shared-model': { limit: { context: 1_000_000 } },
},
},
openai: {
models: {
'shared-model': { limit: { context: 400_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('does not trust a stale custom:name provider hint without a matching custom provider entry', async () => {
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom:deepseek\n`)
writeModelsCache({
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
it('does not trust custom:name alone when the matched custom provider entry points at an unknown proxy url', async () => {
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom:deepseek\n\ncustom_providers:\n - name: deepseek\n base_url: https://proxy.example.com/v1\n model: deepseek-v4-pro\n`)
writeModelsCache({
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
it('does not fall through to a unique global match after a resolved custom:name provider misses in its scoped cache provider', async () => {
writeConfig(`model:\n default: gpt-5.5\n provider: custom:deepseek\n\ncustom_providers:\n - name: deepseek\n base_url: https://api.deepseek.com\n model: gpt-5.5\n`)
writeModelsCache({
openai: {
models: {
'gpt-5.5': { limit: { context: 400_000 } },
},
},
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
it('allows a unique global model-name fallback for unresolved custom providers', async () => {
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom\n base_url: https://proxy.example.com/v1\n`)
writeModelsCache({
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('still allows the unique global fallback when provider: custom matches a custom provider entry that cannot be mapped to a builtin cache provider', async () => {
writeConfig(`model:\n default: deepseek-v4-pro\n provider: custom\n\ncustom_providers:\n - name: corp-proxy\n base_url: https://proxy.example.com/v1\n model: deepseek-v4-pro\n`)
writeModelsCache({
deepseek: {
models: {
'deepseek-v4-pro': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(1_000_000)
})
it('keeps the unresolved custom-provider fallback strict to exact or case-insensitive model-name matches', async () => {
writeConfig(`model:\n default: gpt-5\n provider: custom\n base_url: https://proxy.example.com/v1\n`)
writeModelsCache({
vercel: {
models: {
'openai/gpt-5': { limit: { context: 1_000_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
it('does not guess across multiple cache providers when a custom provider remains unresolved', async () => {
writeConfig(`model:\n default: shared-model\n provider: custom\n base_url: https://proxy.example.com/v1\n`)
writeModelsCache({
deepseek: {
models: {
'shared-model': { limit: { context: 1_000_000 } },
},
},
openai: {
models: {
'shared-model': { limit: { context: 400_000 } },
},
},
})
const { getModelContextLength } = await loadModelContext()
expect(getModelContextLength()).toBe(256_000)
})
})
@@ -0,0 +1,519 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync, mockListProfileNamesFromDisk, mockListUserProfiles } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockReadConfigYaml: vi.fn(),
mockReadConfigYamlForProfile: vi.fn(),
mockFetchProviderModels: vi.fn(),
mockBuildModelGroups: vi.fn(() => ({ default: '', groups: [] })),
mockReadAppConfig: vi.fn(),
mockWriteAppConfig: vi.fn(),
mockExistsSync: vi.fn(() => false),
mockReadFileSync: vi.fn(),
mockListProfileNamesFromDisk: vi.fn(() => ['default']),
mockListUserProfiles: vi.fn(() => []),
}))
vi.mock('fs/promises', () => ({
readFile: mockReadFile,
}))
vi.mock('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
}))
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: mockListProfileNamesFromDisk,
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
listUserProfiles: mockListUserProfiles,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYaml: mockReadConfigYaml,
readConfigYamlForProfile: mockReadConfigYamlForProfile,
writeConfigYaml: vi.fn(),
fetchProviderModels: mockFetchProviderModels,
buildModelGroups: mockBuildModelGroups,
PROVIDER_ENV_MAP: {
'fun-codex': { api_key_env: '', base_url_env: '' },
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' },
'xai-oauth': { api_key_env: '', base_url_env: '' },
openrouter: {},
},
}))
vi.mock('../../packages/server/src/shared/providers', () => ({
buildProviderModelMap: () => ({
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
'xai-oauth': ['grok-4.3', 'grok-4.20-0309-reasoning'],
openrouter: ['openrouter/auto'],
}),
PROVIDER_PRESETS: [
{
value: 'fun-codex',
label: 'Codex-apikey.fun',
base_url: 'https://api.apikey.fun/v1',
models: ['gpt-5.5'],
builtin: true,
},
{
value: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat', 'deepseek-reasoner'],
builtin: true,
},
{
value: 'openrouter',
label: 'OpenRouter',
base_url: 'https://openrouter.ai/api/v1',
models: ['openrouter/auto'],
builtin: true,
},
{
value: 'lmstudio',
label: 'LM Studio',
base_url: 'http://127.0.0.1:1234/v1',
models: [],
builtin: true,
},
{
value: 'xai-oauth',
label: 'xAI Grok OAuth (SuperGrok Subscription)',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
builtin: true,
},
],
}))
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
getCopilotModelsDetailed: vi.fn(async () => []),
resolveCopilotOAuthToken: vi.fn(async () => ''),
}))
vi.mock('../../packages/server/src/services/app-config', () => ({
readAppConfig: mockReadAppConfig,
writeAppConfig: mockWriteAppConfig,
}))
vi.mock('../../packages/server/src/db', () => ({
getDb: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
MODEL_CONTEXT_TABLE: 'model_context',
}))
import * as ctrl from '../../packages/server/src/controllers/hermes/models'
function makeCtx(body: Record<string, unknown> = {}): any {
return { params: {}, query: {}, request: { body }, body: undefined, status: 200 }
}
beforeEach(() => {
vi.clearAllMocks()
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
mockFetchProviderModels.mockResolvedValue([])
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)
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReturnValue('{}')
mockListProfileNamesFromDisk.mockReturnValue(['default'])
mockListUserProfiles.mockReturnValue([])
})
describe('models controller — model visibility', () => {
it('filters available models per provider without changing canonical IDs', async () => {
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toHaveLength(1)
expect(ctx.body.groups[0]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-reasoner'],
available_models: ['deepseek-chat', 'deepseek-reasoner'],
})
expect(ctx.body.default).toBe('deepseek-reasoner')
expect(ctx.body.default_provider).toBe('deepseek')
expect(ctx.body.model_visibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
})
})
it('merges Web UI custom models into available provider groups', async () => {
mockReadAppConfig.mockResolvedValue({
customModels: {
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
},
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups[0]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'],
available_models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'],
})
expect(ctx.body.custom_models).toEqual({
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
})
})
it('limits the default available-models response to profiles bound to regular admins', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default', 'research', 'private'])
mockListUserProfiles.mockReturnValue([
{ user_id: 7, profile_name: 'research', is_default: 1, created_at: 1 },
])
mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => ({
model: {
default: `${profile}-model`,
provider: 'deepseek',
},
}))
const ctx = makeCtx()
ctx.state = { user: { id: 7, username: 'ops', role: 'admin' } }
ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : '')
await ctrl.getAvailable(ctx)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research')
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research'])
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: 'deepseek' }),
]))
})
it('uses the requested profile for aggregate response defaults', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default', 'tester'])
mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => ({
model: {
default: profile === 'tester' ? 'deepseek-reasoner' : 'deepseek-chat',
provider: 'deepseek',
},
}))
const ctx = makeCtx()
ctx.state = { user: { id: 1, username: 'admin', role: 'super_admin' } }
ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'tester' : '')
await ctrl.getAvailable(ctx)
expect(ctx.body.default).toBe('deepseek-reasoner')
expect(ctx.body.default_provider).toBe('deepseek')
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['default', 'tester'])
})
it('uses explicit query profile for single-profile model fetches', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default', 'research'])
const ctx = makeCtx()
ctx.query = { profile: 'research' }
ctx.state = { profile: { name: 'default' }, user: { id: 1, username: 'admin', role: 'super_admin' } }
await ctrl.getAvailable(ctx)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research')
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research'])
})
it('accepts OAuth providers stored in credential_pool entries', async () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue(JSON.stringify({
credential_pool: {
openrouter: [{ label: 'primary', access_token: 'oauth-token' }],
},
}))
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'openrouter',
label: 'OpenRouter',
models: ['openrouter/auto'],
available_models: ['openrouter/auto'],
}),
]))
})
it('shows xAI Grok OAuth when SuperGrok credentials exist in auth.json', async () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue(JSON.stringify({
providers: {
'xai-oauth': {
tokens: { access_token: 'xai-token' },
},
},
}))
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'xai-oauth',
label: 'xAI Grok OAuth (SuperGrok Subscription)',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
}),
]))
})
it('marks allProviders with base URL env support for editable preset URLs', async () => {
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.body.allProviders).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'deepseek',
builtin: true,
base_url_env: 'DEEPSEEK_BASE_URL',
}),
expect.not.objectContaining({
provider: 'xai-oauth',
base_url_env: expect.any(String),
}),
]))
})
it('marks custom-prefixed providers as builtin when their provider key matches a preset', async () => {
mockReadConfigYamlForProfile.mockResolvedValue({
model: { default: 'gpt-5.5', provider: 'custom:fun-codex' },
custom_providers: [
{
name: 'fun-codex',
base_url: 'https://proxy.example.com/v1',
model: 'gpt-5.5',
api_key: 'sk-test',
},
],
})
const ctx = makeCtx()
ctx.query = { profile: 'default' }
await ctrl.getAvailable(ctx)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'custom:fun-codex',
builtin: true,
}),
]))
})
it('returns LM Studio configured default model when env credentials exist and catalog is empty', async () => {
mockReadFile.mockResolvedValue('LM_API_KEY=local\nLM_BASE_URL=http://127.0.0.1:1234/v1\n')
mockReadConfigYaml.mockResolvedValue({ model: { default: 'eee', provider: 'lmstudio' } })
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'eee', provider: 'lmstudio' } })
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'lmstudio',
label: 'LM Studio',
base_url: 'http://127.0.0.1:1234/v1',
models: ['eee'],
available_models: ['eee'],
}),
]))
expect(ctx.body.default).toBe('eee')
expect(ctx.body.default_provider).toBe('lmstudio')
})
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
deepseek: { mode: 'include', models: ['missing-model'] },
},
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.body.groups[0]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-chat', 'deepseek-reasoner'],
available_models: ['deepseek-chat', 'deepseek-reasoner'],
})
})
it('applies visibility to the config fallback path when no credentialed providers are active', async () => {
mockReadFile.mockResolvedValue('')
mockReadConfigYaml.mockResolvedValue({
model: { default: 'custom-a' },
custom_providers: [
{ name: 'local', model: 'custom-a' },
{ name: 'local', model: 'custom-b' },
],
})
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
Custom: { mode: 'include', models: ['custom-b'] },
},
})
mockBuildModelGroups.mockReturnValue({
default: 'custom-a',
groups: [
{
provider: 'Custom',
models: [
{ id: 'custom-a', label: 'local: custom-a' },
{ id: 'custom-b', label: 'local: custom-b' },
],
},
],
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.body.groups).toEqual([
expect.objectContaining({
provider: 'Custom',
models: ['custom-b'],
available_models: ['custom-a', 'custom-b'],
}),
])
expect(ctx.body.default).toBe('custom-b')
expect(ctx.body.default_provider).toBe('Custom')
})
it('saves include visibility in web-ui app config only', async () => {
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
mockWriteAppConfig.mockResolvedValue({
copilotEnabled: true,
modelVisibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
})
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: ['deepseek-chat', 'deepseek-chat', ''] })
await ctrl.setModelVisibility(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
modelVisibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
})
expect(ctx.body).toEqual({
success: true,
model_visibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
})
})
it('resets a provider to all models by deleting its web-ui visibility rule', async () => {
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
deepseek: { mode: 'include', models: ['deepseek-chat'] },
openrouter: { mode: 'include', models: ['x'] },
},
})
mockWriteAppConfig.mockResolvedValue({
modelVisibility: {
openrouter: { mode: 'include', models: ['x'] },
},
})
const ctx = makeCtx({ provider: 'deepseek', mode: 'all', models: [] })
await ctrl.setModelVisibility(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
modelVisibility: {
openrouter: { mode: 'include', models: ['x'] },
},
})
expect(ctx.body.model_visibility).toEqual({
openrouter: { mode: 'include', models: ['x'] },
})
})
it('adds and removes custom models in web-ui app config only', async () => {
mockReadAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing'] },
})
mockWriteAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing', 'manual-model'] },
})
const addCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' })
await ctrl.addCustomModel(addCtx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
customModels: { deepseek: ['existing', 'manual-model'] },
})
expect(addCtx.body).toEqual({
success: true,
custom_models: { deepseek: ['existing', 'manual-model'] },
})
mockReadAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing', 'manual-model'] },
})
mockWriteAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing'] },
})
const removeCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' })
await ctrl.removeCustomModel(removeCtx)
expect(mockWriteAppConfig).toHaveBeenLastCalledWith({
customModels: { deepseek: ['existing'] },
})
expect(removeCtx.body).toEqual({
success: true,
custom_models: { deepseek: ['existing'] },
})
})
it('removes custom models from query params when DELETE body is missing', async () => {
mockReadAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['manual-model'] },
})
mockWriteAppConfig.mockResolvedValueOnce({
customModels: {},
})
const ctx = makeCtx()
ctx.request.body = undefined
ctx.query = { provider: 'deepseek', model: 'manual-model' }
await ctrl.removeCustomModel(ctx)
expect(ctx.status).toBe(200)
expect(mockWriteAppConfig).toHaveBeenCalledWith({ customModels: {} })
expect(ctx.body).toEqual({ success: true, custom_models: {} })
})
it('rejects empty include lists', async () => {
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: [] })
await ctrl.setModelVisibility(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Select at least one model' })
expect(mockWriteAppConfig).not.toHaveBeenCalled()
})
})
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
let hermesHome = ''
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: { name: profile } },
get: () => '',
body: undefined,
status: 200,
}
}
async function loadNousAuthController() {
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/nous-auth')
}
describe('Nous auth controller', () => {
beforeEach(() => {
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-nous-auth-'))
process.env.HERMES_HOME = hermesHome
})
afterEach(() => {
vi.doUnmock('../../packages/server/src/services/logger')
delete process.env.HERMES_HOME
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
it('persists OAuth credentials in the request-scoped profile only', async () => {
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
const { saveNousOAuthTokensForProfile } = await loadNousAuthController()
saveNousOAuthTokensForProfile(
'research',
{
access_token: 'research-access-token',
refresh_token: 'research-refresh-token',
expires_in: 3600,
inference_base_url: 'https://inference-api.nousresearch.com/v1',
},
'research-agent-key',
'2026-06-02T01:00:00.000Z',
)
expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false)
const auth = readAuthJson('profiles/research/auth.json')
expect(auth.providers.nous.access_token).toBe('research-access-token')
expect(auth.providers.nous.agent_key).toBe('research-agent-key')
expect(auth.credential_pool.nous[0].refresh_token).toBe('research-refresh-token')
})
it('checks Nous auth status against the request-scoped profile', async () => {
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
const { saveNousOAuthTokensForProfile, status } = await loadNousAuthController()
saveNousOAuthTokensForProfile('research', {
access_token: 'research-access-token',
refresh_token: 'research-refresh-token',
})
const ctx = makeCtx('research')
await status(ctx)
expect(ctx.body).toEqual({ authenticated: true })
})
})
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const getOpsRuntimeSnapshot = vi.fn()
vi.mock('../../packages/server/src/services/hermes/ops-monitor', () => ({
createEmptyOpsRuntimeSnapshot: (error?: string) => ({ timestamp: 0, error }),
getOpsRuntimeSnapshot,
}))
describe('performance monitor controller', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('returns the runtime snapshot from the performance service', async () => {
const snapshot = {
timestamp: 1,
bridge: { workers: [] },
sessions: { active: 0 },
}
getOpsRuntimeSnapshot.mockResolvedValue(snapshot)
const ctx: any = {}
const { runtime } = await import('../../packages/server/src/controllers/hermes/performance-monitor')
await runtime(ctx)
expect(ctx.body).toBe(snapshot)
})
it('returns a zero snapshot when metrics collection fails', async () => {
getOpsRuntimeSnapshot.mockRejectedValue(new Error('boom'))
const ctx: any = {}
const { runtime } = await import('../../packages/server/src/controllers/hermes/performance-monitor')
await runtime(ctx)
expect(ctx.status).toBeUndefined()
expect(ctx.body).toEqual({ timestamp: 0, error: 'boom' })
})
it('requires super admin on the runtime route', async () => {
const { performanceMonitorRoutes } = await import('../../packages/server/src/routes/hermes/performance-monitor')
const layer = performanceMonitorRoutes.stack.find((entry: any) => entry.path === '/api/hermes/performance/runtime')
expect(layer).toBeTruthy()
const deniedCtx: any = { state: { user: { role: 'admin' } }, status: 200, body: null }
const deniedNext = vi.fn(async () => {})
await layer.stack[0](deniedCtx, deniedNext)
expect(deniedCtx.status).toBe(403)
expect(deniedNext).not.toHaveBeenCalled()
const allowedCtx: any = { state: { user: { role: 'super_admin' } }, status: 200, body: null }
const allowedNext = vi.fn(async () => {})
await layer.stack[0](allowedCtx, allowedNext)
expect(allowedNext).toHaveBeenCalledOnce()
})
})
+34
View File
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const listMock = vi.fn(async (ctx: any) => {
ctx.body = { plugins: [], warnings: [], metadata: {} }
})
vi.mock('../../packages/server/src/controllers/hermes/plugins', () => ({
list: listMock,
}))
describe('plugin routes', () => {
beforeEach(() => {
vi.resetModules()
listMock.mockClear()
})
it('registers the plugins inventory route', async () => {
const { pluginRoutes } = await import('../../packages/server/src/routes/hermes/plugins')
const paths = pluginRoutes.stack.map((entry: any) => entry.path)
expect(paths).toEqual(expect.arrayContaining(['/api/hermes/plugins']))
})
it('delegates plugin listing to the controller', async () => {
const { pluginRoutes } = await import('../../packages/server/src/routes/hermes/plugins')
const layer = pluginRoutes.stack.find((entry: any) => entry.path === '/api/hermes/plugins')
const ctx: any = { body: null, params: {}, query: {} }
await layer.stack[0](ctx)
expect(listMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ plugins: [], warnings: [], metadata: {} })
})
})
+205
View File
@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
isExclusivePlatformKey,
stripExclusivePlatformCredentials,
disableExclusivePlatformsInConfig,
EXCLUSIVE_PLATFORMS,
EXCLUSIVE_PLATFORM_ENV_PATTERNS,
} from '../../packages/server/src/services/hermes/profile-credentials'
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'profile-cred-test-'))
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
describe('isExclusivePlatformKey', () => {
it('matches all known exclusive platform prefixes (aligned with hermes-agent gateway/platforms)', () => {
const samples = [
'TELEGRAM_BOT_TOKEN',
'DISCORD_BOT_TOKEN',
'SLACK_APP_TOKEN',
'WHATSAPP_PHONE_NUMBER_ID',
'SIGNAL_PHONE_NUMBER',
'WEIXIN_TOKEN', 'WEIXIN_ACCOUNT_ID',
'FEISHU_APP_ID',
]
for (const k of samples) {
expect(isExclusivePlatformKey(k)).toBe(true)
}
})
it('does not match removed aliases or non-lock platforms', () => {
// 这些前缀在 hermes-agent gateway/platforms/ 中没有 _acquire_platform_lock 调用
const nonLock = [
'WECHAT_APP_ID', // wechat 不是上游 platform key(实际是 weixin
'LARK_APP_SECRET', // lark 不是上游 platform key(实际是 feishu
'LINE_CHANNEL_SECRET', // line 在 hermes-agent 中没有 adapter
'MATTERMOST_TOKEN', 'MATRIX_TOKEN', 'DINGTALK_TOKEN',
'WECOM_TOKEN', 'QQBOT_TOKEN', 'BLUEBUBBLES_TOKEN',
]
for (const k of nonLock) {
expect(isExclusivePlatformKey(k)).toBe(false)
}
})
it('does not match model provider keys or generic config', () => {
const safe = [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'DEEPSEEK_API_KEY',
'MINIMAX_API_KEY',
'DASHSCOPE_API_KEY',
'BROWSER_HEADLESS',
'TERMINAL_DEFAULT_SHELL',
'HERMES_MAX_ITERATIONS',
'PORT',
'NODE_ENV',
]
for (const k of safe) {
expect(isExclusivePlatformKey(k)).toBe(false)
}
})
})
describe('stripExclusivePlatformCredentials', () => {
it('returns empty when file does not exist', () => {
expect(stripExclusivePlatformCredentials(join(tmpDir, 'nope.env'))).toEqual([])
})
it('returns empty and does not write when no exclusive keys present', () => {
const p = join(tmpDir, '.env')
const content = 'OPENAI_API_KEY=sk-xxx\nPORT=8642\n'
writeFileSync(p, content)
expect(stripExclusivePlatformCredentials(p)).toEqual([])
expect(readFileSync(p, 'utf-8')).toBe(content)
// 无备份文件
expect(readdirSync(tmpDir).filter(f => f.startsWith('.env.bak'))).toHaveLength(0)
})
it('strips exclusive credentials, keeps safe ones, and creates a backup', () => {
const p = join(tmpDir, '.env')
writeFileSync(p, [
'# comment',
'OPENAI_API_KEY=sk-xxx',
'WEIXIN_TOKEN=secret-token',
'WEIXIN_ACCOUNT_ID=acct-1',
'TELEGRAM_BOT_TOKEN=tg-token',
'PORT=8642',
'',
].join('\n'))
const removed = stripExclusivePlatformCredentials(p)
expect(removed).toEqual(['WEIXIN_TOKEN', 'WEIXIN_ACCOUNT_ID', 'TELEGRAM_BOT_TOKEN'])
const after = readFileSync(p, 'utf-8')
expect(after).toContain('OPENAI_API_KEY=sk-xxx')
expect(after).toContain('PORT=8642')
expect(after).toContain('# comment')
expect(after).not.toContain('WEIXIN_')
expect(after).not.toContain('TELEGRAM_')
// 备份文件存在且与原始内容一致
const backups = readdirSync(tmpDir).filter(f => f.startsWith('.env.bak'))
expect(backups).toHaveLength(1)
const backupContent = readFileSync(join(tmpDir, backups[0]), 'utf-8')
expect(backupContent).toContain('WEIXIN_TOKEN=secret-token')
})
})
describe('disableExclusivePlatformsInConfig', () => {
it('returns empty when file does not exist', () => {
expect(disableExclusivePlatformsInConfig(join(tmpDir, 'nope.yaml')))
.toEqual({ disabled: [], strippedConfigCredentials: [] })
})
it('returns empty when no exclusive platforms enabled and no embedded credentials', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, 'platforms:\n cli:\n enabled: true\n')
expect(disableExclusivePlatformsInConfig(p))
.toEqual({ disabled: [], strippedConfigCredentials: [] })
expect(readdirSync(tmpDir).filter(f => f.startsWith('config.yaml.bak'))).toHaveLength(0)
})
it('disables enabled exclusive platforms, strips embedded credentials, and backs up', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, [
'platforms:',
' cli:',
' enabled: true',
' weixin:',
' enabled: true',
' token: secret',
' extra:',
' account_id: acct-1',
' app_id: app-1',
' telegram:',
' enabled: true',
' bot_token: tg-token',
' discord:',
' enabled: false',
'',
].join('\n'))
const result = disableExclusivePlatformsInConfig(p)
expect(result.disabled.sort()).toEqual(['telegram', 'weixin'])
// 节点直挂 + extra 子节点的凭据都应该被清掉
expect(result.strippedConfigCredentials.sort()).toEqual([
'telegram.bot_token',
'weixin.extra.account_id',
'weixin.extra.app_id',
'weixin.token',
])
const after = readFileSync(p, 'utf-8')
expect(after).toMatch(/weixin:[\s\S]*?enabled:\s*false/)
expect(after).toMatch(/telegram:[\s\S]*?enabled:\s*false/)
expect(after).toMatch(/cli:[\s\S]*?enabled:\s*true/)
// 凭据已被清除
expect(after).not.toContain('secret')
expect(after).not.toContain('tg-token')
expect(after).not.toContain('acct-1')
const backups = readdirSync(tmpDir).filter(f => f.startsWith('config.yaml.bak'))
expect(backups).toHaveLength(1)
})
it('strips embedded credentials even when platform is already disabled', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, [
'platforms:',
' weixin:',
' enabled: false',
' token: leftover-secret',
'',
].join('\n'))
const result = disableExclusivePlatformsInConfig(p)
expect(result.disabled).toEqual([])
expect(result.strippedConfigCredentials).toEqual(['weixin.token'])
const after = readFileSync(p, 'utf-8')
expect(after).not.toContain('leftover-secret')
})
it('returns empty on malformed yaml without throwing', () => {
const p = join(tmpDir, 'config.yaml')
writeFileSync(p, 'platforms: [unclosed')
expect(disableExclusivePlatformsInConfig(p))
.toEqual({ disabled: [], strippedConfigCredentials: [] })
})
})
describe('EXCLUSIVE_PLATFORMS list', () => {
it('stays in sync with the env pattern list (same length)', () => {
expect(EXCLUSIVE_PLATFORMS.length).toBe(EXCLUSIVE_PLATFORM_ENV_PATTERNS.length)
})
})
+263
View File
@@ -0,0 +1,263 @@
import { existsSync, readFileSync } from 'fs'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const agentBridgeMocks = vi.hoisted(() => ({
destroyAll: vi.fn(),
destroyProfile: vi.fn(),
}))
const skillInjectorMocks = vi.hoisted(() => ({
injectMissingSkills: vi.fn(),
resolveTargetDirForProfile: vi.fn(),
}))
const sessionDeleterMocks = vi.hoisted(() => ({
switchProfile: vi.fn(),
}))
// Mock hermes-cli
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
listProfiles: vi.fn(),
getProfile: vi.fn(),
createProfile: vi.fn(),
deleteProfile: vi.fn(),
renameProfile: vi.fn(),
useProfile: vi.fn(),
stopGateway: vi.fn(),
startGateway: vi.fn(),
startGatewayBackground: vi.fn(),
setupReset: vi.fn(),
exportProfile: vi.fn(),
importProfile: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
AgentBridgeClient: vi.fn(() => ({
destroyAll: agentBridgeMocks.destroyAll,
destroyProfile: agentBridgeMocks.destroyProfile,
})),
}))
vi.mock('../../packages/server/src/services/hermes/skill-injector', () => {
const HermesSkillInjector = vi.fn(() => ({
injectMissingSkills: skillInjectorMocks.injectMissingSkills,
})) as any
HermesSkillInjector.resolveTargetDirForProfile = skillInjectorMocks.resolveTargetDirForProfile
return { HermesSkillInjector }
})
vi.mock('../../packages/server/src/services/hermes/session-deleter', () => ({
SessionDeleter: {
getInstance: vi.fn(() => sessionDeleterMocks),
},
}))
import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
describe('Profile Routes', () => {
const originalHermesHome = process.env.HERMES_HOME
const originalWebUiHome = process.env.HERMES_WEB_UI_HOME
const tempHomes: string[] = []
beforeEach(() => {
vi.clearAllMocks()
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 0 })
skillInjectorMocks.injectMissingSkills.mockResolvedValue({ targets: [] })
skillInjectorMocks.resolveTargetDirForProfile.mockImplementation((name: string) => join('/tmp/hermes-skills', name))
})
afterEach(async () => {
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = originalHermesHome
if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME
else process.env.HERMES_WEB_UI_HOME = originalWebUiHome
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
describe('hermes-cli wrapper', () => {
it('listProfiles returns array', async () => {
const mockProfiles = [{ name: 'default', active: true }]
vi.mocked(hermesCli.listProfiles).mockResolvedValue(mockProfiles as any)
const result = await hermesCli.listProfiles()
expect(result).toEqual(mockProfiles)
})
it('getProfile returns profile detail', async () => {
const mockDetail = { name: 'default', path: '/tmp/default' }
vi.mocked(hermesCli.getProfile).mockResolvedValue(mockDetail as any)
const result = await hermesCli.getProfile('default')
expect(result).toEqual(mockDetail)
expect(hermesCli.getProfile).toHaveBeenCalledWith('default')
})
it('createProfile calls CLI with name and clone flag', async () => {
vi.mocked(hermesCli.createProfile).mockResolvedValue('Profile created')
await hermesCli.createProfile('test', true)
expect(hermesCli.createProfile).toHaveBeenCalledWith('test', true)
})
it('deleteProfile calls CLI with name', async () => {
vi.mocked(hermesCli.deleteProfile).mockResolvedValue(true)
await hermesCli.deleteProfile('test')
expect(hermesCli.deleteProfile).toHaveBeenCalledWith('test')
})
it('renameProfile calls CLI with old and new name', async () => {
vi.mocked(hermesCli.renameProfile).mockResolvedValue(true)
await hermesCli.renameProfile('old', 'new')
expect(hermesCli.renameProfile).toHaveBeenCalledWith('old', 'new')
})
})
describe('profile deletion fallback', () => {
it('removes a reserved profile directory when Hermes CLI refuses to delete it', async () => {
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-delete-'))
tempHomes.push(hermesHome)
process.env.HERMES_HOME = hermesHome
const badProfileDir = join(hermesHome, 'profiles', 'hermes')
await mkdir(badProfileDir, { recursive: true })
await writeFile(join(badProfileDir, 'config.yaml'), 'model:\n default: bad\n', 'utf-8')
await writeFile(join(hermesHome, 'active_profile'), 'hermes\n', 'utf-8')
vi.mocked(hermesCli.deleteProfile).mockResolvedValue(false)
const { remove } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = { params: { name: 'hermes' }, status: 200, body: undefined }
await remove(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({ success: true, fallback: 'removed_reserved_profile_from_disk' })
expect(existsSync(badProfileDir)).toBe(false)
expect(readFileSync(join(hermesHome, 'active_profile'), 'utf-8')).toBe('default\n')
})
it('does not bypass Hermes CLI failures for normal profile names', async () => {
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-delete-'))
tempHomes.push(hermesHome)
process.env.HERMES_HOME = hermesHome
const profileDir = join(hermesHome, 'profiles', 'work')
await mkdir(profileDir, { recursive: true })
vi.mocked(hermesCli.deleteProfile).mockResolvedValue(false)
const { remove } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = { params: { name: 'work' }, status: 200, body: undefined }
await remove(ctx)
expect(ctx.status).toBe(500)
expect(ctx.body).toEqual({ error: 'Failed to delete profile' })
expect(existsSync(profileDir)).toBe(true)
})
})
describe('Hermes CLI active profile switch', () => {
it('only destroys bridge sessions for the target profile', async () => {
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-switch-'))
tempHomes.push(hermesHome)
process.env.HERMES_HOME = hermesHome
const profileDir = join(hermesHome, 'profiles', 'work')
await mkdir(profileDir, { recursive: true })
await writeFile(join(profileDir, 'config.yaml'), 'model:\n default: gpt-test\n', 'utf-8')
await writeFile(join(hermesHome, 'active_profile'), 'work\n', 'utf-8')
vi.mocked(hermesCli.useProfile).mockResolvedValue('Switched to work')
vi.mocked(hermesCli.getProfile).mockResolvedValue({
name: 'work',
path: profileDir,
model: 'gpt-test',
provider: 'test',
skills: 0,
hasEnv: false,
hasSoulMd: false,
} as any)
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 2 })
const { switchProfile } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = {
request: { body: { name: 'work' } },
status: 200,
body: undefined,
}
await switchProfile(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toMatchObject({ success: true, active: 'work' })
expect(agentBridgeMocks.destroyProfile).toHaveBeenCalledWith('work')
expect(agentBridgeMocks.destroyAll).not.toHaveBeenCalled()
expect(sessionDeleterMocks.switchProfile).toHaveBeenCalledWith('work')
})
})
describe('profile avatars', () => {
it('stores generated avatar metadata under the Web UI home', async () => {
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
tempHomes.push(webUiHome)
process.env.HERMES_WEB_UI_HOME = webUiHome
const { updateAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = {
params: { name: 'work' },
request: { body: { type: 'generated', seed: 'custom-seed' } },
status: 200,
body: undefined,
}
await updateAvatar(ctx)
const metaPath = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'), 'avatar.json')
expect(ctx.status).toBe(200)
expect(ctx.body.avatar).toMatchObject({ type: 'generated', seed: 'custom-seed' })
expect(JSON.parse(readFileSync(metaPath, 'utf-8'))).toMatchObject({
type: 'generated',
seed: 'custom-seed',
})
})
it('stores uploaded image avatars and returns a data URL', async () => {
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
tempHomes.push(webUiHome)
process.env.HERMES_WEB_UI_HOME = webUiHome
const dataUrl = `data:image/png;base64,${Buffer.from('avatar-png').toString('base64')}`
const { updateAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = {
params: { name: 'work' },
request: { body: { type: 'image', dataUrl } },
status: 200,
body: undefined,
}
await updateAvatar(ctx)
const dir = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'))
const meta = JSON.parse(readFileSync(join(dir, 'avatar.json'), 'utf-8'))
expect(ctx.status).toBe(200)
expect(ctx.body.avatar).toMatchObject({ type: 'image', dataUrl })
expect(meta).toMatchObject({ type: 'image', file: 'avatar.bin', mime: 'image/png' })
expect(readFileSync(join(dir, 'avatar.bin')).toString()).toBe('avatar-png')
})
it('deletes profile avatar metadata', async () => {
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
tempHomes.push(webUiHome)
process.env.HERMES_WEB_UI_HOME = webUiHome
const metadataDir = join(webUiHome, 'profile-metadata', Buffer.from('work', 'utf-8').toString('base64url'))
await mkdir(metadataDir, { recursive: true })
await writeFile(join(metadataDir, 'avatar.json'), '{"type":"generated"}\n', 'utf-8')
const { deleteAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
const ctx: any = { params: { name: 'work' }, status: 200, body: undefined }
await deleteAvatar(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({ success: true })
expect(existsSync(metadataDir)).toBe(false)
})
})
})
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import YAML from 'js-yaml'
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
restartGateway: vi.fn().mockResolvedValue(undefined),
}))
let hermesHome = ''
async function loadProvidersController() {
vi.resetModules()
process.env.HERMES_HOME = hermesHome
return import('../../packages/server/src/controllers/hermes/providers')
}
function makeCtx(body: Record<string, any>, profile = 'default') {
return {
request: { body },
state: { profile: { name: profile } },
status: 200,
body: undefined as unknown,
}
}
function readYaml(filePath: string) {
return YAML.load(readFileSync(filePath, 'utf-8')) as any
}
describe('providers controller create', () => {
beforeEach(() => {
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-provider-create-'))
mkdirSync(hermesHome, { recursive: true })
writeFileSync(join(hermesHome, 'config.yaml'), 'model: {}\n')
writeFileSync(join(hermesHome, '.env'), '')
})
afterEach(() => {
delete process.env.HERMES_HOME
vi.doUnmock('../../packages/server/src/controllers/hermes/providers')
vi.clearAllMocks()
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
it('does not persist a built-in provider base URL when it matches the preset default', async () => {
const { create } = await loadProvidersController()
const ctx = makeCtx({
name: 'DeepSeek',
base_url: 'https://api.deepseek.com',
api_key: 'deepseek-key',
model: 'deepseek-chat',
providerKey: 'deepseek',
})
await create(ctx)
expect(ctx.body).toEqual({ success: true })
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(envAfter).toContain('DEEPSEEK_API_KEY=deepseek-key')
expect(envAfter).not.toContain('DEEPSEEK_BASE_URL')
})
it('persists a built-in provider base URL when it differs from the preset default', async () => {
const { create } = await loadProvidersController()
const ctx = makeCtx({
name: 'DeepSeek',
base_url: 'https://deepseek-proxy.invalid/v1',
api_key: 'deepseek-key',
model: 'deepseek-chat',
providerKey: 'deepseek',
})
await create(ctx)
expect(ctx.body).toEqual({ success: true })
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(envAfter).toContain('DEEPSEEK_API_KEY=deepseek-key')
expect(envAfter).toContain('DEEPSEEK_BASE_URL=https://deepseek-proxy.invalid/v1')
})
it('creates xAI OAuth as a direct config provider without an API key or custom provider entry', async () => {
const { create } = await loadProvidersController()
const ctx = makeCtx({
name: 'xAI Grok OAuth',
base_url: 'https://api.x.ai/v1',
api_key: '',
model: 'grok-4.3',
providerKey: 'xai-oauth',
})
await create(ctx)
expect(ctx.body).toEqual({ success: true })
const configAfter = readYaml(join(hermesHome, 'config.yaml'))
expect(configAfter.model).toEqual({ default: 'grok-4.3', provider: 'xai-oauth' })
expect(configAfter.custom_providers).toBeUndefined()
expect(readFileSync(join(hermesHome, '.env'), 'utf-8')).toBe('')
})
})
@@ -0,0 +1,261 @@
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'
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
restartGateway: vi.fn().mockResolvedValue(undefined),
}))
let hermesHome = ''
async function loadProvidersController() {
vi.resetModules()
process.env.HERMES_HOME = hermesHome
return import('../../packages/server/src/controllers/hermes/providers')
}
function makeCtx(poolKey: string, overrides: Record<string, any> = {}) {
return {
params: { poolKey: encodeURIComponent(poolKey) },
request: { body: {} },
status: 200,
body: undefined as unknown,
...overrides,
}
}
function readAuth(profileDir = hermesHome) {
return JSON.parse(readFileSync(join(profileDir, 'auth.json'), 'utf-8'))
}
describe('providers controller delete', () => {
beforeEach(() => {
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-provider-delete-'))
mkdirSync(hermesHome, { recursive: true })
writeFileSync(join(hermesHome, 'config.yaml'), 'model:\n provider: openai-codex\n default: gpt-5.5\n')
})
afterEach(() => {
delete process.env.HERMES_HOME
vi.doUnmock('../../packages/server/src/controllers/hermes/providers')
vi.clearAllMocks()
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
it('removes built-in API-key provider credentials from env and auth pool', async () => {
writeFileSync(join(hermesHome, '.env'), [
['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='),
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='),
['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='),
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
providers: {
deepseek: { access_token: 'legacy-token' },
openrouter: { access_token: 'keep-token' },
},
credential_pool: {
deepseek: [{ label: 'DEEPSEEK_API_KEY', source: 'env:DEEPSEEK_API_KEY' }],
openrouter: [{ label: 'OPENROUTER_API_KEY', source: 'env:OPENROUTER_API_KEY' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('deepseek')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(envAfter).not.toContain('DEEPSEEK_API_KEY')
expect(envAfter).not.toContain('DEEPSEEK_BASE_URL')
expect(envAfter).toContain(['OPENROUTER_API_KEY', 'openrouter-placeholder'].join('='))
expect(envAfter).toContain(['OPENROUTER_BASE_URL', 'https://openrouter-proxy.invalid/v1'].join('='))
const authAfter = readAuth()
expect(authAfter.providers).not.toHaveProperty('deepseek')
expect(authAfter.credential_pool).not.toHaveProperty('deepseek')
expect(authAfter.providers.openrouter).toEqual({ access_token: 'keep-token' })
expect(authAfter.credential_pool.openrouter).toEqual([
{ label: 'OPENROUTER_API_KEY', source: 'env:OPENROUTER_API_KEY' },
])
})
it('does not remove unrelated base URL env for a provider without a base URL env mapping', async () => {
writeFileSync(join(hermesHome, '.env'), [
['XAI_BASE_URL', 'https://xai-proxy.invalid/v1'].join('='),
['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='),
'',
].join('\n'))
const { remove } = await loadProvidersController()
const ctx = makeCtx('xai-oauth')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const envAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(envAfter).toContain(['XAI_BASE_URL', 'https://xai-proxy.invalid/v1'].join('='))
expect(envAfter).toContain(['DEEPSEEK_BASE_URL', 'https://deepseek-proxy.invalid/v1'].join('='))
})
it('removes custom provider config and any matching stored auth entry', async () => {
writeFileSync(join(hermesHome, 'config.yaml'), [
'model:',
' provider: openai-codex',
' default: gpt-5.5',
'custom_providers:',
' - name: deepseek-proxy',
' base_url: https://example.invalid/v1',
' api_key: placeholder',
' model: deepseek-chat',
' - name: keep-provider',
' base_url: https://keep.invalid/v1',
' api_key: placeholder',
' model: keep-model',
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
credential_pool: {
'custom:deepseek-proxy': [{ label: 'custom' }],
'custom:keep-provider': [{ label: 'keep' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('custom:deepseek-proxy')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const configAfter = readFileSync(join(hermesHome, 'config.yaml'), 'utf-8')
expect(configAfter).not.toContain('deepseek-proxy')
expect(configAfter).toContain('keep-provider')
const authAfter = readAuth()
expect(authAfter.credential_pool).not.toHaveProperty('custom:deepseek-proxy')
expect(authAfter.credential_pool['custom:keep-provider']).toEqual([{ label: 'keep' }])
})
it('keeps OAuth-style provider deletion clearing stored auth entries', async () => {
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
providers: {
'openai-codex': { account_id: 'remove-me' },
copilot: { account_id: 'keep-me' },
},
credential_pool: {
'openai-codex': [{ label: 'remove-me' }],
copilot: [{ label: 'keep-me' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('openai-codex')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const authAfter = readAuth()
expect(authAfter.providers).not.toHaveProperty('openai-codex')
expect(authAfter.credential_pool).not.toHaveProperty('openai-codex')
expect(authAfter.providers.copilot).toEqual({ account_id: 'keep-me' })
expect(authAfter.credential_pool.copilot).toEqual([{ label: 'keep-me' }])
})
it('does not create auth.json when deleting a provider without stored auth credentials', async () => {
writeFileSync(join(hermesHome, '.env'), [['DEEPSEEK_API_KEY', 'deepseek-placeholder'].join('='), ''].join('\n'))
const { remove } = await loadProvidersController()
const ctx = makeCtx('deepseek')
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false)
})
it('deletes provider state from the request-scoped profile only', async () => {
const researchDir = join(hermesHome, 'profiles', 'research')
mkdirSync(researchDir, { recursive: true })
writeFileSync(join(hermesHome, 'config.yaml'), [
'model:',
' provider: deepseek',
' default: keep-default-model',
'',
].join('\n'))
writeFileSync(join(hermesHome, '.env'), [
['DEEPSEEK_API_KEY', 'keep-default-key'].join('='),
['OPENROUTER_API_KEY', 'keep-default-openrouter'].join('='),
'',
].join('\n'))
writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({
providers: {
deepseek: { access_token: 'keep-default-token' },
},
credential_pool: {
deepseek: [{ label: 'keep-default' }],
},
}, null, 2))
writeFileSync(join(researchDir, 'config.yaml'), [
'model:',
' provider: deepseek',
' default: research-model',
'custom_providers:',
' - name: keep-provider',
' base_url: https://keep.invalid/v1',
' api_key: placeholder',
' model: keep-model',
'',
].join('\n'))
writeFileSync(join(researchDir, '.env'), [
['DEEPSEEK_API_KEY', 'remove-research-key'].join('='),
['OPENROUTER_API_KEY', 'keep-research-openrouter'].join('='),
'',
].join('\n'))
writeFileSync(join(researchDir, 'auth.json'), JSON.stringify({
providers: {
deepseek: { access_token: 'remove-research-token' },
openrouter: { access_token: 'keep-research-token' },
},
credential_pool: {
deepseek: [{ label: 'remove-research' }],
openrouter: [{ label: 'keep-research' }],
},
}, null, 2))
const { remove } = await loadProvidersController()
const ctx = makeCtx('deepseek', { state: { profile: { name: 'research' } } })
await remove(ctx)
expect(ctx.body).toEqual({ success: true })
const defaultEnvAfter = readFileSync(join(hermesHome, '.env'), 'utf-8')
expect(defaultEnvAfter).toContain(['DEEPSEEK_API_KEY', 'keep-default-key'].join('='))
expect(defaultEnvAfter).toContain(['OPENROUTER_API_KEY', 'keep-default-openrouter'].join('='))
expect(readFileSync(join(hermesHome, 'config.yaml'), 'utf-8')).toContain('keep-default-model')
expect(readAuth()).toEqual({
providers: {
deepseek: { access_token: 'keep-default-token' },
},
credential_pool: {
deepseek: [{ label: 'keep-default' }],
},
})
const researchEnvAfter = readFileSync(join(researchDir, '.env'), 'utf-8')
expect(researchEnvAfter).not.toContain('DEEPSEEK_API_KEY')
expect(researchEnvAfter).toContain(['OPENROUTER_API_KEY', 'keep-research-openrouter'].join('='))
const researchConfigAfter = readFileSync(join(researchDir, 'config.yaml'), 'utf-8')
expect(researchConfigAfter).toContain('keep-provider')
expect(researchConfigAfter).toContain('keep-model')
const researchAuthAfter = readAuth(researchDir)
expect(researchAuthAfter.providers).not.toHaveProperty('deepseek')
expect(researchAuthAfter.credential_pool).not.toHaveProperty('deepseek')
expect(researchAuthAfter.providers.openrouter).toEqual({ access_token: 'keep-research-token' })
expect(researchAuthAfter.credential_pool.openrouter).toEqual([{ label: 'keep-research' }])
})
})
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import YAML from 'js-yaml'
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
restartGateway: vi.fn().mockResolvedValue(undefined),
}))
let hermesHome = ''
async function loadProvidersController() {
vi.resetModules()
process.env.HERMES_HOME = hermesHome
return import('../../packages/server/src/controllers/hermes/providers')
}
function makeCtx(poolKey: string, body: Record<string, any>, profile = 'research') {
return {
params: { poolKey: encodeURIComponent(poolKey) },
request: { body },
state: { profile: { name: profile } },
status: 200,
body: undefined as unknown,
}
}
function readYaml(filePath: string) {
return YAML.load(readFileSync(filePath, 'utf-8')) as any
}
describe('providers controller update', () => {
beforeEach(() => {
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-provider-update-'))
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
writeFileSync(join(hermesHome, 'config.yaml'), 'model:\n provider: deepseek\n default: keep-default-model\n')
writeFileSync(join(hermesHome, '.env'), [
'DEEPSEEK_API_KEY=keep-default-key',
'',
].join('\n'))
writeFileSync(join(hermesHome, 'profiles', 'research', 'config.yaml'), [
'model:',
' provider: custom:research-proxy',
' default: research-model',
'custom_providers:',
' - name: research-proxy',
' base_url: https://research.invalid/v1',
' api_key: old-research-custom-key',
' model: research-model',
'',
].join('\n'))
writeFileSync(join(hermesHome, 'profiles', 'research', '.env'), [
'DEEPSEEK_API_KEY=old-research-key',
'',
].join('\n'))
})
afterEach(() => {
delete process.env.HERMES_HOME
vi.doUnmock('../../packages/server/src/controllers/hermes/providers')
vi.clearAllMocks()
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
it('updates built-in provider API keys in the request-scoped profile env only', async () => {
const { update } = await loadProvidersController()
const ctx = makeCtx('deepseek', { api_key: 'new-research-key' })
await update(ctx)
expect(ctx.body).toEqual({ success: true })
expect(readFileSync(join(hermesHome, '.env'), 'utf-8')).toContain('DEEPSEEK_API_KEY=keep-default-key')
expect(readFileSync(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')).toContain('DEEPSEEK_API_KEY=new-research-key')
})
it('updates custom provider API keys in the request-scoped profile config only', async () => {
const defaultConfigPath = join(hermesHome, 'config.yaml')
writeFileSync(defaultConfigPath, [
'model:',
' provider: custom:research-proxy',
' default: default-model',
'custom_providers:',
' - name: research-proxy',
' base_url: https://default.invalid/v1',
' api_key: keep-default-custom-key',
' model: default-model',
'',
].join('\n'))
const { update } = await loadProvidersController()
const ctx = makeCtx('custom:research-proxy', { api_key: 'new-research-custom-key' })
await update(ctx)
expect(ctx.body).toEqual({ success: true })
const defaultConfig = readYaml(defaultConfigPath)
const researchConfig = readYaml(join(hermesHome, 'profiles', 'research', 'config.yaml'))
expect(defaultConfig.custom_providers[0].api_key).toBe('keep-default-custom-key')
expect(researchConfig.custom_providers[0].api_key).toBe('new-research-custom-key')
})
})
+436
View File
@@ -0,0 +1,436 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock updateUsage so we can assert calls without real DB
const { mockUpdateUsage } = vi.hoisted(() => ({
mockUpdateUsage: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
updateUsage: mockUpdateUsage,
}))
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
import { proxy, setGatewayManagerForTest, setRunSession } from '../../packages/server/src/routes/hermes/proxy-handler'
function createMockCtx(overrides: Record<string, any> = {}) {
const ctx: any = {
path: '/api/hermes/jobs',
method: 'GET',
headers: { host: 'localhost:8648', 'content-type': 'application/json' },
query: {},
search: '',
req: { method: 'GET' },
res: {
write: vi.fn(),
end: vi.fn(),
headersSent: false,
writableEnded: false,
},
request: { rawBody: undefined },
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
}
/**
* Helper: create a ReadableStream from string chunks.
* Each chunk is a Uint8Array segment delivered sequentially.
*/
function createSSEBody(events: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
let idx = 0
return new ReadableStream({
pull(controller) {
if (idx < events.length) {
controller.enqueue(encoder.encode(events[idx]))
idx++
} else {
controller.close()
}
},
})
}
describe('Proxy Handler', () => {
beforeEach(() => {
vi.clearAllMocks()
setGatewayManagerForTest({
getUpstream: () => 'http://127.0.0.1:8642',
getApiKey: () => null,
})
})
it('rewrites /api/hermes/v1/* to /v1/*', async () => {
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: null,
json: () => Promise.resolve({ ok: true }),
})
const ctx = createMockCtx({ path: '/api/hermes/v1/runs', search: '' })
await proxy(ctx)
expect(mockFetch).toHaveBeenCalledOnce()
const url = mockFetch.mock.calls[0][0]
expect(url).toContain('/v1/runs')
expect(url).not.toContain('/api/hermes')
})
it('rewrites /api/hermes/* to /api/*', async () => {
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: null,
json: () => Promise.resolve({ ok: true }),
})
const ctx = createMockCtx({ path: '/api/hermes/jobs', search: '' })
await proxy(ctx)
const url = mockFetch.mock.calls[0][0]
expect(url).toContain('/api/jobs')
expect(url).not.toContain('/api/hermes')
})
it('strips authorization header', async () => {
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: null,
json: () => Promise.resolve({}),
})
const ctx = createMockCtx({
headers: { host: 'localhost:8648', authorization: 'Bearer web-ui-token' },
})
await proxy(ctx)
const [, options] = mockFetch.mock.calls[0]
expect(options.headers.authorization).toBeUndefined()
})
it('replaces host header with upstream host', async () => {
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: null,
json: () => Promise.resolve({}),
})
const ctx = createMockCtx()
await proxy(ctx)
const [, options] = mockFetch.mock.calls[0]
expect(options.headers.host).toBe('127.0.0.1:8642')
})
it('forwards query string while stripping the web-ui token parameter', async () => {
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: null,
json: () => Promise.resolve({}),
})
const ctx = createMockCtx({ search: '?include_disabled=true&token=web-ui-token&profile=work' })
await proxy(ctx)
const url = mockFetch.mock.calls[0][0]
expect(url).toContain('?include_disabled=true')
expect(url).toContain('profile=work')
expect(url).not.toContain('token=')
})
it('returns 502 on connection failure', async () => {
mockFetch.mockImplementation((url: string) => {
if (typeof url === 'string' && url.includes('/health')) {
return Promise.resolve({ ok: true })
}
return Promise.reject(new Error('ECONNREFUSED'))
})
const ctx = createMockCtx()
await proxy(ctx)
expect(ctx.status).toBe(502)
expect(ctx.body).toEqual({ error: { message: 'Proxy error: ECONNREFUSED' } })
})
it('passes through non-200 status codes', async () => {
mockFetch.mockResolvedValue({
status: 404,
headers: new Headers({ 'content-type': 'application/json' }),
body: null,
json: () => Promise.resolve({ error: 'Not found' }),
})
const ctx = createMockCtx()
await proxy(ctx)
expect(ctx.status).toBe(404)
})
})
describe('POST /v1/runs — session_id capture', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('captures run_id → session_id mapping from POST /v1/runs', async () => {
const runId = 'run-abc-123'
const sessionId = 'session-xyz'
const responseBody = JSON.stringify({ run_id: runId, status: 'queued' })
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
text: () => Promise.resolve(responseBody),
body: null,
})
const ctx = createMockCtx({
path: '/api/hermes/v1/runs',
req: { method: 'POST' },
request: {
body: { session_id: sessionId, input: 'hello', model: 'gpt-4' },
},
})
await proxy(ctx)
// Verify the response was forwarded to client
expect(ctx.res.write).toHaveBeenCalledWith(responseBody)
expect(ctx.res.end).toHaveBeenCalled()
})
it('falls through to normal stream when POST body has no session_id', async () => {
const responseBody = JSON.stringify({ run_id: 'r1', status: 'queued' })
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
text: () => Promise.resolve(responseBody),
body: null,
})
const ctx = createMockCtx({
path: '/api/hermes/v1/runs',
req: { method: 'POST' },
request: { body: { input: 'hello' } }, // no session_id
})
await proxy(ctx)
// Should still forward the response
expect(ctx.res.end).toHaveBeenCalled()
})
it('serializes parsed JSON body when rawBody is not available', async () => {
const responseBody = JSON.stringify({ run_id: 'r1', status: 'queued' })
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
body: {
getReader: () => {
const encoder = new TextEncoder()
let done = false
return {
read: () => {
if (done) return Promise.resolve({ done: true, value: undefined })
done = true
return Promise.resolve({ done: false, value: encoder.encode(responseBody) })
},
}
},
},
})
const ctx = createMockCtx({
path: '/api/hermes/v1/runs',
req: { method: 'POST' },
request: { body: { session_id: 's1', input: 'test' } },
})
await proxy(ctx)
// Verify fetch was called with stringified body
const [, options] = mockFetch.mock.calls[0]
expect(typeof options.body).toBe('string')
const parsed = JSON.parse(options.body)
expect(parsed.session_id).toBe('s1')
expect(parsed.input).toBe('test')
})
})
describe('SSE stream interception — run.completed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('intercepts run.completed and calls updateUsage', async () => {
const runId = 'run-test-1'
const sessionId = 'session-test-1'
// Pre-populate the run → session mapping
setRunSession(runId, sessionId)
const sseData = [
`data: ${JSON.stringify({ event: 'run.started', run_id: runId })}\n\n`,
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: 'Hello' })}\n\n`,
`data: ${JSON.stringify({ event: 'run.completed', run_id: runId, usage: { input_tokens: 13949, output_tokens: 45, total_tokens: 13994 } })}\n\n`,
]
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody(sseData),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: `?token=test&profile=default`,
})
await proxy(ctx)
// Verify updateUsage was called with correct values
expect(mockUpdateUsage).toHaveBeenCalledWith(sessionId, {
inputTokens: 13949,
outputTokens: 45,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
model: '',
profile: 'default',
})
// Verify SSE data was forwarded to client
expect(ctx.res.write).toHaveBeenCalled()
expect(ctx.res.end).toHaveBeenCalled()
})
it('does not call updateUsage when no mapping exists', async () => {
const sseData = [
`data: ${JSON.stringify({ event: 'run.completed', run_id: 'unknown-run', usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 } })}\n\n`,
]
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody(sseData),
})
const ctx = createMockCtx({
path: '/api/hermes/v1/runs/unknown-run/events',
search: '',
})
await proxy(ctx)
expect(mockUpdateUsage).not.toHaveBeenCalled()
})
it('does not call updateUsage for non-run.completed events', async () => {
const runId = 'run-no-complete'
setRunSession(runId, 'session-x')
const sseData = [
`data: ${JSON.stringify({ event: 'run.started', run_id: runId })}\n\n`,
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: 'Hi' })}\n\n`,
`data: ${JSON.stringify({ event: 'run.failed', run_id: runId, error: 'timeout' })}\n\n`,
]
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody(sseData),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: '',
})
await proxy(ctx)
expect(mockUpdateUsage).not.toHaveBeenCalled()
})
it('handles SSE with multiple events in a single chunk', async () => {
const runId = 'run-multi'
setRunSession(runId, 'session-multi')
// All events in one chunk
const singleChunk = [
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: 'A' })}\n\n`,
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: 'B' })}\n\n`,
`data: ${JSON.stringify({ event: 'run.completed', run_id: runId, usage: { input_tokens: 500, output_tokens: 100, total_tokens: 600 } })}\n\n`,
].join('')
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody([singleChunk]),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: '',
})
await proxy(ctx)
expect(mockUpdateUsage).toHaveBeenCalledWith('session-multi', {
inputTokens: 500,
outputTokens: 100,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
model: '',
profile: 'default',
})
})
it('handles SSE split across multiple chunks', async () => {
const runId = 'run-split'
setRunSession(runId, 'session-split')
const completedJson = JSON.stringify({ event: 'run.completed', run_id: runId, usage: { input_tokens: 200, output_tokens: 50, total_tokens: 250 } })
const sseEvent = `data: ${completedJson}\n\n`
// Split the event across two chunks
const chunk1 = sseEvent.slice(0, 30)
const chunk2 = sseEvent.slice(30)
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody([chunk1, chunk2]),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: '',
})
await proxy(ctx)
expect(mockUpdateUsage).toHaveBeenCalledWith('session-split', {
inputTokens: 200,
outputTokens: 50,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
reasoningTokens: undefined,
model: '',
profile: 'default',
})
})
})
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const updateSessionStatsMock = vi.fn()
const flushBridgePendingToDbMock = vi.fn()
const flushResponseRunToDbMock = vi.fn()
const replaceStateMock = vi.fn()
const calcAndUpdateUsageMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
updateSessionStats: updateSessionStatsMock,
}))
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/services/hermes/run-chat/bridge-message', () => ({
flushBridgePendingToDb: flushBridgePendingToDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/response-stream', () => ({
flushResponseRunToDb: flushResponseRunToDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/compression', () => ({
replaceState: replaceStateMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: calcAndUpdateUsageMock,
}))
function makeHarness() {
const emit = vi.fn()
const nsp = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
to: vi.fn(() => ({ emit })),
}
const socket = {
connected: true,
emit: vi.fn(),
}
return { emit, nsp, socket }
}
describe('run chat abort goal handling', () => {
beforeEach(() => {
vi.clearAllMocks()
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 0, outputTokens: 0 })
})
it('pauses an active goal and clears hidden goal continuations when aborting a CLI run', async () => {
const { handleAbort } = await import('../../packages/server/src/services/hermes/run-chat/abort')
const { emit, nsp, socket } = makeHarness()
const state = {
messages: [],
isWorking: true,
isAborting: false,
events: [],
queue: [
{ queue_id: 'goal-1', input: 'continue goal', profile: 'default', goalContinuation: true },
{ queue_id: 'user-1', input: 'normal follow-up', profile: 'default', source: 'cli' },
],
runId: 'run-1',
profile: 'default',
source: 'cli',
} as any
const sessionMap = new Map([['session-1', state]])
const bridge = {
interrupt: vi.fn().mockResolvedValue({ ok: true }),
goalPause: vi.fn().mockResolvedValue({ handled: true, status: 'paused', reason: 'user-interrupted' }),
}
const runQueuedItem = vi.fn()
await handleAbort(nsp as any, socket as any, 'session-1', sessionMap, bridge, runQueuedItem)
expect(bridge.interrupt).toHaveBeenCalledWith('session-1', 'Aborted by user', 'default')
expect(bridge.goalPause).toHaveBeenCalledWith('session-1', 'user-interrupted', 'default')
expect(runQueuedItem).toHaveBeenCalledWith(socket, 'session-1', expect.objectContaining({
queue_id: 'user-1',
}), 'default')
expect(state.queue).toEqual([])
expect(emit).toHaveBeenCalledWith('abort.completed', expect.objectContaining({
session_id: 'session-1',
synced: true,
}))
})
})
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { filterBridgeToolCallMarkupDelta, flushPendingToolCallMarkup } 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')
})
it('flushes an orphan partial marker suffix when no text chunk follows', () => {
const state = {}
expect(filterBridgeToolCallMarkupDelta(state, 'Text [Call')).toBe('Text ')
expect(flushPendingToolCallMarkup(state)).toBe('[Call')
expect(flushPendingToolCallMarkup(state)).toBe('')
})
})
@@ -0,0 +1,681 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getSystemPromptMock = vi.fn()
const getSessionMock = vi.fn()
const createSessionMock = vi.fn()
const addMessageMock = vi.fn()
const updateSessionMock = vi.fn()
const updateSessionStatsMock = vi.fn()
const updateUsageMock = vi.fn()
const buildCompressedHistoryMock = vi.fn()
const buildDbHistoryMock = vi.fn()
const buildSnapshotAwareHistoryMock = vi.fn(async (_sessionId: string, _profile: string, history: any[]) => history)
const pushStateMock = vi.fn()
const replaceStateMock = vi.fn()
const forceCompressBridgeHistoryMock = vi.fn()
const calcAndUpdateUsageMock = vi.fn()
const estimateUsageTokensFromMessagesMock = vi.fn()
const updateContextTokenUsageMock = vi.fn((sid: string, state: any, emit: any, contextTokens: number, usage?: { inputTokens: number; outputTokens: number }) => {
state.contextTokens = contextTokens
emit('usage.updated', {
event: 'usage.updated',
session_id: sid,
inputTokens: usage?.inputTokens ?? state.inputTokens ?? 0,
outputTokens: usage?.outputTokens ?? state.outputTokens ?? 0,
contextTokens,
})
return contextTokens
})
const getCachedBridgeContextOverheadMock = vi.fn(() => undefined)
const contextTokensWithCachedOverheadMock = vi.fn((_state: any, messageTokens: number) => messageTokens)
const updateMessageContextTokenUsageMock = vi.fn((sid: string, state: any, emit: any, messageTokens: number, usage?: { inputTokens: number; outputTokens: number }) => updateContextTokenUsageMock(sid, state, emit, messageTokens, usage))
const flushBridgePendingToDbMock = vi.fn()
const ensureOpenBridgeAssistantMessageMock = vi.fn()
const syncBridgeReasoningToMessageMock = vi.fn()
const recordBridgeToolStartedMock = vi.fn()
const recordBridgeToolCompletedMock = vi.fn()
const resolveBridgeRunModelConfigMock = vi.fn()
vi.mock('../../packages/server/src/lib/llm-prompt', () => ({
getSystemPrompt: getSystemPromptMock,
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: getSessionMock,
createSession: createSessionMock,
addMessage: addMessageMock,
updateSession: updateSessionMock,
updateSessionStats: updateSessionStatsMock,
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
updateUsage: updateUsageMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
bridgeLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/compression', () => ({
buildCompressedHistory: buildCompressedHistoryMock,
buildDbHistory: buildDbHistoryMock,
buildSnapshotAwareHistory: buildSnapshotAwareHistoryMock,
pushState: pushStateMock,
replaceState: replaceStateMock,
forceCompressBridgeHistory: forceCompressBridgeHistoryMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: calcAndUpdateUsageMock,
estimateUsageTokensFromMessages: estimateUsageTokensFromMessagesMock,
getCachedBridgeContextOverhead: getCachedBridgeContextOverheadMock,
contextTokensWithCachedOverhead: contextTokensWithCachedOverheadMock,
updateContextTokenUsage: updateContextTokenUsageMock,
updateMessageContextTokenUsage: updateMessageContextTokenUsageMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/bridge-message', () => ({
flushBridgePendingToDb: flushBridgePendingToDbMock,
ensureOpenBridgeAssistantMessage: ensureOpenBridgeAssistantMessageMock,
syncBridgeReasoningToMessage: syncBridgeReasoningToMessageMock,
recordBridgeToolStarted: recordBridgeToolStartedMock,
recordBridgeToolCompleted: recordBridgeToolCompletedMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/model-config', () => ({
resolveBridgeRunModelConfig: resolveBridgeRunModelConfigMock,
}))
function makeSocket() {
return {
connected: true,
emit: vi.fn(),
join: vi.fn(),
to: vi.fn(() => ({ emit: vi.fn() })),
} as any
}
function makeNamespace(emit: ReturnType<typeof vi.fn>) {
const room = new Set(['socket-1'])
return {
adapter: { rooms: new Map([['session:session-1', room]]) },
to: vi.fn(() => ({ emit })),
} as any
}
function makeState() {
return {
messages: [],
isWorking: false,
events: [],
queue: [],
} as any
}
describe('bridge run final context usage', () => {
beforeEach(() => {
vi.clearAllMocks()
getSystemPromptMock.mockReturnValue('system prompt')
getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default', model: '', provider: '' })
resolveBridgeRunModelConfigMock.mockResolvedValue({ model: 'gpt-test', provider: 'openai' })
buildCompressedHistoryMock.mockResolvedValue([{ role: 'user', content: 'previous' }])
buildDbHistoryMock.mockResolvedValue([
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'done' },
])
buildSnapshotAwareHistoryMock.mockImplementation(async (_sessionId: string, _profile: string, history: any[]) => history)
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 11, outputTokens: 7 })
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 11, outputTokens: 7 })
getCachedBridgeContextOverheadMock.mockImplementation((state: any) => {
const fixed = state?.bridgeContext?.fixedContextTokens
return typeof fixed === 'number' ? fixed : undefined
})
contextTokensWithCachedOverheadMock.mockImplementation((state: any, messageTokens: number) => {
const fixed = state?.bridgeContext?.fixedContextTokens
return typeof fixed === 'number' ? fixed + messageTokens : messageTokens
})
updateMessageContextTokenUsageMock.mockImplementation((sid: string, state: any, emit: any, messageTokens: number, usage?: { inputTokens: number; outputTokens: number }) => {
const contextTokens = contextTokensWithCachedOverheadMock(state, messageTokens)
return updateContextTokenUsageMock(sid, state, emit, contextTokens, usage)
})
})
it('refreshes full context tokens when a bridge run completes', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
fixed_context_tokens: 12327,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
streamOutput: vi.fn(async function* () {
yield { run_id: 'run-1', done: true, status: 'completed', output: 'done' }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(bridge.contextEstimate).toHaveBeenCalledWith(
'session-1',
[],
expect.stringContaining('[Current Hermes profile: default]'),
'default',
{ model: 'gpt-test', provider: 'openai' },
)
expect(bridge.contextEstimate.mock.calls[0][2]).toContain('system prompt')
expect(bridge.contextEstimate.mock.calls[0][2]).toContain('X-Hermes-Profile')
expect(state.contextTokens).toBe(12345)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
inputTokens: 11,
outputTokens: 7,
contextTokens: 12345,
}))
expect(emit).toHaveBeenCalledWith('run.completed', expect.objectContaining({
inputTokens: 11,
outputTokens: 7,
contextTokens: 12345,
}))
})
it('evaluates active goals after a successful bridge run and queues continuation prompts', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const dequeueNextQueuedRun = vi.fn()
addMessageMock.mockReturnValue(42)
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
goalEvaluate: vi.fn().mockResolvedValue({
handled: true,
should_continue: true,
continuation_prompt: '[Continuing toward your standing goal]\nGoal: fix tests',
message: '↻ Continuing toward goal (1/20): tests still fail',
verdict: 'continue',
}),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: true,
status: 'completed',
output: 'not finished',
result: { final_response: 'not finished' },
}
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{
input: 'hello',
session_id: 'session-1',
model_groups: [{ provider: 'openai', models: ['gpt-test'] }],
},
'default',
sessionMap,
bridge,
false,
vi.fn(),
dequeueNextQueuedRun,
)
expect(bridge.goalEvaluate).toHaveBeenCalledWith('session-1', 'not finished', 'default')
expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({
session_id: 'session-1',
role: 'command',
content: '↻ Continuing toward goal (1/20): tests still fail',
}))
expect(emit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'goal',
action: 'continue',
message: '↻ Continuing toward goal (1/20): tests still fail',
}))
expect(state.queue).toEqual([expect.objectContaining({
input: '[Continuing toward your standing goal]\nGoal: fix tests',
displayInput: null,
storageMessage: '[Continuing toward your standing goal]\nGoal: fix tests',
model: 'gpt-test',
provider: 'openai',
model_groups: [{ provider: 'openai', models: ['gpt-test'] }],
goalContinuation: true,
})])
expect(dequeueNextQueuedRun).toHaveBeenCalledWith(socket, 'session-1')
})
it('skips hidden goal continuation runs without pausing when the judge is unavailable', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const dequeueNextQueuedRun = vi.fn()
addMessageMock.mockReturnValue(43)
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
command: vi.fn(),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
goalEvaluate: vi.fn().mockResolvedValue({
handled: true,
should_continue: true,
continuation_prompt: '[Continuing toward your standing goal]\nGoal: fix tests',
message: '↻ Continuing toward goal (1/20): no auxiliary client configured',
verdict: 'continue',
reason: 'no auxiliary client configured',
}),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: true,
status: 'completed',
output: 'done',
result: { final_response: 'done' },
}
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
dequeueNextQueuedRun,
)
expect(bridge.command).not.toHaveBeenCalled()
expect(state.queue).toEqual([])
expect(dequeueNextQueuedRun).not.toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'goal',
action: 'judge_unavailable',
message: 'Goal judge is not configured; automatic goal continuation was skipped. The goal remains active, but Hermes cannot mark it done automatically.',
}))
})
it('uses cached fixed context instead of bridge estimate when available', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn(),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: false,
status: 'running',
events: [{
event: 'bridge.context.ready',
fixed_context_tokens: 20_000,
system_prompt_tokens: 3_000,
tool_tokens: 17_000,
}],
}
yield { run_id: 'run-1', done: true, status: 'completed', output: 'done' }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(bridge.contextEstimate).not.toHaveBeenCalled()
expect(updateMessageContextTokenUsageMock).toHaveBeenCalledWith(
'session-1',
state,
expect.any(Function),
18,
{ inputTokens: 11, outputTokens: 7 },
)
expect(state.contextTokens).toBe(20_018)
expect(emit).toHaveBeenCalledWith('run.completed', expect.objectContaining({
contextTokens: 20_018,
}))
})
it('keeps bridge context ready updates on the snapshot-aware token baseline', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 28_000, outputTokens: 0 })
buildDbHistoryMock.mockResolvedValue([
{ role: 'user', content: 'very large old context' },
{ role: 'assistant', content: 'large old response' },
{ role: 'user', content: 'hello' },
])
buildSnapshotAwareHistoryMock.mockResolvedValue([
{ role: 'user', content: '[Previous context summary]\n\nsmall summary' },
{ role: 'user', content: 'hello' },
])
estimateUsageTokensFromMessagesMock.mockImplementation((messages: any[]) => {
if (messages?.[0]?.content?.includes('small summary')) {
return { inputTokens: 9_000, outputTokens: 0 }
}
return { inputTokens: 28_000, outputTokens: 0 }
})
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn(),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: false,
status: 'running',
events: [{
event: 'bridge.context.ready',
fixed_context_tokens: 10_000,
system_prompt_tokens: 2_000,
tool_tokens: 8_000,
}],
}
yield { run_id: 'run-1', done: true, status: 'completed', output: 'done' }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(updateMessageContextTokenUsageMock).toHaveBeenCalledWith(
'session-1',
state,
expect.any(Function),
9_000,
{ inputTokens: 28_000, outputTokens: 0 },
)
expect(updateMessageContextTokenUsageMock).not.toHaveBeenCalledWith(
'session-1',
state,
expect.any(Function),
28_000,
{ inputTokens: 28_000, outputTokens: 0 },
)
expect(state.contextTokens).toBe(19_000)
expect(emit).toHaveBeenCalledWith('run.completed', expect.objectContaining({
contextTokens: 19_000,
}))
})
it('persists pending tool marker text before a bridge run completes', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const persistedContent: string[] = []
flushBridgePendingToDbMock.mockImplementation((targetState: any) => {
persistedContent.push(targetState.bridgePendingAssistantContent || '')
targetState.bridgePendingAssistantContent = ''
})
ensureOpenBridgeAssistantMessageMock.mockImplementation((targetState: any, sessionId: string, runMarker: string) => {
let message = [...targetState.messages].reverse().find((m: any) => m.runMarker === runMarker && m.role === 'assistant' && m.finish_reason == null)
if (!message) {
message = {
id: targetState.messages.length + 1,
session_id: sessionId,
runMarker,
role: 'assistant',
content: '',
timestamp: Math.floor(Date.now() / 1000),
}
targetState.messages.push(message)
}
return message
})
const sessionMap = new Map([['session-1', state]])
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
streamOutput: vi.fn(async function* () {
yield { run_id: 'run-1', done: false, status: 'running', delta: 'Text [Call', events: [] }
yield { run_id: 'run-1', done: true, status: 'completed', output: '', events: [] }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(persistedContent).toContain('Text [Call')
expect(emit).toHaveBeenCalledWith('message.delta', expect.objectContaining({
delta: 'Text ',
output: 'Text ',
}))
expect(emit).toHaveBeenCalledWith('message.delta', expect.objectContaining({
delta: '[Call',
output: 'Text [Call',
}))
expect(emit).toHaveBeenCalledWith('run.completed', expect.objectContaining({
output: 'Text [Call',
}))
})
it('persists the visible plan command instead of the expanded skill prompt', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
streamOutput: vi.fn(async function* () {
yield { run_id: 'run-1', done: true, status: 'completed', output: 'planned' }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{
input: '[IMPORTANT: expanded plan skill prompt]',
display_input: '/plan build the feature',
display_role: 'command',
storage_message: '/plan build the feature',
session_id: 'session-1',
},
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(state.messages.find((message: any) => message.role === 'command')).toEqual(expect.objectContaining({
role: 'command',
content: '/plan build the feature',
}))
expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({
role: 'command',
content: '/plan build the feature',
}))
expect(addMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({
role: 'user',
content: '[IMPORTANT: expanded plan skill prompt]',
}))
expect(bridge.chat).toHaveBeenCalledWith(
'session-1',
'[IMPORTANT: expanded plan skill prompt]',
expect.any(Array),
expect.any(String),
'default',
expect.objectContaining({ storage_message: '/plan build the feature' }),
)
})
it('refreshes full context tokens when a bridge run fails', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const bridge = {
chat: vi.fn().mockRejectedValue(new Error('bridge timeout')),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 54321,
fixed_context_tokens: 54303,
message_count: 1,
tool_count: 4,
system_prompt_chars: 13,
}),
streamOutput: vi.fn(),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(state.contextTokens).toBe(54321)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
inputTokens: 11,
outputTokens: 7,
contextTokens: 54321,
}))
expect(emit).toHaveBeenCalledWith('run.failed', expect.objectContaining({
error: 'bridge timeout',
inputTokens: 11,
outputTokens: 7,
contextTokens: 54321,
}))
})
it('emits bridge lifecycle status events so retries are visible', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: false,
status: 'running',
events: [
{ event: 'status', kind: 'lifecycle', text: 'Retrying in 3.0s (attempt 1/3)...' },
],
}
yield { run_id: 'run-1', done: true, status: 'completed', output: 'done' }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(replaceStateMock).toHaveBeenCalledWith(sessionMap, 'session-1', 'agent.event', expect.objectContaining({
event: 'agent.event',
kind: 'lifecycle',
text: 'Retrying in 3.0s (attempt 1/3)...',
}))
expect(emit).toHaveBeenCalledWith('agent.event', expect.objectContaining({
event: 'agent.event',
kind: 'lifecycle',
text: 'Retrying in 3.0s (attempt 1/3)...',
}))
})
})
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { bridgeTerminalError } from '../../packages/server/src/services/hermes/run-chat/handle-bridge-run'
describe('bridge terminal error detection', () => {
it('uses bridge status errors directly', () => {
expect(bridgeTerminalError({
status: 'error',
error: 'bridge crashed',
result: null,
} as any)).toBe('bridge crashed')
})
it('surfaces agent result failure flags as run failures', () => {
expect(bridgeTerminalError({
status: 'complete',
error: undefined,
result: {
failed: true,
completed: false,
error: 'API call failed after 3 retries. HTTP 503: no available channel',
final_response: 'API call failed after 3 retries. HTTP 503: no available channel',
},
} as any)).toBe('API call failed after 3 retries. HTTP 503: no available channel')
})
it('falls back to final_response for failed results without an error field', () => {
expect(bridgeTerminalError({
status: 'complete',
result: {
completed: false,
final_response: 'API call failed after 3 retries: timeout',
},
} as any)).toBe('API call failed after 3 retries: timeout')
})
it('surfaces HTTP auth/provider errors even when failure flags are missing', () => {
expect(bridgeTerminalError({
status: 'complete',
result: {
final_response: 'API call failed after 3 retries. HTTP 403: forbidden',
},
} as any)).toBe('API call failed after 3 retries. HTTP 403: forbidden')
expect(bridgeTerminalError({
status: 'complete',
result: {
error: 'HTTP 401: unauthorized',
},
} as any)).toBe('HTTP 401: unauthorized')
})
it('surfaces generic provider result errors even without failed flags', () => {
expect(bridgeTerminalError({
status: 'complete',
result: {
error: '分组 subrouter 下模型 test 无可用渠道(distributor',
},
} as any)).toBe('分组 subrouter 下模型 test 无可用渠道(distributor')
})
it('does not flag successful complete results', () => {
expect(bridgeTerminalError({
status: 'complete',
result: {
completed: true,
final_response: 'done',
},
} as any)).toBeNull()
})
})
@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const bridgeMock = vi.hoisted(() => ({
clarifyRespond: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
AgentBridgeClient: vi.fn(() => bridgeMock),
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: vi.fn(() => null),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: vi.fn(() => 'default'),
getProfileDir: vi.fn(() => '/tmp/hermes-default'),
listProfileNamesFromDisk: vi.fn(() => ['default']),
}))
vi.mock('../../packages/server/src/middleware/user-auth', () => ({
authenticateUserToken: vi.fn(),
isAuthEnabled: vi.fn(async () => false),
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
userCanAccessProfile: vi.fn(() => true),
}))
function createSocketHarness() {
const handlers = new Map<string, Function>()
const namespaceEmit = vi.fn()
const namespace = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
to: vi.fn(() => ({ emit: namespaceEmit })),
use: vi.fn(),
on: vi.fn(),
}
const io = {
of: vi.fn(() => namespace),
}
const socket = {
id: 'socket-1',
connected: true,
data: {},
handshake: { auth: {}, query: { profile: 'default' } },
on: vi.fn((event: string, handler: Function) => {
handlers.set(event, handler)
}),
join: vi.fn(),
emit: vi.fn(),
}
return { handlers, io, namespace, namespaceEmit, socket }
}
describe('ChatRunSocket clarify responses', () => {
beforeEach(() => {
vi.resetModules()
bridgeMock.clarifyRespond.mockReset()
})
it('forwards clarify.respond events to the bridge and emits clarify.resolved', async () => {
bridgeMock.clarifyRespond.mockResolvedValue({ ok: true, resolved: true })
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, namespace, namespaceEmit, socket } = createSocketHarness()
const server = new ChatRunSocket(io as any)
;(server as any).onConnection(socket)
await handlers.get('clarify.respond')?.({
session_id: 'session-1',
clarify_id: 'clarify-1',
response: 'Use option A',
})
expect(bridgeMock.clarifyRespond).toHaveBeenCalledWith('clarify-1', 'Use option A')
expect(namespace.to).toHaveBeenCalledWith('session:session-1')
expect(namespaceEmit).toHaveBeenCalledWith('clarify.resolved', {
event: 'clarify.resolved',
session_id: 'session-1',
clarify_id: 'clarify-1',
resolved: true,
})
})
it('does not replay answered clarify prompts when the session resumes', async () => {
bridgeMock.clarifyRespond.mockResolvedValue({ ok: true, resolved: true })
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, socket } = createSocketHarness()
const server = new ChatRunSocket(io as any)
const toolEvent = {
event: 'tool.started',
data: { event: 'tool.started', tool_call_id: 'tool-1' },
}
;(server as any).sessionMap.set('session-1', {
messages: [],
isWorking: true,
events: [
{
event: 'clarify.requested',
data: {
event: 'clarify.requested',
clarify_id: 'clarify-1',
question: 'Pick one',
},
},
toolEvent,
],
queue: [],
})
;(server as any).onConnection(socket)
await handlers.get('clarify.respond')?.({
session_id: 'session-1',
clarify_id: 'clarify-1',
response: 'Use option A',
})
await handlers.get('resume')?.({ session_id: 'session-1' })
expect((server as any).sessionMap.get('session-1').events).toEqual([toolEvent])
expect(socket.emit).toHaveBeenCalledWith('resumed', expect.objectContaining({
session_id: 'session-1',
isWorking: true,
events: [toolEvent],
}))
})
it('emits an unresolved clarify result when the bridge rejects the response', async () => {
bridgeMock.clarifyRespond.mockRejectedValue(new Error('unknown clarify request'))
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, namespaceEmit, socket } = createSocketHarness()
const namespace = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
to: vi.fn(() => ({ emit: namespaceEmit })),
use: vi.fn(),
on: vi.fn(),
}
const server = new ChatRunSocket({ of: vi.fn(() => namespace) } as any)
;(server as any).onConnection(socket)
await handlers.get('clarify.respond')?.({
session_id: 'session-1',
clarify_id: 'clarify-1',
response: 'Use option B',
})
expect(namespaceEmit).toHaveBeenCalledWith('clarify.resolved', {
event: 'clarify.resolved',
session_id: 'session-1',
clarify_id: 'clarify-1',
resolved: false,
error: 'unknown clarify request',
})
})
})
+613
View File
@@ -0,0 +1,613 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getSessionDetailMock = vi.fn()
const getSessionMock = vi.fn()
const getCompressionSnapshotMock = vi.fn()
const getModelContextLengthMock = vi.fn()
const calcAndUpdateUsageMock = vi.fn()
const estimateUsageTokensFromMessagesMock = vi.fn()
const updateMessageContextTokenUsageMock = vi.fn((sid: string, state: any, emit: any, messageTokens: number, usage?: { inputTokens: number; outputTokens: number }) => {
state.contextTokens = messageTokens
emit('usage.updated', {
event: 'usage.updated',
session_id: sid,
inputTokens: usage?.inputTokens ?? state.inputTokens ?? 0,
outputTokens: usage?.outputTokens ?? state.outputTokens ?? 0,
contextTokens: messageTokens,
})
return messageTokens
})
const compressorCompressMock = vi.fn()
const readConfigYamlForProfileMock = vi.fn()
const compressorConstructorMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSessionDetail: getSessionDetailMock,
getSession: getSessionMock,
}))
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
getCompressionSnapshot: getCompressionSnapshotMock,
}))
vi.mock('../../packages/server/src/lib/context-compressor', () => ({
SUMMARY_PREFIX: '[Previous context summary]',
ChatContextCompressor: class {
constructor(opts?: any) {
compressorConstructorMock(opts)
}
compress = compressorCompressMock
},
}))
vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
getModelContextLength: getModelContextLengthMock,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYamlForProfile: readConfigYamlForProfileMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
bridgeLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: calcAndUpdateUsageMock,
estimateUsageTokensFromMessages: estimateUsageTokensFromMessagesMock,
updateMessageContextTokenUsage: updateMessageContextTokenUsageMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/message-format', () => ({
isAssistantMessageSendable: vi.fn(() => true),
}))
describe('run chat compression trigger', () => {
beforeEach(() => {
getSessionDetailMock.mockReset()
getSessionMock.mockReset()
getCompressionSnapshotMock.mockReset()
getModelContextLengthMock.mockReset()
calcAndUpdateUsageMock.mockReset()
estimateUsageTokensFromMessagesMock.mockReset()
updateMessageContextTokenUsageMock.mockClear()
compressorCompressMock.mockReset()
compressorConstructorMock.mockReset()
readConfigYamlForProfileMock.mockReset()
getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default' })
getModelContextLengthMock.mockReturnValue(256_000)
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 0, outputTokens: 0 })
getCompressionSnapshotMock.mockReturnValue(null)
readConfigYamlForProfileMock.mockResolvedValue({})
})
it('does not compress long low-token history just because it has more than 150 messages', async () => {
const messages = Array.from({ length: 152 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 151 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `m${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toHaveLength(151)
expect(history[0]).toEqual({ role: 'user', content: 'm0' })
expect(history.at(-1)).toEqual({ role: 'user', content: 'm150' })
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('uses configured threshold before triggering compression', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
readConfigYamlForProfileMock.mockResolvedValue({
compression: { threshold: 0.25, target_ratio: 0.1, protect_last_n: 7, protect_first_n: 2 },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 70_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toEqual([{ role: 'user', content: 'compressed' }])
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.any(Array),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({ profile: 'default' }),
)
})
it('uses local context estimates for compression threshold decisions', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed by local context estimate' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const emit = vi.fn()
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
vi.fn(async () => 160_000),
)
expect(history).toEqual([{ role: 'user', content: 'compressed by local context estimate' }])
expect(compressorCompressMock).toHaveBeenCalledTimes(1)
expect(updateMessageContextTokenUsageMock).toHaveBeenCalledWith(
'session-1',
expect.any(Object),
emit,
1_000,
{ inputTokens: 1_000, outputTokens: 0 },
)
})
it('emits local context token usage when the local estimate is under threshold', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 900 })
const emit = vi.fn()
const contextTokenEstimator = vi.fn(async () => 19_379)
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
contextTokenEstimator,
)
expect(history).toHaveLength(9)
expect(contextTokenEstimator).toHaveBeenCalledWith(
expect.arrayContaining([{ role: 'user', content: 'message 0' }]),
1_900,
)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
event: 'usage.updated',
session_id: 'session-1',
inputTokens: 1_000,
outputTokens: 900,
contextTokens: 19_379,
}))
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('includes current input tokens when estimating snapshot-aware context', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getCompressionSnapshotMock.mockReturnValue({
summary: 'previous summary',
lastMessageIndex: 4,
messageCountAtTime: 5,
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 10, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 1_000, outputTokens: 0 })
const emit = vi.fn()
const contextTokenEstimator = vi.fn(async (_messages, messageTokens: number) => 20_000 + messageTokens)
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
contextTokenEstimator,
700,
)
expect(contextTokenEstimator).toHaveBeenCalledWith(expect.any(Array), 1_700)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
contextTokens: 21_700,
}))
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('keeps current input tokens in the compression completed context total', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 100, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockImplementation((items: any[]) => {
if (items?.[0]?.content === 'compressed result') return { inputTokens: 1_000, outputTokens: 0 }
return { inputTokens: 100, outputTokens: 0 }
})
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed result' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const emit = vi.fn()
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
vi.fn(async () => 160_000),
700,
)
expect(updateMessageContextTokenUsageMock).toHaveBeenCalledWith(
'session-1',
expect.any(Object),
emit,
1_700,
{ inputTokens: 100, outputTokens: 0 },
)
expect(emit).toHaveBeenCalledWith('compression.completed', expect.objectContaining({
afterTokens: 1_700,
contextTokens: 1_700,
}))
})
it('throws when fixed prompt and tool schemas exceed threshold before any history exists', async () => {
getSessionDetailMock.mockReturnValue({ messages: [] })
const emit = vi.fn()
const { buildCompressedHistory, ContextWindowTooSmallError } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await expect(buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
vi.fn(async () => 160_000),
)).rejects.toBeInstanceOf(ContextWindowTooSmallError)
expect(emit).not.toHaveBeenCalledWith('usage.updated', expect.anything())
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('throws instead of compressing when full context is over threshold but history is too short', async () => {
const messages = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 4 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
const { buildCompressedHistory, ContextWindowTooSmallError } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await expect(buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
{},
vi.fn(async () => 160_000),
)).rejects.toBeInstanceOf(ContextWindowTooSmallError)
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('merges partial compression config with defaults', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
readConfigYamlForProfileMock.mockResolvedValue({
compression: { protect_last_n: 5 },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(compressorConstructorMock).toHaveBeenCalledWith({
config: {
triggerTokens: 128_000,
summaryBudget: 51_200,
headMessageCount: 3,
tailMessageCount: 5,
},
})
expect(compressorCompressMock).toHaveBeenCalledTimes(1)
})
it('uses stale snapshot summary plus safe tail instead of full history when under threshold', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getCompressionSnapshotMock.mockReturnValue({
summary: 'old summary',
lastMessageIndex: 99,
messageCountAtTime: 100,
})
readConfigYamlForProfileMock.mockResolvedValue({
compression: { protect_first_n: 2, protect_last_n: 3 },
})
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 1_000, outputTokens: 0 })
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history.map(m => m.content)).toEqual([
'message 0',
'message 1',
'[Previous context summary]\n\nold summary',
'message 6',
'message 7',
'message 8',
])
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('compresses stale snapshot safe tail instead of full history when stale assembly exceeds threshold', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getCompressionSnapshotMock.mockReturnValue({
summary: 'old summary',
lastMessageIndex: 99,
messageCountAtTime: 100,
})
readConfigYamlForProfileMock.mockResolvedValue({
compression: { protect_first_n: 2, protect_last_n: 3 },
})
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'updated stale compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 8,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toEqual([{ role: 'user', content: 'updated stale compressed' }])
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.arrayContaining([{ role: 'user', content: 'message 0' }]),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({ profile: 'default' }),
)
})
it('does not compress when compression is disabled', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
readConfigYamlForProfileMock.mockResolvedValue({
compression: { enabled: false, threshold: 0.01 },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 180_000, outputTokens: 0 })
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toHaveLength(9)
expect(compressorCompressMock).not.toHaveBeenCalled()
expect(calcAndUpdateUsageMock).not.toHaveBeenCalled()
})
})
@@ -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,/)
})
})
+129
View File
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getSessionMock = vi.fn()
const getSessionDetailPaginatedMock = vi.fn()
const getCompressionSnapshotMock = vi.fn()
const estimateUsageTokensFromMessagesMock = vi.fn()
const buildDbHistoryMock = vi.fn()
const buildSnapshotAwareHistoryMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: getSessionMock,
createSession: vi.fn(),
addMessage: vi.fn(),
updateSessionStats: vi.fn(),
getSessionDetailPaginated: getSessionDetailPaginatedMock,
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
updateUsage: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
getCompressionSnapshot: getCompressionSnapshotMock,
}))
vi.mock('../../packages/server/src/lib/context-compressor', () => ({
SUMMARY_PREFIX: '[Previous context summary]',
countTokens: vi.fn(() => 0),
}))
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/services/hermes/run-chat/compression', () => ({
buildCompressedHistory: vi.fn(),
buildDbHistory: buildDbHistoryMock,
buildSnapshotAwareHistory: buildSnapshotAwareHistoryMock,
getOrCreateSession: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: vi.fn(),
estimateUsageTokensFromMessages: estimateUsageTokensFromMessagesMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/message-format', () => ({
convertHistoryFormat: vi.fn((messages: any[]) => messages),
handleMessage: vi.fn((messages: any[]) => messages),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/content-blocks', () => ({
contentBlocksToString: vi.fn((value: any) => String(value || '')),
extractTextForPreview: vi.fn((value: any) => String(value || '')),
isContentBlockArray: vi.fn(() => false),
convertContentBlocks: vi.fn(),
}))
vi.mock('../../packages/server/src/lib/llm-prompt', () => ({
getSystemPrompt: vi.fn(() => 'system prompt'),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/sse-utils', () => ({
readSseFrames: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/response-utils', () => ({
extractResponseText: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/response-stream', () => ({
applyResponseStreamEvent: vi.fn(),
flushResponseRunToDb: vi.fn(),
}))
describe('loadSessionStateFromDb', () => {
beforeEach(() => {
vi.clearAllMocks()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
model: 'gpt-test',
provider: 'openai',
})
getSessionDetailPaginatedMock.mockReturnValue({
messages: [
{ role: 'user', content: 'old large context' },
{ role: 'assistant', content: 'old large answer' },
{ role: 'user', content: 'new tail' },
],
})
getCompressionSnapshotMock.mockReturnValue({
summary: 'small summary',
lastMessageIndex: 0,
messageCountAtTime: 1,
})
buildDbHistoryMock.mockResolvedValue([
{ role: 'user', content: 'old large context' },
{ role: 'assistant', content: 'old large answer' },
{ role: 'user', content: 'new tail' },
])
buildSnapshotAwareHistoryMock.mockResolvedValue([
{ role: 'user', content: '[Previous context summary]\n\nsmall summary' },
{ role: 'user', content: 'new tail' },
])
estimateUsageTokensFromMessagesMock.mockImplementation((messages: any[]) => {
if (messages?.[0]?.content?.includes('small summary')) {
return { inputTokens: 9_000, outputTokens: 0 }
}
return { inputTokens: 28_000, outputTokens: 0 }
})
})
it('hydrates contextTokens from the same snapshot-aware history used for bridge runs', async () => {
const { loadSessionStateFromDb } = await import('../../packages/server/src/services/hermes/run-chat/handle-api-run')
const state = await loadSessionStateFromDb('session-1', new Map())
expect(buildSnapshotAwareHistoryMock).toHaveBeenCalledWith(
'session-1',
'default',
expect.any(Array),
{ model: 'gpt-test', provider: 'openai' },
)
expect(state.inputTokens).toBe(28_000)
expect(state.outputTokens).toBe(0)
expect(state.contextTokens).toBe(9_000)
})
})
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
},
}))
import {
convertHistoryFormat,
handleMessage,
isAssistantMessageSendable,
} from '../../packages/server/src/services/hermes/run-chat/message-format'
import type { SessionMessage } from '../../packages/server/src/services/hermes/run-chat/types'
describe('run-chat message formatting', () => {
it('drops empty assistant history messages without tool calls', () => {
const formatted = convertHistoryFormat([
{ role: 'user', content: 'run a command' },
{ role: 'assistant', content: '' },
{ role: 'user', content: 'next turn' },
])
expect(formatted).toEqual([
{ role: 'user', content: 'run a command' },
{ role: 'user', content: 'next turn' },
])
})
it('converts empty assistant tool-call history messages to non-empty text', () => {
const toolCalls = [{
id: 'call_1',
type: 'function',
function: { name: 'terminal', arguments: '{}' },
}]
const formatted = convertHistoryFormat([
{ role: 'assistant', content: '', tool_calls: toolCalls },
])
expect(formatted).toEqual([
{ role: 'assistant', content: '[Calling tool: terminal with arguments: {}]' },
])
})
it('drops stale empty assistant messages loaded from the session database', () => {
const messages: SessionMessage[] = [
{ id: 1, session_id: 's1', role: 'user', content: 'first', timestamp: 1 },
{ id: 2, session_id: 's1', role: 'assistant', content: '', timestamp: 2 },
{ id: 3, session_id: 's1', role: 'assistant', content: 'done', timestamp: 3 },
]
expect(handleMessage(messages, 's1').map(m => ({ role: m.role, content: m.content }))).toEqual([
{ role: 'user', content: 'first' },
{ role: 'assistant', content: 'done' },
])
})
it('treats assistant tool-call messages as sendable even with empty text', () => {
expect(isAssistantMessageSendable({
content: '',
tool_calls: [{
id: 'call_1',
type: 'function',
function: { name: 'terminal', arguments: '{}' },
}],
})).toBe(true)
expect(isAssistantMessageSendable({ content: '' })).toBe(false)
})
})
@@ -0,0 +1,76 @@
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('keeps an explicit model when no model group list is available', async () => {
const { resolveBridgeRunModelConfig } = await import('../../packages/server/src/services/hermes/run-chat/model-config')
const result = await resolveBridgeRunModelConfig({
profile: 'default',
requestedModel: 'gpt-5.5',
requestedProvider: 'custom',
})
expect(result).toEqual({ model: 'gpt-5.5', provider: 'custom' })
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')
})
})
+128
View File
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handleBridgeRunMock = vi.hoisted(() => vi.fn(async () => {}))
const handleApiRunMock = vi.hoisted(() => vi.fn(async () => {}))
vi.mock('../../packages/server/src/services/hermes/run-chat/handle-bridge-run', () => ({
handleBridgeRun: handleBridgeRunMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/handle-api-run', () => ({
handleApiRun: handleApiRunMock,
loadSessionStateFromDb: vi.fn(),
resolveRunSource: vi.fn((source?: string) => source || 'cli'),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/session-command', () => ({
handleSessionCommand: vi.fn(),
isSessionCommand: vi.fn(() => false),
parseSessionCommand: vi.fn(() => null),
}))
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
AgentBridgeClient: 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/llm-prompt', () => ({
getSystemPrompt: vi.fn(() => 'system prompt'),
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: vi.fn(() => ({ id: 'session-1', profile: 'default', source: 'cli' })),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: vi.fn(() => 'default'),
getProfileDir: vi.fn(() => '/tmp/hermes-default'),
listProfileNamesFromDisk: vi.fn(() => ['default']),
}))
vi.mock('../../packages/server/src/middleware/user-auth', () => ({
authenticateUserToken: vi.fn(),
isAuthEnabled: vi.fn(async () => false),
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
userCanAccessProfile: vi.fn(() => true),
}))
function makeServerHarness() {
const namespace = {
adapter: { rooms: new Map() },
to: vi.fn(() => ({ emit: vi.fn() })),
use: vi.fn(),
on: vi.fn(),
}
const io = { of: vi.fn(() => namespace) }
const socket = {
id: 'socket-1',
connected: true,
handshake: { auth: {}, query: { profile: 'default' } },
data: {},
emit: vi.fn(),
join: vi.fn(),
to: vi.fn(() => ({ emit: vi.fn() })),
on: vi.fn(),
}
return { io, namespace, socket }
}
describe('ChatRunSocket queued bridge runs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('persists normal queued bridge messages when they are dequeued', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).runQueuedItem(socket, 'session-1', {
queue_id: 'queue-normal',
input: 'queued follow-up',
source: 'cli',
profile: 'default',
}, 'default')
await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled())
const call = handleBridgeRunMock.mock.calls.at(-1)!
expect(call[2]).toEqual(expect.objectContaining({
input: 'queued follow-up',
display_input: undefined,
storage_message: undefined,
queue_id: 'queue-normal',
}))
expect(call[6]).toBe(false)
})
it('persists the visible plan command when dequeuing expanded plan command runs', async () => {
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { io, socket } = makeServerHarness()
const server = new ChatRunSocket(io as any)
;(server as any).runQueuedItem(socket, 'session-1', {
queue_id: 'queue-plan',
input: '[IMPORTANT: expanded plan skill prompt]',
displayInput: '/plan build the feature',
displayRole: 'command',
storageMessage: '/plan build the feature',
source: 'cli',
profile: 'default',
}, 'default')
await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled())
const call = handleBridgeRunMock.mock.calls.at(-1)!
expect(call[2]).toEqual(expect.objectContaining({
input: '[IMPORTANT: expanded plan skill prompt]',
display_input: '/plan build the feature',
display_role: 'command',
storage_message: '/plan build the feature',
queue_id: 'queue-plan',
}))
expect(call[6]).toBe(false)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest'
import { countTokens } from '../../packages/server/src/lib/context-compressor'
import {
contextTokensWithCachedOverhead,
estimateUsageTokensFromMessages,
updateMessageContextTokenUsage,
} from '../../packages/server/src/services/hermes/run-chat/usage'
describe('run-chat usage token estimates', () => {
it('counts message content instead of serialized message payloads', () => {
const messages = [
{ role: 'user', content: 'hello from user' },
{ role: 'assistant', content: 'hello from assistant' },
]
const usage = estimateUsageTokensFromMessages(messages)
expect(usage.inputTokens).toBe(countTokens('hello from user'))
expect(usage.outputTokens).toBe(countTokens('hello from assistant'))
expect(usage.inputTokens + usage.outputTokens).toBeLessThan(countTokens(JSON.stringify(messages)))
})
it('keeps assistant tool call tokens on the output side', () => {
const messages = [
{
role: 'assistant',
content: 'calling tool',
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{"q":"x"}' } }],
},
]
const usage = estimateUsageTokensFromMessages(messages)
expect(usage.inputTokens).toBe(0)
expect(usage.outputTokens).toBe(countTokens('calling tool') + countTokens(String(messages[0].tool_calls || '')))
})
it('adds cached bridge fixed context when updating full context usage', () => {
const emit = vi.fn()
const state = {
messages: [],
isWorking: false,
events: [],
queue: [],
bridgeContext: { fixedContextTokens: 20_000 },
} as any
const contextTokens = updateMessageContextTokenUsage(
'session-1',
state,
emit,
1_569,
{ inputTokens: 1_200, outputTokens: 369 },
)
expect(contextTokens).toBe(21_569)
expect(state.contextTokens).toBe(21_569)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
session_id: 'session-1',
inputTokens: 1_200,
outputTokens: 369,
contextTokens: 21_569,
}))
})
it('falls back to message tokens when bridge fixed context is missing', () => {
const emit = vi.fn()
const state = {
messages: [],
isWorking: false,
events: [],
queue: [],
} as any
expect(contextTokensWithCachedOverhead(state, 1_569)).toBe(1_569)
const contextTokens = updateMessageContextTokenUsage(
'session-1',
state,
emit,
1_569,
{ inputTokens: 1_200, outputTokens: 369 },
)
expect(contextTokens).toBe(1_569)
expect(state.contextTokens).toBe(1_569)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
contextTokens: 1_569,
}))
})
})
+54
View File
@@ -0,0 +1,54 @@
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import YAML from 'js-yaml'
import { SafeFileStore } from '../../packages/server/src/services/safe-file-store'
const tempDirs: string[] = []
async function tempFile(name: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'hermes-safe-file-store-'))
tempDirs.push(dir)
return join(dir, name)
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
describe('SafeFileStore', () => {
it('serializes concurrent YAML read-modify-write updates for the same file', async () => {
const store = new SafeFileStore()
const file = await tempFile('config.yaml')
await writeFile(file, 'model:\n default: old\n', 'utf-8')
await Promise.all([
store.updateYaml(file, async (cfg) => {
await new Promise(resolve => setTimeout(resolve, 25))
cfg.model.default = 'glm-5.1'
return cfg
}),
store.updateYaml(file, (cfg) => {
cfg.platforms = cfg.platforms || {}
cfg.platforms.api_server = { extra: { port: 8648 } }
return cfg
}),
])
const result = YAML.load(await readFile(file, 'utf-8')) as any
expect(result.model.default).toBe('glm-5.1')
expect(result.platforms.api_server.extra.port).toBe(8648)
})
it('backs up the previous content and writes through a temporary file', async () => {
const store = new SafeFileStore()
const file = await tempFile('config.yaml')
await writeFile(file, 'model:\n default: old\n', 'utf-8')
await store.writeText(file, 'model:\n default: new\n', { backup: true })
await expect(readFile(`${file}.bak`, 'utf-8')).resolves.toContain('default: old')
await expect(readFile(file, 'utf-8')).resolves.toContain('default: new')
})
})
+409
View File
@@ -0,0 +1,409 @@
import { beforeAll, beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { unlinkSync, existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync } from 'fs'
import { resolve } from 'path'
// Test database path
const TEST_DB_DIR = resolve(process.cwd(), 'packages/server/data/test')
const TEST_DB_PATH = resolve(TEST_DB_DIR, 'test-hermes.db')
// Global test database instance
let testDbInstance: DatabaseSync | null = null
// Mock getDb to return our test database
vi.mock('../../packages/server/src/db/index', () => ({
getDb: () => testDbInstance,
getStoragePath: () => TEST_DB_PATH,
}))
// Helper to get the actual database instance
function getTestDb(): DatabaseSync {
if (!testDbInstance) {
throw new Error('Test database not initialized. Call beforeAll() first.')
}
return testDbInstance
}
// Helper to check if table exists
function tableExists(db: DatabaseSync, tableName: string): boolean {
const result = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
).get(tableName)
return !!result
}
// Helper to get table columns
function getTableColumns(db: DatabaseSync, tableName: string): Map<string, string> {
const columns = db.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{
name: string
type: string
pk: number
}>
const columnMap = new Map<string, string>()
for (const col of columns) {
columnMap.set(col.name, col.type)
}
return columnMap
}
// Helper to get table primary key from SQL
function getTablePrimaryKey(db: DatabaseSync, tableName: string): string | null {
const tableInfo = db.prepare(
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
).get(tableName) as { sql: string } | undefined
const sql = tableInfo?.sql || ''
// First, check for composite primary key: PRIMARY KEY (col1, col2)
const pkMatch = sql.match(/PRIMARY KEY\s*\(([^)]+)\)/i)
if (pkMatch) {
return pkMatch[1].replace(/\s+/g, '')
}
// Then, check for inline primary key: col TEXT PRIMARY KEY
const inlinePkMatch = sql.match(/"(\w+)"\s+\w+\s+PRIMARY KEY/i)
if (inlinePkMatch) {
return inlinePkMatch[1]
}
return null
}
describe('Database Schema Synchronization', () => {
beforeAll(() => {
// Create test directory
if (!existsSync(TEST_DB_DIR)) {
mkdirSync(TEST_DB_DIR, { recursive: true })
}
})
beforeEach(() => {
// Clean up any existing test database
try { unlinkSync(TEST_DB_PATH) } catch {}
try { unlinkSync(TEST_DB_PATH + '-wal') } catch {}
try { unlinkSync(TEST_DB_PATH + '-shm') } catch {}
// Create new test database
testDbInstance = new DatabaseSync(TEST_DB_PATH)
testDbInstance.exec('PRAGMA journal_mode=WAL')
testDbInstance.exec('PRAGMA synchronous=NORMAL')
// Reset modules to ensure fresh imports
vi.resetModules()
})
afterEach(() => {
// Close test database
if (testDbInstance) {
testDbInstance.close()
testDbInstance = null
}
// Clean up test database and backup files
try { unlinkSync(TEST_DB_PATH) } catch {}
try { unlinkSync(TEST_DB_PATH + '-wal') } catch {}
try { unlinkSync(TEST_DB_PATH + '-shm') } catch {}
})
describe('Normal initialization - fresh database creation', () => {
it('creates all tables with correct schemas when database does not exist', async () => {
const { initAllHermesTables, USAGE_TABLE, USAGE_SCHEMA, SESSIONS_TABLE, SESSIONS_SCHEMA } =
await import('../../packages/server/src/db/hermes/schemas')
initAllHermesTables()
const db = getTestDb()
// Verify USAGE_TABLE was created
expect(tableExists(db, USAGE_TABLE)).toBe(true)
// Verify USAGE_TABLE has correct columns
const usageCols = getTableColumns(db, USAGE_TABLE)
expect(usageCols.size).toBe(Object.keys(USAGE_SCHEMA).length)
expect(usageCols.has('id')).toBe(true)
expect(usageCols.has('session_id')).toBe(true)
expect(usageCols.has('input_tokens')).toBe(true)
// Verify SESSIONS_TABLE was created
expect(tableExists(db, SESSIONS_TABLE)).toBe(true)
// Verify SESSIONS_TABLE has correct columns
const sessionsCols = getTableColumns(db, SESSIONS_TABLE)
expect(sessionsCols.size).toBe(Object.keys(SESSIONS_SCHEMA).length)
expect(sessionsCols.has('id')).toBe(true)
expect(sessionsCols.has('profile')).toBe(true)
expect(sessionsCols.has('source')).toBe(true)
})
})
describe('Safe additive schema changes', () => {
it('adds missing safe columns to existing table without rebuilding', async () => {
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
// Create initial table without some columns
const db = getTestDb()
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-1', Date.now())
// Sync with full schema
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
// Verify safe missing columns now exist
const cols = getTableColumns(db, USAGE_TABLE)
expect(cols.has('input_tokens')).toBe(true)
expect(cols.has('output_tokens')).toBe(true)
expect(cols.has('cache_read_tokens')).toBe(true)
expect(cols.has('cache_write_tokens')).toBe(true)
// Verify data integrity (should be preserved)
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('test-1')
expect(row).toBeTruthy()
expect(row.session_id).toBe('test-1')
})
it('adds created_at to legacy session_usage tables missing the column', async () => {
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
const db = getTestDb()
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL)`)
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id) VALUES (?)`).run('legacy-session')
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
const cols = getTableColumns(db, USAGE_TABLE)
expect(cols.has('created_at')).toBe(true)
const row = db.prepare(`SELECT session_id, created_at FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('legacy-session')
expect(row).toMatchObject({ session_id: 'legacy-session', created_at: 0 })
})
})
describe('Schema sync with single-column primary keys', () => {
it('creates table with single-column primary key', async () => {
const { syncTable, GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA } =
await import('../../packages/server/src/db/hermes/schemas')
syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, {
primaryKey: 'id',
})
const db = getTestDb()
// Verify table exists
expect(tableExists(db, GC_ROOM_AGENTS_TABLE)).toBe(true)
// Verify single-column primary key
const pk = getTablePrimaryKey(db, GC_ROOM_AGENTS_TABLE)
expect(pk).toBe('id')
// Verify all columns exist
const cols = getTableColumns(db, GC_ROOM_AGENTS_TABLE)
expect(cols.has('id')).toBe(true)
expect(cols.has('roomId')).toBe(true)
expect(cols.has('agentId')).toBe(true)
expect(cols.has('profile')).toBe(true)
expect(cols.has('name')).toBe(true)
// Verify primary key constraint works (unique id required)
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)
// Verify both rows exist
const rows = db.prepare(`SELECT COUNT(*) as count FROM "${GC_ROOM_AGENTS_TABLE}"`).get() as { count: number }
expect(rows.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()
})
})
describe('Destructive schema changes are not applied automatically', () => {
it('does not rebuild table when primary key differs', async () => {
const { syncTable, GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA } =
await import('../../packages/server/src/db/hermes/schemas')
const db = getTestDb()
// Create table with roomId as primary key and all necessary columns
db.exec(`CREATE TABLE "${GC_ROOM_MEMBERS_TABLE}" (roomId TEXT PRIMARY KEY, userId TEXT, userName TEXT, description TEXT DEFAULT '', joinedAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL)`)
// Insert test data
db.prepare(`INSERT INTO "${GC_ROOM_MEMBERS_TABLE}" (roomId, userId, userName, description, joinedAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`)
.run('room-1', 'user-1', 'User 1', '', Date.now(), Date.now())
// Sync with id-based primary key schema
syncTable(GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA, {
primaryKey: 'id',
})
// Verify existing primary key was left untouched
const tableCols = db.prepare(`PRAGMA table_info("${GC_ROOM_MEMBERS_TABLE}")`).all() as Array<{ name: string; pk: number }>
expect(tableCols.find(c => c.name === 'roomId')?.pk).toBe(1)
// Verify data was preserved
const row = db.prepare(`SELECT * FROM "${GC_ROOM_MEMBERS_TABLE}" WHERE roomId = ? AND userId = ?`).get('room-1', 'user-1')
expect(row).toBeTruthy()
expect(row.roomId).toBe('room-1')
expect(row.userId).toBe('user-1')
})
it('does not rebuild table when column types differ', async () => {
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
const db = getTestDb()
// Create table with wrong column type (INTEGER instead of TEXT for session_id)
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, created_at INTEGER NOT NULL)`)
// Insert test data
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run(12345, Date.now())
// Sync with correct schema
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
// Verify column type was left untouched
const cols = getTableColumns(db, USAGE_TABLE)
expect(cols.get('session_id')).toBe('INTEGER')
// Verify data was preserved
const rows = db.prepare(`SELECT COUNT(*) as count FROM "${USAGE_TABLE}"`).get() as { count: number }
expect(rows.count).toBe(1)
})
})
describe('Index synchronization', () => {
it('creates specified indexes on table', async () => {
const { syncTable, MESSAGES_TABLE, MESSAGES_SCHEMA } =
await import('../../packages/server/src/db/hermes/schemas')
syncTable(MESSAGES_TABLE, MESSAGES_SCHEMA, {
indexes: {
idx_messages_session_id: 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)',
},
})
const db = getTestDb()
// Verify index was created
const indexes = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_messages_session_id')
expect(indexes).toBeTruthy()
})
it('does not alter indexes on existing tables', async () => {
const { syncTable, MESSAGES_TABLE, MESSAGES_SCHEMA } =
await import('../../packages/server/src/db/hermes/schemas')
const db = getTestDb()
// Create table and an extra index
db.exec(`CREATE TABLE "${MESSAGES_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, content TEXT)`)
db.exec(`CREATE INDEX idx_extra ON "${MESSAGES_TABLE}"(content)`)
// Sync without the extra index
syncTable(MESSAGES_TABLE, MESSAGES_SCHEMA, {
indexes: {
idx_messages_session_id: 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)',
},
})
// Verify extra index remains
const extraIndex = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_extra')
expect(extraIndex).toBeTruthy()
// Verify expected index was not added to an existing table
const correctIndex = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_messages_session_id')
expect(correctIndex).toBeFalsy()
})
})
describe('Data preservation during schema sync', () => {
it('preserves data when adding safe columns', async () => {
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
const db = getTestDb()
// Create minimal table
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL)`)
// Insert test data (only columns that exist)
const sessionId = 'test-session-123'
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run(sessionId, Date.now())
// Sync with full schema (should add safe columns only)
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
// Verify data is still there
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get(sessionId)
expect(row).toBeTruthy()
expect(row.session_id).toBe(sessionId)
const cols = getTableColumns(db, USAGE_TABLE)
expect(cols.has('input_tokens')).toBe(true)
})
it('preserves data and existing table definition when primary key is missing', async () => {
const { syncTable, GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA } =
await import('../../packages/server/src/db/hermes/schemas')
const db = getTestDb()
// Create table without id primary key but with all columns
db.exec(`CREATE TABLE "${GC_ROOM_AGENTS_TABLE}" (id TEXT NOT NULL, roomId TEXT NOT NULL, agentId TEXT NOT NULL, profile TEXT NOT NULL, name TEXT NOT NULL, description TEXT DEFAULT '', invited INTEGER DEFAULT 0)`)
// Insert test data (only columns that exist)
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run('agent-1', 'room-1', 'agent-1', 'default', 'Test Agent', '', 0)
// Sync with id primary key expectation; should not rebuild existing table
syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, {
primaryKey: 'id',
})
expect(getTablePrimaryKey(db, GC_ROOM_AGENTS_TABLE)).toBe(null)
// Verify data was preserved
const row = db.prepare(`SELECT * FROM "${GC_ROOM_AGENTS_TABLE}" WHERE id = ?`)
.get('agent-1')
expect(row).toBeTruthy()
expect(row.id).toBe('agent-1')
expect(row.roomId).toBe('room-1')
expect(row.agentId).toBe('agent-1')
expect(row.name).toBe('Test Agent')
})
})
describe('Column preservation', () => {
it('keeps extra columns on existing table', async () => {
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
// Create table with extra columns
const db = getTestDb()
db.exec(`CREATE TABLE "${USAGE_TABLE}" (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL, extra_col TEXT, another_extra INTEGER)`)
// Insert test data (only for columns that exist)
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at, extra_col, another_extra) VALUES (?, ?, ?, ?)`)
.run('test-1', Date.now(), 'value', 123)
// Sync with schema (should keep extra columns)
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
// Verify extra columns are preserved
const cols = getTableColumns(db, USAGE_TABLE)
expect(cols.has('extra_col')).toBe(true)
expect(cols.has('another_extra')).toBe(true)
// Verify data is still there
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('test-1')
expect(row).toBeTruthy()
expect(row.session_id).toBe('test-1')
})
})
})
+333
View File
@@ -0,0 +1,333 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const addMessageMock = vi.fn()
const createSessionMock = vi.fn()
const getSessionMock = vi.fn()
const updateSessionStatsMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
addMessage: addMessageMock,
clearSessionMessages: vi.fn(),
createSession: createSessionMock,
getSession: getSessionMock,
renameSession: vi.fn(),
updateSessionStats: updateSessionStatsMock,
}))
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/services/hermes/run-chat/compression', () => ({
buildDbHistory: vi.fn(),
estimateSnapshotAwareHistoryUsage: vi.fn(),
forceCompressBridgeHistory: vi.fn(),
getOrCreateSession: vi.fn((_map: Map<string, any>, sessionId: string) => _map.get(sessionId)),
replaceState: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: vi.fn(),
contextTokensWithCachedOverhead: vi.fn(),
updateMessageContextTokenUsage: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/abort', () => ({
handleAbort: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/bridge-message', () => ({
flushBridgePendingToDb: vi.fn(),
}))
function makeContext(state: any, commandResult: Record<string, unknown> = {
handled: true,
message: '[IMPORTANT: expanded plan skill prompt]',
}) {
const namespaceEmit = vi.fn()
const nsp = {
to: vi.fn(() => ({ emit: namespaceEmit })),
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
}
const socket = {
id: 'socket-1',
connected: true,
join: vi.fn(),
emit: vi.fn(),
}
const sessionMap = new Map([['session-1', state]])
const runQueuedItem = vi.fn()
const bridge = {
command: vi.fn(async () => commandResult),
mcpReload: vi.fn(async () => ({ ok: true, message: 'MCP servers reloaded' })),
status: vi.fn(async () => ({
exists: true,
running: false,
current_run_id: null,
message_count: 0,
})),
}
return { bridge, namespaceEmit, nsp, runQueuedItem, sessionMap, socket }
}
describe('plan session command', () => {
beforeEach(() => {
vi.clearAllMocks()
getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default', source: 'cli' })
})
it('queues running plan commands once without visible command echo', async () => {
const state = { messages: [], isWorking: true, events: [], queue: [] }
const { bridge, namespaceEmit, nsp, runQueuedItem, sessionMap, socket } = makeContext(state)
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/plan build the feature')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
queueId: 'client-queue-id',
runQueuedItem,
})
expect(addMessageMock).not.toHaveBeenCalled()
expect(runQueuedItem).not.toHaveBeenCalled()
expect(state.queue).toEqual([expect.objectContaining({
queue_id: 'client-queue-id',
input: '[IMPORTANT: expanded plan skill prompt]',
displayInput: '/plan build the feature',
displayRole: 'command',
storageMessage: '/plan build the feature',
})])
expect(namespaceEmit).toHaveBeenCalledWith('run.queued', expect.objectContaining({
queue_length: 1,
queued_messages: [expect.objectContaining({
id: 'client-queue-id',
role: 'command',
content: '/plan build the feature',
queued: true,
})],
}))
expect(namespaceEmit).not.toHaveBeenCalledWith('session.command', expect.anything())
})
it('starts an idle goal command as a hidden kickoff run', async () => {
const state = { messages: [], isWorking: false, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'set',
message: 'Goal set.',
kickoff_prompt: 'fix the tests',
max_turns: 20,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal fix the tests')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
queueId: 'goal-queue-id',
runQueuedItem,
})
expect(bridge.command).toHaveBeenCalledWith('session-1', 'goal fix the tests', 'default')
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
action: 'set',
message: 'Goal set.',
terminal: false,
started: true,
}))
expect(runQueuedItem).toHaveBeenCalledWith(socket, 'session-1', expect.objectContaining({
queue_id: 'goal-queue-id',
input: 'fix the tests',
displayInput: null,
storageMessage: 'fix the tests',
source: 'cli',
}), 'default')
})
it('clears queued goal continuations when pausing a goal', async () => {
const state = {
messages: [],
isWorking: true,
events: [],
queue: [
{ queue_id: 'goal-1', input: 'continue', displayInput: null, storageMessage: 'continue', profile: 'default', goalContinuation: true },
{ queue_id: 'user-1', input: 'user message', profile: 'default' },
],
}
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'pause',
message: 'Goal paused.',
clear_goal_continuations: true,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal pause')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(runQueuedItem).not.toHaveBeenCalled()
expect(state.queue).toEqual([expect.objectContaining({ queue_id: 'user-1' })])
expect(namespaceEmit).toHaveBeenCalledWith('run.queued', expect.objectContaining({
queue_length: 1,
queued_messages: [expect.objectContaining({ id: 'user-1', content: 'user message' })],
}))
})
it('emits a goal-specific clear action for goal done', async () => {
const state = {
messages: [],
isWorking: false,
events: [],
queue: [
{ queue_id: 'goal-1', input: 'continue', displayInput: null, storageMessage: 'continue', profile: 'default', goalContinuation: true },
],
}
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'clear',
message: 'Goal cleared.',
clear_goal_continuations: true,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal done')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(bridge.command).toHaveBeenCalledWith('session-1', 'goal done', 'default')
expect(runQueuedItem).not.toHaveBeenCalled()
expect(state.queue).toEqual([])
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'goal',
action: 'goal_clear',
message: 'Goal cleared.',
terminal: true,
started: false,
}))
})
it('starts a resumed goal as a hidden continuation run', async () => {
const state = { messages: [], isWorking: false, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'resume',
message: 'Goal resumed.',
kickoff_prompt: '[Continuing toward your standing goal]\nGoal: fix the tests',
max_turns: 20,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal resume')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
queueId: 'resume-queue-id',
runQueuedItem,
})
expect(bridge.command).toHaveBeenCalledWith('session-1', 'goal resume', 'default')
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
action: 'resume',
message: 'Goal resumed.',
terminal: false,
started: true,
}))
expect(runQueuedItem).toHaveBeenCalledWith(socket, 'session-1', expect.objectContaining({
queue_id: 'resume-queue-id',
input: '[Continuing toward your standing goal]\nGoal: fix the tests',
displayInput: null,
storageMessage: '[Continuing toward your standing goal]\nGoal: fix the tests',
source: 'cli',
}), 'default')
})
it('includes bridge run state in goal status output', async () => {
const state = { messages: [], isWorking: false, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'goal_status',
message: 'Goal (active, 0/20 turns): build docs',
})
bridge.status.mockResolvedValueOnce({
exists: true,
running: true,
current_run_id: 'run-123',
message_count: 4,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal status')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(runQueuedItem).not.toHaveBeenCalled()
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
action: 'goal_status',
message: 'Goal (active, 0/20 turns): build docs\nCurrent turn: 1/20 running (completed turns: 0/20; count updates after the judge).\nRun: running (run-123)',
bridgeStatus: expect.objectContaining({
running: true,
currentRunId: 'run-123',
}),
}))
})
it('rejects MCP reload while the session is running', async () => {
const state = { messages: [], isWorking: true, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state)
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/reload-mcp github')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(bridge.mcpReload).not.toHaveBeenCalled()
expect(runQueuedItem).not.toHaveBeenCalled()
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'reload-mcp',
ok: false,
action: 'reload-mcp',
terminal: false,
message: 'MCP reload can only run while the session is idle. Wait for the current run to finish or abort it first.',
}))
})
})
+221
View File
@@ -0,0 +1,221 @@
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,
parent_session_id: null,
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: null,
billing_base_url: null,
billing_mode: null,
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
cost_source: null,
pricing_version: null,
title: 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('session DB detail', () => {
beforeEach(() => {
vi.resetModules()
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-session-detail-db-'))
})
afterEach(() => {
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
})
it('reconstructs compressed continuation messages for session detail', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'root',
source: 'cli',
model: 'gpt-5.5',
title: 'Root title',
started_at: 100,
ended_at: 110,
end_reason: 'compression',
message_count: 2,
tool_call_count: 1,
input_tokens: 10,
output_tokens: 20,
actual_cost_usd: 0.1,
cost_status: 'estimated',
})
insertSession(db, {
id: 'root-cont',
parent_session_id: 'root',
source: 'cli',
model: 'gpt-5.5',
started_at: 110,
ended_at: 120,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
actual_cost_usd: 0.2,
cost_status: 'final',
})
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'before compression', timestamp: 101 })
insertMessage(db, {
id: 2,
session_id: 'root',
role: 'assistant',
content: '',
tool_calls: JSON.stringify([{ id: 'call-1', type: 'function', function: { name: 'terminal', arguments: '{"command":"pwd"}' } }]),
finish_reason: 'tool_calls',
reasoning_content: 'thinking before tool',
timestamp: 102,
})
insertMessage(db, { id: 3, session_id: 'root-cont', role: 'tool', content: '{"output":"/tmp"}', tool_call_id: 'call-1', timestamp: 111 })
insertMessage(db, { id: 4, session_id: 'root-cont', role: 'assistant', content: 'after compression', timestamp: 112 })
db.close()
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const detail = await mod.getSessionDetailFromDb('root')
expect(detail?.id).toBe('root')
expect(detail?.message_count).toBe(4)
expect(detail?.tool_call_count).toBe(1)
expect(detail?.ended_at).toBe(120)
expect(detail?.cost_status).toBe('mixed')
expect(detail?.actual_cost_usd).toBeCloseTo(0.3)
expect(detail?.messages.map((message: any) => `${message.session_id}:${message.role}:${message.content}`)).toEqual([
'root:user:before compression',
'root:assistant:',
'root-cont:tool:{"output":"/tmp"}',
'root-cont:assistant:after compression',
])
expect(detail?.messages[1].tool_calls?.[0]?.function?.name).toBe('terminal')
expect(detail?.messages[1].reasoning).toBe('thinking before tool')
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* Tests for the disabled Hermes session import path.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('session-sync', () => {
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:',
}))
vi.doMock('../../packages/server/src/db/hermes/sessions-db', () => ({
listSessionSummaries: vi.fn().mockResolvedValue([]),
getSessionDetailFromDbWithProfile: vi.fn(),
}))
})
afterEach(() => {
db?.close()
db = null
vi.doUnmock('../../packages/server/src/db/index')
vi.doUnmock('../../packages/server/src/db/hermes/sessions-db')
vi.resetModules()
})
async function initTestDb() {
const { initAllStores } = await import('../../packages/server/src/db/hermes/init')
initAllStores()
}
it('does not import Hermes sessions when local DB is not empty', async () => {
await initTestDb()
const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync')
db.prepare(`
INSERT INTO sessions (id, profile, source, model, title, started_at, last_active)
VALUES ('test-session-1', 'default', 'api_server', 'gpt-4', 'Test Session', ?, ?)
`).run(Date.now(), Date.now())
await syncAllHermesSessionsOnStartup()
const countAfter = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
expect(countAfter.count).toBe(1)
})
it('does not import Hermes sessions when local DB is empty', async () => {
await initTestDb()
const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync')
await expect(syncAllHermesSessionsOnStartup()).resolves.toBeUndefined()
const countAfter = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
expect(countAfter.count).toBe(0)
})
})
+859
View File
@@ -0,0 +1,859 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const listConversationSummariesFromDbMock = vi.fn()
const getConversationDetailFromDbMock = vi.fn()
const listConversationSummariesMock = vi.fn()
const getConversationDetailMock = vi.fn()
const listSessionSummariesMock = vi.fn()
const getSessionDetailFromDbMock = vi.fn()
const getSessionDetailFromDbWithProfileMock = 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()
const localDeleteSessionMock = vi.fn()
const localRenameSessionMock = vi.fn()
const localCreateSessionMock = vi.fn()
const localUpdateSessionMock = vi.fn()
const localAddMessagesMock = vi.fn()
const localUpdateSessionStatsMock = vi.fn()
const getGroupChatServerMock = vi.fn()
const getLocalUsageStatsMock = vi.fn()
const getActiveProfileNameMock = vi.fn()
const loggerWarnMock = vi.fn()
const getCompressionSnapshotMock = vi.fn()
const listUserProfilesMock = vi.fn()
const readConfigYamlForProfileMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
getConversationDetailFromDb: getConversationDetailFromDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
listConversationSummaries: listConversationSummariesMock,
getConversationDetail: getConversationDetailMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
warn: loggerWarnMock,
error: vi.fn(),
},
}))
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
listSessions: vi.fn(),
getSession: getSessionMock,
deleteSession: vi.fn(),
deleteSessionForProfile: deleteHermesSessionForProfileMock,
renameSession: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
listSessionSummaries: listSessionSummariesMock,
searchSessionSummaries: vi.fn(),
getSessionDetailFromDb: getSessionDetailFromDbMock,
getSessionDetailFromDbWithProfile: getSessionDetailFromDbWithProfileMock,
getExactSessionDetailFromDbWithProfile: getExactSessionDetailFromDbWithProfileMock,
getUsageStatsFromDb: getUsageStatsFromDbMock,
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
listSessions: localListSessionsMock,
searchSessions: localSearchSessionsMock,
getSessionDetail: localGetSessionDetailMock,
deleteSession: localDeleteSessionMock,
renameSession: localRenameSessionMock,
createSession: localCreateSessionMock,
addMessages: localAddMessagesMock,
getSession: getSessionMock,
updateSession: localUpdateSessionMock,
updateSessionStats: localUpdateSessionStatsMock,
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
listUserProfiles: listUserProfilesMock,
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
deleteUsage: vi.fn(),
getUsage: vi.fn(),
getUsageBatch: vi.fn(),
getLocalUsageStats: getLocalUsageStatsMock,
}))
vi.mock('../../packages/server/src/routes/hermes/group-chat', () => ({
getGroupChatServer: getGroupChatServerMock,
}))
vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
getModelContextLength: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: getActiveProfileNameMock,
listProfileNamesFromDisk: () => ['default', 'travel'],
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYamlForProfile: readConfigYamlForProfileMock,
}))
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
getCompressionSnapshot: getCompressionSnapshotMock,
}))
vi.mock('../../packages/server/src/lib/context-compressor/export-compressor', () => ({
ExportCompressor: class {
async compress(messages: any[]) {
return {
messages,
meta: { totalMessages: messages.length, compressed: true, llmCompressed: true, summaryTokenEstimate: 100, verbatimCount: 0, compressedStartIndex: -1 },
}
}
},
}))
describe('session conversations controller', () => {
beforeEach(() => {
vi.resetModules()
listConversationSummariesFromDbMock.mockReset()
getConversationDetailFromDbMock.mockReset()
listConversationSummariesMock.mockReset()
getConversationDetailMock.mockReset()
listSessionSummariesMock.mockReset()
getSessionDetailFromDbMock.mockReset()
getSessionDetailFromDbWithProfileMock.mockReset()
getExactSessionDetailFromDbWithProfileMock.mockReset()
getUsageStatsFromDbMock.mockReset()
getSessionMock.mockReset()
deleteHermesSessionForProfileMock.mockReset()
localListSessionsMock.mockReset()
localGetSessionDetailMock.mockReset()
localSearchSessionsMock.mockReset()
localDeleteSessionMock.mockReset()
localRenameSessionMock.mockReset()
localCreateSessionMock.mockReset()
localUpdateSessionMock.mockReset()
localAddMessagesMock.mockReset()
localUpdateSessionStatsMock.mockReset()
getGroupChatServerMock.mockReset()
getGroupChatServerMock.mockReturnValue(null)
getLocalUsageStatsMock.mockReset()
getActiveProfileNameMock.mockReset()
getActiveProfileNameMock.mockReturnValue('default')
loggerWarnMock.mockReset()
getCompressionSnapshotMock.mockReset()
listUserProfilesMock.mockReset()
listUserProfilesMock.mockReturnValue([])
readConfigYamlForProfileMock.mockReset()
readConfigYamlForProfileMock.mockResolvedValue({ model: { default: 'gpt-default', provider: 'openai' } })
})
it('lists conversations from the local session store', async () => {
localListSessionsMock.mockReturnValue([{
id: 'local-conversation',
source: 'cli',
model: 'gpt-5',
title: 'Local',
started_at: 1,
ended_at: null,
last_active: Math.floor(Date.now() / 1000),
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: null,
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'preview',
workspace: null,
}])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null }
await mod.listConversations(ctx)
expect(localListSessionsMock).toHaveBeenCalledWith(undefined, undefined, 5)
expect(listConversationSummariesMock).not.toHaveBeenCalled()
expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' })
})
it('lists all account-accessible single-chat sessions when only the active profile header is present', async () => {
listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }, { profile_name: 'travel' }])
localListSessionsMock.mockReturnValue([
{
id: 'default-session',
profile: 'default',
source: 'cli',
model: 'gpt-5',
title: 'Default',
started_at: 1,
ended_at: null,
last_active: 3,
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: '',
preview: '',
},
{
id: 'travel-session',
profile: 'travel',
source: 'cli',
model: 'gpt-5',
title: 'Travel',
started_at: 2,
ended_at: null,
last_active: 4,
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: '',
preview: '',
},
{
id: 'secret-session',
profile: 'secret',
source: 'cli',
model: 'gpt-5',
title: 'Secret',
started_at: 3,
ended_at: null,
last_active: 5,
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: '',
preview: '',
},
])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
query: {},
state: {
user: { id: 1, role: 'admin' },
profile: { name: 'travel' },
},
body: null,
}
await mod.list(ctx)
expect(localListSessionsMock).toHaveBeenCalledWith(undefined, undefined, 2000)
expect(ctx.body.sessions.map((session: any) => session.id)).toEqual(['default-session', 'travel-session'])
})
it('filters the single-chat session list when profile is explicitly provided', async () => {
localListSessionsMock.mockReturnValue([])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
query: { profile: 'travel' },
state: { profile: { name: 'default' } },
body: null,
}
await mod.list(ctx)
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
})
it('marks Hermes history sessions that already exist in the Web UI store', async () => {
localListSessionsMock.mockReturnValue([{ id: 'cli-1', profile: 'travel' }])
listSessionSummariesMock.mockResolvedValue([
{
id: 'cli-1',
source: 'cli',
model: 'gpt-5',
title: 'Imported',
started_at: 1,
ended_at: null,
last_active: 2,
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: '',
preview: '',
},
{
id: 'cli-2',
source: 'cli',
model: 'gpt-5',
title: 'History only',
started_at: 1,
ended_at: null,
last_active: 2,
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: '',
preview: '',
},
])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { profile: 'travel' }, state: {}, body: null }
await mod.listHermesSessions(ctx)
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
expect(listSessionSummariesMock).toHaveBeenCalledWith(undefined, 2000, 'travel')
expect(ctx.body.sessions).toEqual([
expect.objectContaining({ id: 'cli-1', profile: 'travel', webui_imported: true }),
expect.objectContaining({ id: 'cli-2', profile: 'travel', webui_imported: false }),
])
})
it('searches all account-accessible single-chat sessions unless profile is explicit', async () => {
localSearchSessionsMock.mockReturnValue([])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
query: { q: 'docker', limit: '10' },
state: { profile: { name: 'travel' } },
body: null,
}
await mod.search(ctx)
expect(localSearchSessionsMock).toHaveBeenCalledWith(undefined, 'docker', 10)
})
it('propagates local session store errors for conversation summaries', async () => {
localListSessionsMock.mockImplementation(() => {
throw new Error('db unavailable')
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { humanOnly: 'false' }, body: null }
await expect(mod.listConversations(ctx)).rejects.toThrow('db unavailable')
})
it('gets conversation messages from the local session store', async () => {
localGetSessionDetailMock.mockReturnValue({
id: 'root',
messages: [
{ id: 1, session_id: 'root', role: 'user', content: 'hello', timestamp: 1 },
{ id: 2, session_id: 'root', role: 'command', content: '/usage', timestamp: 2 },
],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'true' }, body: null }
await mod.getConversationMessages(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('root')
expect(getConversationDetailMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({
session_id: 'root',
messages: [{ id: 1, session_id: 'root', role: 'user', content: 'hello', timestamp: 1 }],
visible_count: 1,
thread_session_count: 1,
})
})
it('returns 404 when local conversation detail is missing', async () => {
localGetSessionDetailMock.mockReturnValue(null)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'false' }, body: null }
await mod.getConversationMessages(ctx)
expect(ctx.status).toBe(404)
expect(ctx.body).toEqual({ error: 'Conversation not found' })
})
it('prefers local session detail for Hermes history detail when available', async () => {
localGetSessionDetailMock.mockReturnValue({
id: 'cli-1',
source: 'cli',
title: 'Local complete',
messages: [
{ id: 1, session_id: 'cli-1', role: 'user', content: 'local full message', timestamp: 1 },
],
})
getSessionDetailFromDbMock.mockResolvedValue({
id: 'cli-1',
source: 'cli',
title: 'Hermes incomplete',
messages: [],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'cli-1' }, body: null }
await mod.getHermesSession(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('cli-1')
expect(getSessionDetailFromDbMock).not.toHaveBeenCalled()
expect(getSessionMock).not.toHaveBeenCalled()
expect(ctx.body.session).toMatchObject({
id: 'cli-1',
title: 'Local complete',
messages: [{ content: 'local full message' }],
})
})
it('falls back to Hermes state.db when local history detail is missing', async () => {
localGetSessionDetailMock.mockReturnValue(null)
getSessionDetailFromDbMock.mockResolvedValue({
id: 'hermes-1',
source: 'cli',
title: 'Hermes detail',
messages: [
{ id: 1, session_id: 'hermes-1', role: 'user', content: 'from hermes', timestamp: 1 },
],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'hermes-1' }, body: null }
await mod.getHermesSession(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('hermes-1')
expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('hermes-1')
expect(getSessionMock).not.toHaveBeenCalled()
expect(ctx.body.session).toMatchObject({
id: 'hermes-1',
title: 'Hermes detail',
messages: [{ content: 'from hermes' }],
})
})
it('reads Hermes history detail from the requested profile database', async () => {
localGetSessionDetailMock.mockReturnValue(null)
getSessionDetailFromDbWithProfileMock.mockResolvedValue({
id: 'travel-session',
source: 'cli',
title: 'Travel detail',
messages: [
{ id: 1, session_id: 'travel-session', role: 'user', content: 'from travel', timestamp: 1 },
],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'travel-session' }, query: { profile: 'travel' }, body: null }
await mod.getHermesSession(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('travel-session')
expect(getSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('travel-session', 'travel')
expect(getSessionDetailFromDbMock).not.toHaveBeenCalled()
expect(getSessionMock).not.toHaveBeenCalled()
expect(ctx.body.session).toMatchObject({
id: 'travel-session',
profile: 'travel',
title: 'Travel detail',
messages: [{ content: 'from travel' }],
})
})
it('does not return api_server sessions from the Hermes history detail endpoint', async () => {
localGetSessionDetailMock.mockReturnValue({
id: 'api-1',
source: 'api_server',
title: 'API Server',
messages: [{ id: 1, session_id: 'api-1', role: 'user', content: 'local api', timestamp: 1 }],
})
getSessionDetailFromDbMock.mockResolvedValue(null)
getSessionMock.mockResolvedValue(null)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'api-1' }, body: null }
await mod.getHermesSession(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('api-1')
expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('api-1')
expect(ctx.status).toBe(404)
expect(ctx.body).toEqual({ error: 'Session not found' })
})
it('returns native state.db usage analytics for the requested period', async () => {
const today = new Date().toISOString().slice(0, 10)
getLocalUsageStatsMock.mockReturnValue({
input_tokens: 10,
output_tokens: 5,
cache_read_tokens: 2,
cache_write_tokens: 1,
reasoning_tokens: 3,
sessions: 1,
by_model: [
{ model: 'local-model', input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1, reasoning_tokens: 3, sessions: 1 },
],
by_day: [
{ date: today, input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1, sessions: 1, errors: 0, cost: 0 },
],
})
getUsageStatsFromDbMock.mockResolvedValue({
input_tokens: 20,
output_tokens: 10,
cache_read_tokens: 4,
cache_write_tokens: 2,
reasoning_tokens: 6,
sessions: 2,
cost: 0.02,
total_api_calls: 7,
by_model: [
{ model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 },
],
by_day: [
{ date: today, input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, sessions: 2, errors: 0, cost: 0.02 },
],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { days: '2' }, body: null }
await mod.usageStats(ctx)
expect(getLocalUsageStatsMock).not.toHaveBeenCalled()
expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2)
expect(ctx.body).toMatchObject({
total_input_tokens: 20,
total_output_tokens: 10,
total_cache_read_tokens: 4,
total_cache_write_tokens: 2,
total_reasoning_tokens: 6,
total_sessions: 2,
total_cost: 0.02,
total_api_calls: 7,
period_days: 2,
})
expect(ctx.body.model_usage).toEqual([
{ model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 },
])
expect(ctx.body.daily_usage.find((row: any) => row.date === today)).toMatchObject({
input_tokens: 20,
output_tokens: 10,
cache_read_tokens: 4,
cache_write_tokens: 2,
sessions: 2,
cost: 0.02,
})
})
it('loads usage analytics from the request-scoped profile state database', async () => {
getUsageStatsFromDbMock.mockResolvedValue({
input_tokens: 12,
output_tokens: 6,
cache_read_tokens: 3,
cache_write_tokens: 1,
reasoning_tokens: 2,
sessions: 1,
cost: 0.01,
total_api_calls: 4,
by_model: [
{ model: 'research-model', input_tokens: 12, output_tokens: 6, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 },
],
by_day: [],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { days: '2' }, state: { profile: { name: 'research' } }, body: null }
await mod.usageStats(ctx)
expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2, undefined, 'research')
expect(ctx.body).toMatchObject({
total_input_tokens: 12,
total_output_tokens: 6,
total_sessions: 1,
total_cost: 0.01,
total_api_calls: 4,
})
expect(ctx.body.model_usage).toEqual([
{ model: 'research-model', input_tokens: 12, output_tokens: 6, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 },
])
})
it('keeps blank model usage as returned by state.db analytics', async () => {
getLocalUsageStatsMock.mockReturnValue({
input_tokens: 3,
output_tokens: 1,
cache_read_tokens: 2,
cache_write_tokens: 0,
reasoning_tokens: 0,
sessions: 1,
by_model: [
{ model: '', input_tokens: 3, output_tokens: 1, cache_read_tokens: 2, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 },
],
by_day: [],
})
getUsageStatsFromDbMock.mockResolvedValue({
input_tokens: 2,
output_tokens: 1,
cache_read_tokens: 1,
cache_write_tokens: 1,
reasoning_tokens: 0,
sessions: 1,
cost: 0,
total_api_calls: 0,
by_model: [
{ model: ' ', input_tokens: 2, output_tokens: 1, cache_read_tokens: 1, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 1 },
],
by_day: [],
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { days: '2' }, body: null }
await mod.usageStats(ctx)
expect(ctx.body.model_usage).toEqual([
{ model: ' ', input_tokens: 2, output_tokens: 1, cache_read_tokens: 1, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 1 },
])
})
it('sets a session model and provider in the local session store', async () => {
getSessionMock.mockReturnValue({ id: 'session-1' })
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
params: { id: 'session-1' },
request: { body: { model: 'grok-4', provider: 'xai' } },
body: null,
}
await mod.setModel(ctx)
expect(localCreateSessionMock).not.toHaveBeenCalled()
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', { model: 'grok-4', provider: 'xai' })
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 },
})
})
it('batch deletes sessions from their requested profiles', async () => {
listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }, { profile_name: 'travel' }])
getSessionMock.mockImplementation((id: string) => ({
id,
profile: id === 'travel-session' ? 'travel' : 'default',
}))
getExactSessionDetailFromDbWithProfileMock.mockResolvedValue({ id: 'matched', messages: [] })
deleteHermesSessionForProfileMock.mockResolvedValue(true)
localDeleteSessionMock.mockReturnValue(true)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
request: {
body: {
sessions: [
{ id: 'default-session', profile: 'default' },
{ id: 'travel-session', profile: 'travel' },
],
},
},
state: {
user: { id: 1, role: 'admin' },
},
body: null,
}
await mod.batchRemove(ctx)
expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('default-session', 'default')
expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('travel-session', 'travel')
expect(deleteHermesSessionForProfileMock).toHaveBeenCalledWith('default-session', 'default')
expect(deleteHermesSessionForProfileMock).toHaveBeenCalledWith('travel-session', 'travel')
expect(localDeleteSessionMock).toHaveBeenCalledWith('default-session')
expect(localDeleteSessionMock).toHaveBeenCalledWith('travel-session')
expect(ctx.body).toMatchObject({ ok: true, deleted: 2, failed: 0, hermesDeleted: 2 })
})
it('imports a Hermes session into the local Web UI store', async () => {
const hermesDetail = {
id: 'cli-1',
source: 'cli',
user_id: null,
model: 'gpt-5',
title: 'CLI run',
started_at: 100,
ended_at: 200,
end_reason: null,
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: '',
preview: 'hello',
last_active: 200,
thread_session_count: 1,
messages: [
{ id: 1, session_id: 'cli-1', role: 'user', content: 'hello', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 100, token_count: null, finish_reason: null, reasoning: null },
{ id: 2, session_id: 'cli-1', role: 'assistant', content: 'hi', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 101, token_count: null, finish_reason: null, reasoning: null, reasoning_details: { text: 'ok' } },
{ id: 3, session_id: 'cli-1', role: 'assistant', content: '', tool_call_id: null, tool_calls: [{ id: 'call-1', function: { name: 'read_file', arguments: { path: 'README.md' } } }], tool_name: null, timestamp: 102, token_count: null, finish_reason: 'tool_calls', reasoning: null },
{ id: 4, session_id: 'cli-1', role: 'tool', content: { ok: true }, tool_call_id: 'call-1', tool_calls: null, tool_name: 'read_file', timestamp: 103, token_count: null, finish_reason: null, reasoning: null },
{ id: 5, session_id: 'cli-1', role: 'tool', content: 'orphan', tool_call_id: null, tool_calls: null, tool_name: 'bad_tool', timestamp: 104, token_count: null, finish_reason: null, reasoning: null },
{ id: 6, session_id: 'cli-1', role: 'system', content: 'drop me', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 105, token_count: null, finish_reason: null, reasoning: null },
],
}
localGetSessionDetailMock.mockReturnValueOnce(null).mockReturnValueOnce({ ...hermesDetail, profile: 'travel' })
getSessionDetailFromDbWithProfileMock.mockResolvedValue(hermesDetail)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'cli-1' }, query: { profile: 'travel' }, state: {}, body: null }
await mod.importHermesSession(ctx)
expect(getSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('cli-1', 'travel')
expect(localCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({
id: 'cli-1',
profile: 'travel',
source: 'cli',
model: 'gpt-default',
provider: 'openai',
title: 'CLI run',
}))
expect(localUpdateSessionMock).toHaveBeenCalledWith('cli-1', expect.objectContaining({
source: 'cli',
model: 'gpt-default',
provider: 'openai',
}))
expect(localAddMessagesMock).toHaveBeenCalledWith([
expect.objectContaining({ session_id: 'cli-1', role: 'user', content: 'hello', tool_calls: null }),
expect.objectContaining({ session_id: 'cli-1', role: 'assistant', content: 'hi', reasoning_details: '{"text":"ok"}' }),
expect.objectContaining({
session_id: 'cli-1',
role: 'assistant',
content: '',
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'read_file', arguments: '{"path":"README.md"}' } }],
}),
expect.objectContaining({ session_id: 'cli-1', role: 'tool', content: '{"ok":true}', tool_call_id: 'call-1', tool_name: 'read_file' }),
])
expect(localUpdateSessionStatsMock).toHaveBeenCalledWith('cli-1')
expect(localUpdateSessionMock.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({
last_active: expect.any(Number),
}))
expect(localUpdateSessionMock.mock.calls.at(-1)?.[1].last_active).toBeGreaterThan(200)
expect(ctx.body).toMatchObject({ ok: true, imported: true })
})
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' }] }
localGetSessionDetailMock.mockReturnValue(sessionData)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const setMock = vi.fn()
const ctx: any = { params: { id: 'abc-123' }, query: {}, set: setMock, body: null }
await mod.exportSession(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('abc-123')
expect(setMock).toHaveBeenCalledWith('Content-Disposition', expect.stringContaining('abc-123'))
expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/json')
expect(ctx.status).toBeUndefined()
expect(JSON.parse(ctx.body)).toMatchObject({ id: 'abc-123', title: 'Test Session' })
})
it('returns full TXT export', async () => {
const sessionData = {
id: 'txt-123',
title: 'Text Export',
messages: [
{ id: 1, role: 'user', content: 'hello', timestamp: 1700000000 },
{ id: 2, role: 'assistant', content: 'hi', timestamp: 1700000001 },
],
}
localGetSessionDetailMock.mockReturnValue(sessionData)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const setMock = vi.fn()
const ctx: any = { params: { id: 'txt-123' }, query: { mode: 'full', ext: 'txt' }, set: setMock, body: null }
await mod.exportSession(ctx)
expect(setMock).toHaveBeenCalledWith('Content-Type', 'text/plain; charset=utf-8')
expect(ctx.body).toContain('# Text Export')
expect(ctx.body).toContain('[user]')
expect(ctx.body).toContain('hello')
expect(ctx.body).toContain('[assistant]')
expect(ctx.body).toContain('hi')
})
it('returns 404 when session not found', async () => {
localGetSessionDetailMock.mockReturnValue(null)
getSessionMock.mockResolvedValue(null)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'not-found' }, query: {}, set: vi.fn(), body: null }
await mod.exportSession(ctx)
expect(ctx.status).toBe(404)
expect(ctx.body).toEqual({ error: 'Session not found' })
})
it('falls back to CLI when DB query fails', async () => {
const sessionData = { id: 'cli-123', title: 'CLI Session', messages: [] }
localGetSessionDetailMock.mockReturnValue(sessionData)
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const setMock = vi.fn()
const ctx: any = { params: { id: 'cli-123' }, query: {}, set: setMock, body: null }
await mod.exportSession(ctx)
expect(localGetSessionDetailMock).toHaveBeenCalledWith('cli-123')
expect(JSON.parse(ctx.body)).toMatchObject({ id: 'cli-123' })
})
})
})
+293
View File
@@ -0,0 +1,293 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DatabaseSync } from 'node:sqlite'
const profileDir = vi.hoisted(() => ({ value: '' }))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: () => profileDir.value,
}))
function createStateDb(path: string) {
const db = new DatabaseSync(path)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
title TEXT,
started_at REAL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER,
tool_call_count INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
billing_provider TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
parent_session_id TEXT
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
reasoning_content TEXT
);
`)
return db
}
function insertSession(
db: DatabaseSync,
row: {
id: string
source?: string
parent_session_id?: string | null
title?: string
started_at: number
ended_at?: number | null
end_reason?: string | null
message_count?: number
model?: string
},
) {
db.prepare(`
INSERT INTO sessions (
id, source, user_id, model, title, 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,
estimated_cost_usd, actual_cost_usd, cost_status, parent_session_id
) VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, '', 0, NULL, '', ?)
`).run(
row.id,
row.source || 'api_server',
row.model || 'gpt-5.5',
row.title || '',
row.started_at,
row.ended_at ?? null,
row.end_reason ?? null,
row.message_count ?? 1,
row.parent_session_id ?? null,
)
}
function insertMessage(
db: DatabaseSync,
row: {
id: number
session_id: string
role?: string
content: string
timestamp: number
},
) {
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 (?, ?, ?, ?, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, NULL, NULL)
`).run(row.id, row.session_id, row.role || 'user', row.content, row.timestamp)
}
function seedCompressionChain(db: DatabaseSync) {
insertSession(db, {
id: 'root',
source: 'api_server',
title: 'Mermaid fix',
started_at: 100,
ended_at: 200,
end_reason: 'compression',
message_count: 2,
})
insertSession(db, {
id: 'middle',
source: 'cli',
parent_session_id: 'root',
title: 'Mermaid fix #2',
started_at: 201,
ended_at: 300,
end_reason: 'compression',
message_count: 3,
})
insertSession(db, {
id: 'tip',
source: 'cli',
parent_session_id: 'middle',
title: 'Mermaid fix #3',
started_at: 301,
ended_at: null,
end_reason: null,
message_count: 4,
})
insertMessage(db, { id: 1, session_id: 'root', content: 'root turn', timestamp: 101 })
insertMessage(db, { id: 2, session_id: 'middle', content: 'middle turn', timestamp: 202 })
insertMessage(db, { id: 3, session_id: 'tip', content: 'tip lineageunique turn', timestamp: 302 })
}
describe('session DB compression lineage', () => {
let tempDir = ''
let db: DatabaseSync | null = null
beforeEach(() => {
vi.resetModules()
tempDir = mkdtempSync(join(tmpdir(), 'wui-session-lineage-'))
profileDir.value = tempDir
db = createStateDb(join(tempDir, 'state.db'))
})
afterEach(() => {
db?.close()
db = null
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
})
it('projects compressed root summaries to the latest continuation tip', async () => {
seedCompressionChain(db!)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.listSessionSummaries(undefined, 20)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
id: 'tip',
title: 'Mermaid fix #3',
message_count: 4,
end_reason: null,
preview: 'tip lineageunique turn',
started_at: 100,
})
})
it.skip('returns the projected logical session when search matches continuation content (requires FTS5)', async () => {
seedCompressionChain(db!)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('lineageunique', undefined, 20)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
id: 'tip',
title: 'Mermaid fix #3',
matched_message_id: 3,
})
expect(rows[0].snippet).toContain('lineageunique')
})
it('hydrates the full compression chain when detail is requested by projected tip id', async () => {
seedCompressionChain(db!)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const detail = await mod.getSessionDetailFromDb('tip')
expect(detail).toMatchObject({
id: 'tip',
title: 'Mermaid fix #3',
message_count: 9,
thread_session_count: 3,
})
expect(detail?.messages.map(message => message.session_id)).toEqual(['root', 'middle', 'tip'])
})
it.skip('follows only the latest compression continuation child when a parent has multiple children (test logic needs fix)', async () => {
insertSession(db!, {
id: 'root',
started_at: 100,
ended_at: 200,
end_reason: 'compression',
message_count: 1,
})
insertSession(db!, {
id: 'older-child',
parent_session_id: 'root',
title: 'Older branch',
started_at: 201,
ended_at: null,
end_reason: null,
message_count: 1,
})
insertSession(db!, {
id: 'latest-child',
parent_session_id: 'root',
title: 'Latest branch',
started_at: 205,
ended_at: null,
end_reason: null,
message_count: 1,
})
insertMessage(db!, { id: 11, session_id: 'root', content: 'root', timestamp: 101 })
insertMessage(db!, { id: 12, session_id: 'older-child', content: 'older should not merge', timestamp: 202 })
insertMessage(db!, { id: 13, session_id: 'latest-child', content: 'latest should merge', timestamp: 206 })
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const detail = await mod.getSessionDetailFromDb('root')
expect(detail).toMatchObject({
id: 'root',
title: 'Latest branch',
message_count: 2,
thread_session_count: 2,
})
expect(detail?.messages.map(message => message.session_id)).toEqual(['root', 'latest-child'])
const olderDetail = await mod.getSessionDetailFromDb('older-child')
expect(olderDetail).toMatchObject({
id: 'older-child',
title: 'Older branch',
message_count: 2,
thread_session_count: 2,
})
expect(olderDetail?.messages.map(message => message.session_id)).toEqual(['root', 'older-child'])
})
it('applies source filters before search candidate limiting', async () => {
for (let index = 0; index < 105; index += 1) {
insertSession(db!, {
id: `cli-${index}`,
source: 'cli',
title: `needle cli ${index}`,
started_at: 1000 + index,
ended_at: null,
end_reason: null,
})
}
insertSession(db!, {
id: 'telegram-match',
source: 'telegram',
title: 'needle telegram target',
started_at: 10,
ended_at: null,
end_reason: null,
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('needle', 'telegram', 1)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
id: 'telegram-match',
source: 'telegram',
title: 'needle telegram target',
})
})
})
+754
View File
@@ -0,0 +1,754 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const allMock = vi.fn()
const indexAllMock = vi.fn()
const titleAllMock = vi.fn()
const contentAllMock = vi.fn()
const likeAllMock = vi.fn()
const prepareMock = vi.fn((sql: string) => {
if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock })
if (sql.includes('JOIN messages m') && sql.includes('LIKE')) return ({ all: likeAllMock })
if (sql.includes('base.title') && sql.includes('LIKE')) return ({ all: titleAllMock })
// loadAllSessions: full table scan — contains parent_session_id but NOT base/CTE/WHERE
if (sql.includes('parent_session_id AS parent_session_id') && !sql.includes('base') && !sql.includes('parent_session_id IS NULL')) return ({ all: indexAllMock })
return ({ all: allMock })
})
const closeMock = vi.fn()
const databaseSyncMock = vi.fn(() => ({ prepare: prepareMock, close: closeMock }))
const getActiveProfileDirMock = vi.fn(() => '/tmp/hermes-profile')
vi.doMock('node:sqlite', () => ({
DatabaseSync: databaseSyncMock,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: getActiveProfileDirMock,
}))
describe('session DB summaries', () => {
beforeEach(() => {
vi.resetModules()
allMock.mockReset()
indexAllMock.mockReset()
indexAllMock.mockReturnValue([])
titleAllMock.mockReset()
contentAllMock.mockReset()
likeAllMock.mockReset()
prepareMock.mockClear()
closeMock.mockClear()
databaseSyncMock.mockClear()
getActiveProfileDirMock.mockReset()
getActiveProfileDirMock.mockReturnValue('/tmp/hermes-profile')
})
it('queries sqlite for lightweight session summaries', async () => {
allMock.mockReturnValue([
{
id: 's1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Named session',
started_at: 1710000000,
ended_at: null,
end_reason: '',
message_count: 3,
tool_call_count: 1,
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openrouter',
estimated_cost_usd: 0.01,
actual_cost_usd: null,
cost_status: 'estimated',
preview: 'hello world',
last_active: 1710000005,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.listSessionSummaries(undefined, 50)
expect(databaseSyncMock).toHaveBeenCalledWith('/tmp/hermes-profile/state.db', { open: true, readOnly: true })
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining("s.source != 'tool'"))
expect(allMock).toHaveBeenCalledWith(200)
expect(closeMock).toHaveBeenCalled()
expect(rows).toEqual([
{
id: 's1',
source: 'cli',
user_id: null,
model: 'openai/gpt-5.4',
title: 'Named session',
started_at: 1710000000,
ended_at: null,
end_reason: null,
message_count: 3,
tool_call_count: 1,
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openrouter',
estimated_cost_usd: 0.01,
actual_cost_usd: null,
cost_status: 'estimated',
preview: 'hello world',
last_active: 1710000005,
},
])
})
it('adds source filter and falls back last_active to started_at', async () => {
allMock.mockReturnValue([
{
id: 's2',
source: 'telegram',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710000100,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 4,
output_tokens: 5,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'preview text',
last_active: null,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.listSessionSummaries('telegram', 2)
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining("s.source != 'tool'"))
expect(allMock).toHaveBeenCalledWith('telegram', 8)
expect(rows[0].last_active).toBe(1710000100)
expect(rows[0].source).toBe('telegram')
expect(rows[0].title).toBe('preview text')
})
it('searches session titles and content with deduped results', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Docker debugging',
started_at: 1710001000,
ended_at: null,
end_reason: '',
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: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710001005,
matched_message_id: null,
snippet: 'Docker debugging',
rank: 0,
},
])
contentAllMock.mockReturnValue([
{
id: 'title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Docker debugging',
started_at: 1710001000,
ended_at: null,
end_reason: '',
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: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710001005,
matched_message_id: 42,
snippet: '>>>docker<<< compose up',
rank: 0.25,
},
{
id: 'content-2',
source: 'telegram',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'content preview',
last_active: 1710002001,
matched_message_id: 7,
snippet: '>>>docker<<< swarm',
rank: 0.1,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('messages_fts MATCH'))
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('title-1')
expect(rows[0].matched_message_id).toBeNull()
expect(rows[0].snippet).toBe('Docker debugging')
expect(rows[1].id).toBe('content-2')
expect(rows[1].matched_message_id).toBe(7)
expect(rows[1].snippet).toContain('docker')
})
it('falls back to literal content search for punctuation-only queries instead of unsafe FTS', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('fts5: syntax error near "."')
})
likeAllMock.mockReturnValue([
{
id: 'dot-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710004000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'punctuation preview',
last_active: 1710004001,
matched_message_id: 21,
snippet: 'value.with.dot',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('.', undefined, 10)
expect(contentAllMock).not.toHaveBeenCalled()
expect(likeAllMock).toHaveBeenCalled()
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('dot-1')
})
it('keeps safe dotted queries on the FTS path', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockReturnValue([
{
id: 'node-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Node.js notes',
started_at: 1710004500,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'dotted preview',
last_active: 1710004501,
matched_message_id: 22,
snippet: '>>>node.js<<< runtime',
rank: 0.2,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('node.js', undefined, 10)
expect(contentAllMock).toHaveBeenCalled()
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('node-1')
})
it('keeps explicit wildcard dotted queries on the FTS path with valid syntax', async () => {
titleAllMock.mockReturnValue([
{
id: 'node-wildcard-title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Node.js wildcard notes',
started_at: 1710004590,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'wildcard title preview',
last_active: 1710004595,
matched_message_id: null,
snippet: 'Node.js wildcard notes',
rank: 0,
},
])
contentAllMock.mockReturnValue([
{
id: 'node-wildcard-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Node.js wildcard notes',
started_at: 1710004600,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'wildcard dotted preview',
last_active: 1710004601,
matched_message_id: 24,
snippet: '>>>node.js<<< runtime',
rank: 0.15,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('node.js*', undefined, 10)
expect(titleAllMock).toHaveBeenCalledWith('%node.js%', 200)
expect(contentAllMock).toHaveBeenCalledWith('"node.js"*', 200)
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('node-wildcard-title-1')
expect(rows[1].id).toBe('node-wildcard-1')
})
it('keeps quoted wildcard dotted queries on the FTS path with valid syntax', async () => {
titleAllMock.mockReturnValue([
{
id: 'node-quoted-title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Quoted Node.js wildcard notes',
started_at: 1710004640,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'quoted title preview',
last_active: 1710004645,
matched_message_id: null,
snippet: 'Quoted Node.js wildcard notes',
rank: 0,
},
])
contentAllMock.mockReturnValue([
{
id: 'node-quoted-wildcard-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Quoted Node.js wildcard notes',
started_at: 1710004650,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'quoted wildcard dotted preview',
last_active: 1710004651,
matched_message_id: 25,
snippet: '>>>node.js<<< runtime',
rank: 0.12,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('"node.js"*', undefined, 10)
expect(titleAllMock).toHaveBeenCalledWith('%node.js%', 200)
expect(contentAllMock).toHaveBeenCalledWith('"node.js"*', 200)
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('node-quoted-title-1')
expect(rows[1].id).toBe('node-quoted-wildcard-1')
})
it('keeps non-ASCII dotted queries on the safe quoted FTS path', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockReturnValue([
{
id: 'unicode-dot-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'naïve.js note',
started_at: 1710004700,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'unicode dotted preview',
last_active: 1710004701,
matched_message_id: 23,
snippet: 'naïve.js runtime',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('naïve.js', undefined, 10)
expect(contentAllMock).toHaveBeenCalledWith('"naïve.js"', 200)
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('unicode-dot-1')
})
it('escapes LIKE wildcards for literal special-character searches', async () => {
titleAllMock.mockReturnValue([])
likeAllMock.mockReturnValue([
{
id: 'percent-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '100% reproducible',
started_at: 1710005000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'literal percent preview',
last_active: 1710005001,
matched_message_id: 31,
snippet: '100% reproducible',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('100%', undefined, 10)
expect(titleAllMock).toHaveBeenCalledWith('%100\\%%', 200)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('percent-1')
})
it('uses literal search for CJK queries even when FTS returns no rows', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockReturnValue([])
likeAllMock.mockReturnValue([
{
id: 'cjk-literal-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002980,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '中文内容预览',
last_active: 1710002985,
matched_message_id: 10,
snippet: '这里也有记忆断裂',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
expect(contentAllMock).not.toHaveBeenCalled()
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%', 200)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('cjk-literal-1')
})
it('falls back to LIKE search for CJK queries while preserving title matches', async () => {
titleAllMock.mockReturnValue([
{
id: 'cjk-title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '记忆断裂标题',
started_at: 1710002990,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710002995,
matched_message_id: null,
snippet: '记忆断裂标题',
rank: 0,
},
])
contentAllMock.mockImplementation(() => {
throw new Error('fts5 tokenizer miss')
})
likeAllMock.mockReturnValue([
{
id: 'cjk-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710003000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '中文预览',
last_active: 1710003002,
matched_message_id: 11,
snippet: '这是一段记忆断裂的内容',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%', 200)
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('cjk-1')
expect(rows[1].id).toBe('cjk-title-1')
expect(rows[0].snippet).toContain('记忆断裂')
})
it('falls back to title results when FTS content query fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('database malformed')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(rows).toEqual([])
expect(likeAllMock).not.toHaveBeenCalled()
})
it('falls back to title results for numeric queries when FTS fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toEqual([])
expect(likeAllMock).not.toHaveBeenCalled()
})
it('falls back to title results for numeric queries with source filter when FTS fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', 'telegram', 10)
expect(rows).toEqual([])
})
it('returns title matches for numeric queries even when content search fails', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-123',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Issue 123',
started_at: 1710002900,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title numeric preview',
last_active: 1710002910,
matched_message_id: null,
snippet: 'Issue 123',
rank: 0,
},
])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('title-123')
expect(rows[0].title).toBe('Issue 123')
})
it('falls back to title results for non-numeric queries when FTS fails', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(rows).toEqual([])
})
it('falls back to title results for any query when FTS has unrelated database failure', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('database malformed')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toEqual([])
expect(likeAllMock).not.toHaveBeenCalled()
})
})
+151
View File
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const listConversationsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'conversation-1' }] } })
const getConversationMessagesMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
const getConversationMessagesPaginatedMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [], pagination: {} } })
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
const listHermesSessionsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'hermes-1' }] } })
const getHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
const importHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id } })
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
const removeMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const renameMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const setWorkspaceMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const setModelMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
const listWorkspaceFoldersMock = vi.fn(async (ctx: any) => { ctx.body = { folders: [] } })
const usageBatchMock = vi.fn(async (ctx: any) => { ctx.body = {} })
const usageSingleMock = vi.fn(async (ctx: any) => { ctx.body = { input_tokens: 0, output_tokens: 0 } })
const usageStatsMock = vi.fn(async (ctx: any) => { ctx.body = { total_input_tokens: 0, total_output_tokens: 0 } })
const contextLengthMock = vi.fn(async (ctx: any) => { ctx.body = { context_length: 256000 } })
const batchRemoveMock = vi.fn(async (ctx: any) => { ctx.body = { deleted: 1, failed: 0, errors: [] } })
const exportSessionMock = vi.fn(async (ctx: any) => { ctx.body = JSON.stringify({ id: ctx.params.id }) })
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
listConversations: listConversationsMock,
getConversationMessages: getConversationMessagesMock,
getConversationMessagesPaginated: getConversationMessagesPaginatedMock,
list: listMock,
listHermesSessions: listHermesSessionsMock,
getHermesSession: getHermesSessionMock,
importHermesSession: importHermesSessionMock,
search: searchMock,
get: getMock,
remove: removeMock,
batchRemove: batchRemoveMock,
rename: renameMock,
setWorkspace: setWorkspaceMock,
setModel: setModelMock,
listWorkspaceFolders: listWorkspaceFoldersMock,
usageBatch: usageBatchMock,
usageSingle: usageSingleMock,
usageStats: usageStatsMock,
contextLength: contextLengthMock,
exportSession: exportSessionMock,
}))
describe('session routes', () => {
beforeEach(() => {
vi.resetModules()
listConversationsMock.mockClear()
getConversationMessagesMock.mockClear()
getConversationMessagesPaginatedMock.mockClear()
listMock.mockClear()
listHermesSessionsMock.mockClear()
getHermesSessionMock.mockClear()
importHermesSessionMock.mockClear()
searchMock.mockClear()
getMock.mockClear()
removeMock.mockClear()
renameMock.mockClear()
setModelMock.mockClear()
})
it('registers conversations, session list, and search routes', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const paths = sessionRoutes.stack.map((entry: any) => entry.path)
expect(paths).toEqual(expect.arrayContaining([
'/api/hermes/sessions/conversations',
'/api/hermes/sessions/conversations/:id/messages',
'/api/hermes/sessions/conversations/:id/messages/paginated',
'/api/hermes/sessions',
'/api/hermes/sessions/hermes',
'/api/hermes/sessions/hermes/:id',
'/api/hermes/sessions/hermes/:id/import',
'/api/hermes/search/sessions',
'/api/hermes/sessions/search',
'/api/hermes/sessions/usage',
'/api/hermes/usage/stats',
'/api/hermes/sessions/context-length',
'/api/hermes/sessions/:id',
'/api/hermes/sessions/:id/export',
'/api/hermes/sessions/:id/usage',
'/api/hermes/sessions/:id/rename',
'/api/hermes/sessions/:id/model',
]))
})
it('delegates session search to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/search/sessions')
const handler = layer.stack[0]
const ctx: any = { query: { q: 'docker', limit: '8' }, body: null, params: {} }
await handler(ctx)
expect(searchMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
})
it('keeps the legacy search path wired to the same controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/search')
const handler = layer.stack[0]
const ctx: any = { query: { q: 'docker' }, body: null, params: {} }
await handler(ctx)
expect(searchMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
})
it('delegates conversations list and detail routes', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
const listCtx: any = { query: {}, body: null, params: {} }
await listLayer.stack[0](listCtx)
expect(listConversationsMock).toHaveBeenCalledWith(listCtx)
expect(listCtx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
const detailCtx: any = { params: { id: 'child-session' }, query: {}, body: null }
await detailLayer.stack[0](detailCtx)
expect(getConversationMessagesMock).toHaveBeenCalledWith(detailCtx)
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
})
it('delegates Hermes session import to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/hermes/:id/import')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'hermes-abc' }, query: {}, request: { body: { profile: 'default' } }, body: null }
await handler(ctx)
expect(importHermesSessionMock).toHaveBeenCalledWith(ctx)
expect(ctx.body).toEqual({ session_id: 'hermes-abc' })
})
it('delegates session export to the controller', async () => {
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/export')
const handler = layer.stack[0]
const ctx: any = { params: { id: 'session-abc' }, query: {}, body: null, set: vi.fn() }
await handler(ctx)
expect(exportSessionMock).toHaveBeenCalledWith(ctx)
})
})
+101
View File
@@ -0,0 +1,101 @@
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')
})
it('syncs bundled skills into default and named profiles only touching bundled names', async () => {
const source = await tempDir('hermes-skill-source-')
const hermesHome = await tempDir('hermes-skill-home-')
process.env.HERMES_HOME = hermesHome
await mkdir(join(source, 'webui-skill'), { recursive: true })
await writeFile(join(source, 'webui-skill', 'SKILL.md'), '# WebUI Skill\n', 'utf-8')
await mkdir(join(hermesHome, 'skills', 'webui-skill'), { recursive: true })
await writeFile(join(hermesHome, 'skills', 'webui-skill', 'SKILL.md'), '# Old WebUI Skill\n', 'utf-8')
await mkdir(join(hermesHome, 'skills', 'local-skill'), { recursive: true })
await writeFile(join(hermesHome, 'skills', 'local-skill', 'SKILL.md'), '# Local Skill\n', 'utf-8')
await mkdir(join(hermesHome, 'profiles', 'alpha', 'skills'), { recursive: true })
await mkdir(join(hermesHome, 'profiles', 'beta', 'skills', 'webui-skill'), { recursive: true })
await writeFile(join(hermesHome, 'profiles', 'beta', 'skills', 'webui-skill', 'SKILL.md'), '# Old Profile Skill\n', 'utf-8')
await mkdir(join(hermesHome, 'profiles', 'beta', 'skills', 'profile-local'), { recursive: true })
await writeFile(join(hermesHome, 'profiles', 'beta', 'skills', 'profile-local', 'SKILL.md'), '# Profile Local\n', 'utf-8')
const { HermesSkillInjector } = await import('../../packages/server/src/services/hermes/skill-injector')
const result = await new HermesSkillInjector(source).injectMissingSkills()
expect(result.targets.map(target => target.targetDir)).toEqual([
join(hermesHome, 'skills'),
join(hermesHome, 'profiles', 'alpha', 'skills'),
join(hermesHome, 'profiles', 'beta', 'skills'),
])
expect(result.injected).toEqual(['webui-skill'])
expect(result.updated).toEqual(['webui-skill', 'webui-skill'])
await expect(readFile(join(hermesHome, 'skills', 'webui-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# WebUI Skill\n')
await expect(readFile(join(hermesHome, 'profiles', 'alpha', 'skills', 'webui-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# WebUI Skill\n')
await expect(readFile(join(hermesHome, 'profiles', 'beta', 'skills', 'webui-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# WebUI Skill\n')
await expect(readFile(join(hermesHome, 'skills', 'local-skill', 'SKILL.md'), 'utf-8')).resolves.toBe('# Local Skill\n')
await expect(readFile(join(hermesHome, 'profiles', 'beta', 'skills', 'profile-local', 'SKILL.md'), 'utf-8')).resolves.toBe('# Profile Local\n')
})
})
+211
View File
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
const profileMock = vi.hoisted(() => ({
getActiveProfileDir: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: profileMock.getActiveProfileDir,
getProfileDir: vi.fn(),
}))
function createStateDb(): string {
const dir = mkdtempSync(join(tmpdir(), 'hermes-skill-usage-'))
const db = new DatabaseSync(join(dir, 'state.db'))
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
started_at INTEGER
);
CREATE INDEX idx_sessions_started ON sessions(started_at);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp INTEGER
);
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
`)
db.close()
return dir
}
function insertSession(dir: string, row: { id: string; source?: string; started_at: number }) {
const db = new DatabaseSync(join(dir, 'state.db'))
db.prepare('INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)')
.run(row.id, row.source ?? 'cli', row.started_at)
db.close()
}
function insertToolResult(dir: string, row: {
sessionId: string
timestamp: number
toolName?: string | null
toolCallId?: string | null
content: string
}) {
const db = new DatabaseSync(join(dir, 'state.db'))
db.prepare('INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, timestamp) VALUES (?, ?, ?, ?, ?, ?)')
.run(row.sessionId, 'tool', row.content, row.toolCallId ?? null, row.toolName ?? null, row.timestamp)
db.close()
}
function insertAssistantToolCalls(dir: string, sessionId: string, timestamp: number, toolCalls: unknown) {
const db = new DatabaseSync(join(dir, 'state.db'))
db.prepare('INSERT INTO messages (session_id, role, tool_calls, timestamp) VALUES (?, ?, ?, ?)')
.run(sessionId, 'assistant', JSON.stringify(toolCalls), timestamp)
db.close()
}
describe('Hermes skill usage analytics DB aggregation', () => {
let profileDir: string | null = null
beforeEach(() => {
vi.resetModules()
profileMock.getActiveProfileDir.mockReset()
})
afterEach(() => {
if (profileDir) rmSync(profileDir, { recursive: true, force: true })
profileDir = null
})
it('counts completed skill loads and edits from compact tool result rows across CLI and API-server sessions inside the requested period', async () => {
const now = 1_700_000_000
profileDir = createStateDb()
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
insertSession(profileDir, { id: 'recent-cli', source: 'cli', started_at: now - 60 })
insertToolResult(profileDir, {
sessionId: 'recent-cli',
timestamp: now - 50,
content: '[skill_view] name=hermes-agent (64,764 chars)',
})
insertToolResult(profileDir, {
sessionId: 'recent-cli',
timestamp: now - 45,
toolName: 'skill_view',
content: '[skill_view] name=hermes-agent (64,764 chars)',
})
insertToolResult(profileDir, {
sessionId: 'recent-cli',
timestamp: now - 40,
toolName: 'skill_manage',
content: JSON.stringify({ success: true, message: "Patched SKILL.md in skill 'hermes-agent' (1 replacement)." }),
})
insertToolResult(profileDir, {
sessionId: 'recent-cli',
timestamp: now - 35,
content: '[skill_view] name=github-pr-workflow (22,106 chars)',
})
insertAssistantToolCalls(profileDir, 'recent-cli', now - 30, [
{ function: { name: 'skill_view', arguments: JSON.stringify({ name: 'planned-but-not-counted' }) } },
])
insertToolResult(profileDir, {
sessionId: 'recent-cli',
timestamp: now - 25,
toolName: 'terminal',
content: 'noop',
})
insertSession(profileDir, { id: 'web-api-session', source: 'api_server', started_at: now - 30 })
insertAssistantToolCalls(profileDir, 'web-api-session', now - 22, [
{
id: 'call_api_skill_view',
call_id: 'call_api_skill_view',
type: 'function',
function: { name: 'skill_view', arguments: JSON.stringify({ name: 'api-server-skill' }) },
},
])
insertToolResult(profileDir, {
sessionId: 'web-api-session',
timestamp: now - 20,
toolCallId: 'call_api_skill_view',
content: JSON.stringify({ success: true, name: 'api-server-skill', description: 'API-server JSON tool result' }),
})
insertSession(profileDir, { id: 'old-cli', source: 'cli', started_at: now - 10 * 86400 })
insertToolResult(profileDir, {
sessionId: 'old-cli',
timestamp: now - 10 * 86400,
content: '[skill_view] name=old-skill (1 chars)',
})
insertSession(profileDir, { id: 'long-running-cli', source: 'cli', started_at: now - 10 * 86400 })
insertToolResult(profileDir, {
sessionId: 'long-running-cli',
timestamp: now - 40,
content: '[skill_view] name=late-session-skill (1 chars)',
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const result = await mod.getSkillUsageStatsFromDb(7, now)
expect(result).toEqual({
period_days: 7,
summary: {
total_skill_loads: 5,
total_skill_edits: 1,
total_skill_actions: 6,
distinct_skills_used: 4,
},
by_day: [
{
date: '2023-11-14',
view_count: 5,
manage_count: 1,
total_count: 6,
skills: [
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
{ skill: 'api-server-skill', view_count: 1, manage_count: 0, total_count: 1 },
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
{ skill: 'late-session-skill', view_count: 1, manage_count: 0, total_count: 1 },
],
},
],
top_skills: [
{
skill: 'hermes-agent',
view_count: 2,
manage_count: 1,
total_count: 3,
percentage: 50,
last_used_at: now - 40,
},
{
skill: 'api-server-skill',
view_count: 1,
manage_count: 0,
total_count: 1,
percentage: 1 / 6 * 100,
last_used_at: now - 20,
},
{
skill: 'github-pr-workflow',
view_count: 1,
manage_count: 0,
total_count: 1,
percentage: 1 / 6 * 100,
last_used_at: now - 35,
},
{
skill: 'late-session-skill',
view_count: 1,
manage_count: 0,
total_count: 1,
percentage: 1 / 6 * 100,
last_used_at: now - 40,
},
],
})
})
})
+146
View File
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn())
const mockGetActiveProfileName = vi.hoisted(() => vi.fn())
const mockGetProfileDir = vi.hoisted(() => vi.fn())
const mockUpdateConfigYamlForProfile = vi.hoisted(() => vi.fn())
const mockReadConfigYamlForProfile = vi.hoisted(() => vi.fn())
const mockSafeReadFile = vi.hoisted(() => vi.fn())
const mockExtractDescription = vi.hoisted(() => vi.fn())
const mockListFilesRecursive = vi.hoisted(() => vi.fn())
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: mockGetActiveProfileName,
getProfileDir: mockGetProfileDir,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYamlForProfile: mockReadConfigYamlForProfile,
updateConfigYamlForProfile: mockUpdateConfigYamlForProfile,
safeReadFile: mockSafeReadFile,
extractDescription: mockExtractDescription,
listFilesRecursive: mockListFilesRecursive,
}))
async function loadController() {
vi.resetModules()
return import('../../packages/server/src/controllers/hermes/skills')
}
describe('skills controller', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetActiveProfileName.mockReturnValue('default')
mockGetProfileDir.mockImplementation((profile: string) => `/tmp/hermes-${profile}`)
mockReadConfigYamlForProfile.mockResolvedValue({})
mockSafeReadFile.mockImplementation(async (path: string) => {
try {
return await readFile(path, 'utf-8')
} catch {
return null
}
})
mockExtractDescription.mockImplementation((content: string) => {
return content.split('\n').find(line => line.trim() && !line.startsWith('#'))?.trim() || ''
})
mockListFilesRecursive.mockResolvedValue([])
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => updater({}))
mockGetSkillUsageStatsFromDb.mockResolvedValue({
period_days: 7,
summary: {
total_skill_loads: 0,
total_skill_edits: 0,
total_skill_actions: 0,
distinct_skills_used: 0,
},
by_day: [],
top_skills: [],
})
})
it('loads skill usage from the request-scoped profile state database', async () => {
const { usageStats } = await loadController()
const ctx: any = { query: { days: '30' }, state: { profile: { name: 'research' } }, body: null }
await usageStats(ctx)
expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(30, undefined, 'research')
expect(ctx.body.period_days).toBe(7)
})
it('falls back to active profile when no request profile is set', async () => {
mockGetActiveProfileName.mockReturnValue('travel')
const { usageStats } = await loadController()
const ctx: any = { query: {}, state: {}, body: null }
await usageStats(ctx)
expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(7, undefined, 'travel')
})
it('toggles skills in the request-scoped profile config', async () => {
let updatedConfig: Record<string, any> | undefined
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => {
updatedConfig = await updater({ skills: { disabled: ['old-skill'] }, model: { default: 'glm-5.1' } })
return undefined
})
const { toggle } = await loadController()
const ctx: any = {
request: { body: { name: 'new-skill', enabled: false } },
state: { profile: { name: 'research' } },
body: null,
}
await toggle(ctx)
expect(mockUpdateConfigYamlForProfile).toHaveBeenCalledWith('research', expect.any(Function))
expect(updatedConfig).toEqual({
skills: { disabled: ['old-skill', 'new-skill'] },
model: { default: 'glm-5.1' },
})
expect(ctx.body).toEqual({ success: true })
})
it('lists configured external skill directories with external source while keeping local skills first', async () => {
const root = await mkdtemp(join(tmpdir(), 'hermes-web-ui-external-skills-'))
const profileDir = join(root, 'profile')
const localSkillDir = join(profileDir, 'skills', 'tools', 'dupe-skill')
const externalDir = join(root, 'external-skills')
const externalSkillDir = join(externalDir, 'tools', 'external-skill')
const externalDupeDir = join(externalDir, 'tools', 'dupe-skill')
await mkdir(localSkillDir, { recursive: true })
await mkdir(externalSkillDir, { recursive: true })
await mkdir(externalDupeDir, { recursive: true })
await writeFile(join(localSkillDir, 'SKILL.md'), '# Local Dupe\nlocal copy\n', 'utf-8')
await writeFile(join(externalSkillDir, 'SKILL.md'), '# External Skill\nexternal copy\n', 'utf-8')
await writeFile(join(externalDupeDir, 'SKILL.md'), '# External Dupe\nexternal duplicate\n', 'utf-8')
mockGetProfileDir.mockReturnValue(profileDir)
mockReadConfigYamlForProfile.mockResolvedValue({
skills: { external_dirs: [externalDir] },
})
try {
const { list } = await loadController()
const ctx: any = { state: { profile: { name: 'research' } }, body: null }
await list(ctx)
const tools = ctx.body.categories.find((category: any) => category.name === 'tools')
expect(tools.skills).toEqual([
expect.objectContaining({ name: 'dupe-skill', source: 'local', description: 'local copy' }),
expect.objectContaining({ name: 'external-skill', source: 'external', description: 'external copy' }),
])
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
+41
View File
@@ -0,0 +1,41 @@
import { mkdtempSync, mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveTerminalCwd } from '../../packages/server/src/routes/hermes/terminal'
const tmpRoots: string[] = []
function makeTmpRoot() {
const root = mkdtempSync(join(tmpdir(), 'wui-terminal-cwd-'))
tmpRoots.push(root)
return root
}
afterEach(() => {
for (const root of tmpRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('terminal cwd resolution', () => {
it('defaults terminal sessions to the active Hermes profile directory', () => {
const profileDir = makeTmpRoot()
expect(resolveTerminalCwd({}, profileDir)).toBe(profileDir)
})
it('resolves relative configured cwd from the Hermes profile directory', () => {
const profileDir = makeTmpRoot()
mkdirSync(join(profileDir, 'workspace'))
expect(resolveTerminalCwd({ cwd: 'workspace' }, profileDir)).toBe(join(profileDir, 'workspace'))
})
it('uses absolute configured cwd when it exists', () => {
const profileDir = makeTmpRoot()
const cwd = makeTmpRoot()
expect(resolveTerminalCwd({ cwd }, profileDir)).toBe(cwd)
})
it('falls back to the profile directory when configured cwd is missing', () => {
const profileDir = makeTmpRoot()
expect(resolveTerminalCwd({ cwd: 'missing' }, profileDir)).toBe(profileDir)
})
})
+309
View File
@@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter, dirname, join } from 'path'
type UpdateControllerMocks = {
execFile: ReturnType<typeof vi.fn>
execFileSync: ReturnType<typeof vi.fn>
spawn: ReturnType<typeof vi.fn>
unref: ReturnType<typeof vi.fn>
existsSync: ReturnType<typeof vi.fn>
readFileSync: ReturnType<typeof vi.fn>
appendFileSync: ReturnType<typeof vi.fn>
}
async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) {
const execFile = overrides.execFile ?? vi.fn((_command: string, _args: string[], _options: any, callback: any) => callback(null, '', ''))
const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated')
const unref = overrides.unref ?? vi.fn()
const spawn = overrides.spawn ?? vi.fn(() => ({ unref, on: vi.fn() }))
const existsSync = overrides.existsSync ?? vi.fn(() => true)
const readFileSync = overrides.readFileSync ?? vi.fn(() => JSON.stringify({
name: 'hermes-web-ui',
version: '0.0.0',
repository: { url: 'https://github.com/EKKOLearnAI/hermes-web-ui.git' },
}))
const appendFileSync = overrides.appendFileSync ?? vi.fn()
vi.resetModules()
vi.doMock('child_process', () => ({ execFile, execFileSync, spawn }))
vi.doMock('fs', () => ({
appendFileSync,
closeSync: vi.fn(),
existsSync,
mkdirSync: vi.fn(),
openSync: vi.fn(() => 1),
readFileSync,
rmSync: vi.fn(),
writeFileSync: vi.fn(),
}))
const mod = await import('../../packages/server/src/controllers/update')
return {
...mod,
mocks: { execFile, execFileSync, spawn, unref, existsSync, readFileSync, appendFileSync },
}
}
function createMockCtx() {
return {
status: 200,
body: null as unknown,
}
}
function getNodeBinDir() {
return dirname(process.execPath)
}
function getNodePrefix() {
return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir())
}
function getNpmCliPath() {
const prefix = getNodePrefix()
return process.platform === 'win32'
? join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js')
: join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
}
function getGlobalCliScript(prefix: string) {
return process.platform === 'win32'
? join(prefix, 'node_modules', 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
: join(prefix, 'lib', 'node_modules', 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
}
describe('update controller', () => {
const originalPort = process.env.PORT
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
vi.doUnmock('child_process')
vi.doUnmock('fs')
vi.unstubAllGlobals()
if (originalPort === undefined) {
delete process.env.PORT
} else {
process.env.PORT = originalPort
}
delete process.env.HERMES_WEB_UI_PREVIEW_REPO
})
it('updates and restarts through the running Node executable, not PATH shims', async () => {
process.env.PORT = '9129'
const nodeBinDir = getNodeBinDir()
const npmCli = getNpmCliPath()
const globalPrefix = getNodePrefix()
const cliScript = getGlobalCliScript(globalPrefix)
const execFileSync = vi.fn((_command: string, args: string[]) => {
if (args[1] === 'root') {
return process.platform === 'win32'
? join(globalPrefix, 'node_modules')
: join(globalPrefix, 'lib', 'node_modules')
}
return 'updated'
})
const { handleUpdate, mocks } = await loadUpdateController({ execFileSync })
const ctx = createMockCtx()
await handleUpdate(ctx)
expect(mocks.execFileSync).toHaveBeenCalledWith(
process.execPath,
[npmCli, 'install', '-g', 'hermes-web-ui@latest'],
expect.objectContaining({
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}`),
}),
}),
)
expect(ctx.body).toEqual({ success: true, message: 'updated' })
vi.runAllTimers()
expect(mocks.execFileSync).toHaveBeenCalledWith(
process.execPath,
[npmCli, 'root', '-g'],
expect.objectContaining({
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
env: expect.objectContaining({ npm_node_execpath: process.execPath }),
}),
)
expect(mocks.spawn).toHaveBeenCalledWith(
process.execPath,
[cliScript, 'restart', '--port', '9129'],
expect.objectContaining({
detached: true,
stdio: 'ignore',
windowsHide: true,
env: expect.objectContaining({ npm_node_execpath: process.execPath }),
}),
)
expect(mocks.unref).toHaveBeenCalledOnce()
})
it('falls back to the default port when PORT is not set', async () => {
delete process.env.PORT
const { handleUpdate, mocks } = await loadUpdateController()
const ctx = createMockCtx()
await handleUpdate(ctx)
vi.runAllTimers()
expect(mocks.spawn).toHaveBeenCalledWith(
process.execPath,
[expect.any(String), 'restart', '--port', '8648'],
expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true }),
)
})
it('does not log a restart error when the restart helper exits successfully', async () => {
const handlers = new Map<string, (...args: any[]) => void>()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const unref = vi.fn()
const restart = {
unref,
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
return restart
}),
}
const spawn = vi.fn(() => restart)
const { handleUpdate } = await loadUpdateController({ spawn, unref })
const ctx = createMockCtx()
await handleUpdate(ctx)
vi.runAllTimers()
handlers.get('exit')?.(0, null)
expect(errorSpy).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it('returns a 500 with stderr when installation fails', async () => {
const execFileSync = vi.fn(() => {
const error = new Error('install failed') as Error & { stderr?: string }
error.stderr = 'engine mismatch'
throw error
})
const { handleUpdate, mocks } = await loadUpdateController({ execFileSync })
const ctx = createMockCtx()
await handleUpdate(ctx)
expect(ctx.status).toBe(500)
expect(ctx.body).toEqual({ success: false, message: 'engine mismatch' })
expect(mocks.spawn).not.toHaveBeenCalled()
expect(exitSpy).not.toHaveBeenCalled()
})
it('loads preview tags through async git with a short timeout', async () => {
process.env.HERMES_WEB_UI_PREVIEW_REPO = 'https://github.com/EKKOLearnAI/hermes-web-ui'
const execFile = vi.fn((_command: string, _args: string[], _options: any, callback: any) => {
callback(null, [
'abc123\trefs/tags/v0.6.6',
'def456\trefs/tags/v0.6.7',
].join('\n'), '')
})
const execFileSync = vi.fn(() => 'git version 2.0.0')
const { previewTags, mocks } = await loadUpdateController({ execFile, execFileSync })
const ctx = createMockCtx()
await previewTags(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({
tags: [
{ name: 'main', sha: '' },
{ name: 'v0.6.7', sha: 'def456' },
{ name: 'v0.6.6', sha: 'abc123' },
],
})
expect(mocks.execFile).toHaveBeenCalledWith(
'git',
['ls-remote', '--tags', '--refs', 'https://github.com/EKKOLearnAI/hermes-web-ui.git'],
expect.objectContaining({ timeout: 8000 }),
expect.any(Function),
)
})
it('falls back to GitHub API when async git tag loading fails', async () => {
process.env.HERMES_WEB_UI_PREVIEW_REPO = 'https://github.com/EKKOLearnAI/hermes-web-ui'
const execFile = vi.fn((_command: string, _args: string[], _options: any, callback: any) => {
callback(new Error('git timeout'), '', '')
})
const execFileSync = vi.fn(() => 'git version 2.0.0')
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => [
{ name: 'v0.6.7', commit: { sha: 'def456' } },
{ name: 'v0.6.6', commit: { sha: 'abc123' } },
],
}))
vi.stubGlobal('fetch', fetchMock)
const { previewTags } = await loadUpdateController({ execFile, execFileSync })
const ctx = createMockCtx()
await previewTags(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({
tags: [
{ name: 'main', sha: '' },
{ name: 'v0.6.7', sha: 'def456' },
{ name: 'v0.6.6', sha: 'abc123' },
],
})
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/repos/EKKOLearnAI/hermes-web-ui/tags?per_page=100',
expect.objectContaining({
headers: { 'User-Agent': 'hermes-web-ui-preview' },
signal: expect.any(AbortSignal),
}),
)
})
it('runs preview npm install through async execFile', async () => {
const npmCli = getNpmCliPath()
const execFile = vi.fn((_command: string, _args: string[], _options: any, callback: any) => {
callback(null, 'installed', '')
})
const execFileSync = vi.fn(() => '')
const { installPreview, mocks } = await loadUpdateController({ execFile, execFileSync })
const ctx = createMockCtx()
await installPreview(ctx)
expect(ctx.status).toBe(202)
expect((ctx.body as any).success).toBe(true)
expect((ctx.body as any).accepted).toBe(true)
expect((ctx.body as any).active_action).toBe('install')
expect(mocks.execFile).toHaveBeenCalledWith(
process.execPath,
[npmCli, 'install', '--include=dev', '--ignore-scripts'],
expect.objectContaining({
timeout: 15 * 60 * 1000,
cwd: expect.any(String),
}),
expect.any(Function),
)
expect(mocks.execFileSync).not.toHaveBeenCalledWith(
process.execPath,
[npmCli, 'install', '--include=dev', '--ignore-scripts'],
expect.any(Object),
)
})
})
+63
View File
@@ -0,0 +1,63 @@
import { Readable } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mkdirMock = vi.hoisted(() => vi.fn())
const writeFileMock = vi.hoisted(() => vi.fn())
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises')
return {
...actual,
mkdir: mkdirMock,
writeFile: writeFileMock,
}
})
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: vi.fn(() => 'default'),
}))
vi.mock('../../packages/server/src/services/hermes/upload-paths', () => ({
getProfileUploadDir: vi.fn((profile: string) => `/tmp/hermes-web-ui/upload/${profile}`),
}))
function multipartBody(boundary: string, name: string, content: string): Buffer {
return Buffer.from([
`--${boundary}`,
`Content-Disposition: form-data; name="file"; filename="${name}"`,
'Content-Type: text/plain',
'',
content,
`--${boundary}--`,
'',
].join('\r\n'))
}
describe('upload controller', () => {
beforeEach(() => {
vi.clearAllMocks()
mkdirMock.mockResolvedValue(undefined)
writeFileMock.mockResolvedValue(undefined)
})
it('stores chat uploads under the request-scoped profile upload directory', async () => {
const boundary = 'test-boundary'
const { handleUpload } = await import('../../packages/server/src/controllers/upload')
const ctx: any = {
get: vi.fn((header: string) => header === 'content-type' ? `multipart/form-data; boundary=${boundary}` : ''),
req: Readable.from([multipartBody(boundary, 'note.txt', 'hello')]),
state: { profile: { name: 'research' } },
body: undefined,
status: 200,
}
await handleUpload(ctx)
expect(mkdirMock).toHaveBeenCalledWith('/tmp/hermes-web-ui/upload/research', { recursive: true })
expect(writeFileMock).toHaveBeenCalledOnce()
const [savedPath, data] = writeFileMock.mock.calls[0]
expect(savedPath).toMatch(/^\/tmp\/hermes-web-ui\/upload\/research\/[a-f0-9]+\.txt$/)
expect(data.toString('utf-8')).toBe('hello')
expect(ctx.body.files[0]).toMatchObject({ name: 'note.txt', path: savedPath })
})
})
+249
View File
@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
const profileMock = vi.hoisted(() => ({
getActiveProfileDir: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: profileMock.getActiveProfileDir,
getProfileDir: vi.fn(),
}))
function createStateDb(withApiCallCount = true): string {
const dir = mkdtempSync(join(tmpdir(), 'hermes-usage-'))
const db = new DatabaseSync(join(dir, 'state.db'))
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
model TEXT,
started_at INTEGER,
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,
estimated_cost_usd REAL DEFAULT 0,
actual_cost_usd REAL${withApiCallCount ? ', api_call_count INTEGER DEFAULT 0' : ''}
)
`)
db.close()
return dir
}
function insertSession(
dir: string,
row: {
id: string
source?: string
model?: string | null
started_at: number
input_tokens?: number
output_tokens?: number
cache_read_tokens?: number
cache_write_tokens?: number
reasoning_tokens?: number
estimated_cost_usd?: number
actual_cost_usd?: number | null
api_call_count?: number
},
withApiCallCount = true,
) {
const db = new DatabaseSync(join(dir, 'state.db'))
const baseParams = {
id: row.id,
source: row.source ?? 'cli',
model: row.model ?? null,
started_at: row.started_at,
input_tokens: row.input_tokens ?? 0,
output_tokens: row.output_tokens ?? 0,
cache_read_tokens: row.cache_read_tokens ?? 0,
cache_write_tokens: row.cache_write_tokens ?? 0,
reasoning_tokens: row.reasoning_tokens ?? 0,
estimated_cost_usd: row.estimated_cost_usd ?? 0,
actual_cost_usd: row.actual_cost_usd ?? null,
}
if (withApiCallCount) {
db.prepare(`
INSERT INTO sessions (
id, source, model, started_at, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens,
estimated_cost_usd, actual_cost_usd, api_call_count
) VALUES (
$id, $source, $model, $started_at, $input_tokens, $output_tokens,
$cache_read_tokens, $cache_write_tokens, $reasoning_tokens,
$estimated_cost_usd, $actual_cost_usd, $api_call_count
)
`).run({ ...baseParams, api_call_count: row.api_call_count ?? 0 })
} else {
db.prepare(`
INSERT INTO sessions (
id, source, model, started_at, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens,
estimated_cost_usd, actual_cost_usd
) VALUES (
$id, $source, $model, $started_at, $input_tokens, $output_tokens,
$cache_read_tokens, $cache_write_tokens, $reasoning_tokens,
$estimated_cost_usd, $actual_cost_usd
)
`).run(baseParams)
}
db.close()
}
function day(seconds: number): string {
return new Date(seconds * 1000).toISOString().slice(0, 10)
}
describe('native-style Hermes usage analytics DB aggregation', () => {
let profileDir: string | null = null
beforeEach(() => {
vi.resetModules()
profileMock.getActiveProfileDir.mockReset()
})
afterEach(() => {
if (profileDir) rmSync(profileDir, { recursive: true, force: true })
profileDir = null
})
it('sums direct state.db rows in the period', async () => {
const now = 1_700_000_000
profileDir = createStateDb(true)
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
insertSession(profileDir, {
id: 'root',
source: 'cli',
model: 'gpt-5',
started_at: now - 60,
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 10,
cache_write_tokens: 2,
reasoning_tokens: 5,
estimated_cost_usd: 0.02,
actual_cost_usd: null,
api_call_count: 1,
})
insertSession(profileDir, {
id: 'tool-child',
source: 'tool',
model: 'tool-model',
started_at: now - 90,
input_tokens: 30,
output_tokens: 20,
cache_read_tokens: 5,
cache_write_tokens: 1,
reasoning_tokens: 2,
estimated_cost_usd: 0.01,
actual_cost_usd: 0.015,
api_call_count: 2,
})
insertSession(profileDir, {
id: 'compress_1',
source: 'cli',
model: 'gpt-5',
started_at: now - 86400,
input_tokens: 7,
output_tokens: 3,
cache_read_tokens: 1,
estimated_cost_usd: 0.005,
})
insertSession(profileDir, {
id: 'null-model',
source: 'cli',
model: null,
started_at: now - 120,
input_tokens: 1,
output_tokens: 2,
estimated_cost_usd: 0.003,
})
insertSession(profileDir, {
id: 'web-local-copy',
source: 'api_server',
model: 'gpt-5',
started_at: now - 30,
input_tokens: 500,
output_tokens: 500,
estimated_cost_usd: 5,
api_call_count: 5,
})
insertSession(profileDir, {
id: 'old',
source: 'cli',
model: 'old-model',
started_at: now - 31 * 86400,
input_tokens: 999,
output_tokens: 999,
estimated_cost_usd: 9,
api_call_count: 9,
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const result = await mod.getUsageStatsFromDb(30, now)
expect(result).toMatchObject({
input_tokens: 638,
output_tokens: 575,
cache_read_tokens: 16,
cache_write_tokens: 3,
reasoning_tokens: 7,
sessions: 5,
total_api_calls: 8,
})
expect(result.cost).toBeCloseTo(5.043)
expect(result.by_model).toEqual([
{ model: 'gpt-5', input_tokens: 607, output_tokens: 553, cache_read_tokens: 11, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 3 },
{ model: 'tool-model', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 },
])
expect(result.by_day).toHaveLength(2)
expect(result.by_day[0]).toEqual({
date: day(now - 86400),
input_tokens: 7,
output_tokens: 3,
cache_read_tokens: 1,
cache_write_tokens: 0,
sessions: 1,
errors: 0,
cost: 0.005,
})
expect(result.by_day[1]).toMatchObject({
date: day(now),
input_tokens: 631,
output_tokens: 572,
cache_read_tokens: 15,
cache_write_tokens: 3,
sessions: 4,
errors: 0,
})
expect(result.by_day[1].cost).toBeCloseTo(5.038)
})
it('keeps analytics working against older state.db schemas without api_call_count', async () => {
const now = 1_700_000_000
profileDir = createStateDb(false)
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
insertSession(profileDir, {
id: 'legacy',
model: 'legacy-model',
started_at: now - 60,
input_tokens: 4,
output_tokens: 6,
estimated_cost_usd: 0.001,
}, false)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const result = await mod.getUsageStatsFromDb(30, now)
expect(result.input_tokens).toBe(4)
expect(result.output_tokens).toBe(6)
expect(result.total_api_calls).toBe(0)
})
})
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the db index module so we can test usage-store in isolation
const { mockEnsureTable, mockJsonSet, mockJsonGet, mockJsonGetAll, mockJsonDelete } = vi.hoisted(() => ({
mockEnsureTable: vi.fn(),
mockJsonSet: vi.fn(),
mockJsonGet: vi.fn(),
mockJsonGetAll: vi.fn(),
mockJsonDelete: vi.fn(),
}))
vi.mock('../../packages/server/src/db/index', () => ({
isSqliteAvailable: () => false, // Force JSON fallback path
ensureTable: mockEnsureTable,
getDb: () => null,
jsonSet: mockJsonSet,
jsonGet: mockJsonGet,
jsonGetAll: mockJsonGetAll,
jsonDelete: mockJsonDelete,
}))
import {
updateUsage,
getUsage,
getUsageBatch,
deleteUsage,
} from '../../packages/server/src/db/hermes/usage-store'
describe('Usage Store (JSON fallback)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updateUsage writes via jsonSet', () => {
updateUsage('session-1', { inputTokens: 100, outputTokens: 50 })
expect(mockJsonSet).toHaveBeenCalledWith(
'session_usage',
'session-1',
expect.objectContaining({
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: expect.any(Number),
}),
)
})
it('getUsage reads via jsonGet', () => {
mockJsonGet.mockReturnValue({ input_tokens: 200, output_tokens: 80 })
const result = getUsage('session-1')
expect(result).toEqual({
input_tokens: 200,
output_tokens: 80,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: 0,
})
expect(mockJsonGet).toHaveBeenCalledWith('session_usage', 'session-1')
})
it('getUsage returns undefined when jsonGet returns nothing', () => {
mockJsonGet.mockReturnValue(undefined)
const result = getUsage('nonexistent')
expect(result).toBeUndefined()
})
it('getUsageBatch returns empty map for empty input', () => {
const result = getUsageBatch([])
expect(result).toEqual({})
expect(mockJsonGetAll).not.toHaveBeenCalled()
})
it('getUsageBatch returns matching records', () => {
mockJsonGetAll.mockReturnValue({
'session-1': { input_tokens: 100, output_tokens: 50 },
'session-2': { input_tokens: 200, output_tokens: 80 },
'session-3': { input_tokens: 300, output_tokens: 120 },
})
const result = getUsageBatch(['session-1', 'session-3', 'session-missing'])
expect(result).toEqual({
'session-1': {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: 0,
},
'session-3': {
input_tokens: 300,
output_tokens: 120,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: 0,
},
})
})
it('deleteUsage calls jsonDelete', () => {
deleteUsage('session-1')
expect(mockJsonDelete).toHaveBeenCalledWith('session_usage', 'session-1')
})
})
// Test with SQLite available (mocked)
describe('Usage Store (SQLite path)', () => {
let runMock: ReturnType<typeof vi.fn>
let getMock: ReturnType<typeof vi.fn>
let allMock: ReturnType<typeof vi.fn>
let deleteMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.resetModules()
runMock = vi.fn()
getMock = vi.fn()
allMock = vi.fn()
deleteMock = vi.fn()
vi.doMock('../../packages/server/src/db/index', () => ({
isSqliteAvailable: () => true,
ensureTable: vi.fn(),
getDb: () => ({
prepare: vi.fn((sql: string) => {
if (sql.includes('INSERT') || sql.includes('UPDATE')) return { run: runMock }
if (sql.includes('SELECT') && sql.includes('WHERE session_id = ?')) return { get: getMock }
if (sql.includes('SELECT') && sql.includes('IN')) return { all: allMock }
if (sql.includes('DELETE')) return { run: deleteMock }
return { run: runMock, get: getMock, all: allMock }
}),
}),
jsonSet: vi.fn(),
jsonGet: vi.fn(),
jsonGetAll: vi.fn(),
jsonDelete: vi.fn(),
}))
})
it('updateUsage runs INSERT ... ON CONFLICT query', async () => {
const { updateUsage } = await import('../../packages/server/src/db/hermes/usage-store')
updateUsage('s1', { inputTokens: 500, outputTokens: 200 })
expect(runMock).toHaveBeenCalledWith(
's1',
500,
200,
0, // cacheReadTokens
0, // cacheWriteTokens
0, // reasoningTokens
'', // model
'default', // profile
expect.any(Number), // created_at
)
})
it('getUsage queries by session_id', async () => {
getMock.mockReturnValue({
input_tokens: 999,
output_tokens: 111,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: 0,
})
const { getUsage } = await import('../../packages/server/src/db/hermes/usage-store')
const result = getUsage('s1')
expect(getMock).toHaveBeenCalledWith('s1')
expect(result).toEqual({
input_tokens: 999,
output_tokens: 111,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: '',
profile: 'default',
created_at: 0,
})
})
it('getUsageBatch queries with IN clause', async () => {
allMock.mockReturnValue([
{ session_id: 'a', input_tokens: 1, output_tokens: 2, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, model: '', profile: 'default', created_at: 0 },
{ session_id: 'b', input_tokens: 3, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, model: '', profile: 'default', created_at: 0 },
])
const { getUsageBatch } = await import('../../packages/server/src/db/hermes/usage-store')
const result = getUsageBatch(['a', 'b', 'c'])
expect(allMock).toHaveBeenCalledWith('a', 'b', 'c')
expect(result).toEqual({
a: { input_tokens: 1, output_tokens: 2, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, model: '', profile: 'default', created_at: 0 },
b: { input_tokens: 3, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, model: '', profile: 'default', created_at: 0 },
})
})
it('deleteUsage runs DELETE query', async () => {
const { deleteUsage } = await import('../../packages/server/src/db/hermes/usage-store')
deleteUsage('s1')
expect(deleteMock).toHaveBeenCalledWith('s1')
})
})
+338
View File
@@ -0,0 +1,338 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('user auth tables and middleware', () => {
let db: any = null
beforeEach(async () => {
vi.resetModules()
vi.stubEnv('AUTH_JWT_SECRET', 'test-secret')
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.doUnmock('../../packages/server/src/services/hermes/hermes-profile')
vi.unstubAllEnvs()
vi.resetModules()
})
async function initUsers() {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
return {
schemas,
users: await import('../../packages/server/src/db/hermes/users-store'),
auth: await import('../../packages/server/src/middleware/user-auth'),
}
}
function makeCtx(user: any, profile: string) {
return {
state: { user },
query: { profile },
request: { body: {} },
get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? '' : ''),
status: 200,
body: null,
} as any
}
it('creates the default super admin without profile bindings', async () => {
const { schemas, users } = await initUsers()
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
expect(created?.id).toBe(1)
const row = db.prepare(`SELECT * FROM ${schemas.USERS_TABLE} WHERE id = ?`).get(1) as any
expect(row.username).toBe('admin')
expect(row.role).toBe('super_admin')
expect(row.status).toBe('active')
expect(row.password_hash).not.toBe('123456')
expect(users.verifyPassword('123456', row.password_hash)).toBe(true)
const profileCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USER_PROFILES_TABLE} WHERE user_id = ?`).get(1) as any
expect(profileCount.count).toBe(0)
})
it('allows super admin to access profiles without explicit binding', async () => {
const { users, auth } = await initUsers()
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
expect(created?.role).toBe('super_admin')
const ctx = makeCtx({ id: created?.id, username: 'admin', role: 'super_admin' }, 'research')
const next = vi.fn(async () => {})
await auth.resolveUserProfile(ctx, next)
expect(ctx.state.profile).toEqual({ name: 'research' })
expect(next).toHaveBeenCalledOnce()
})
it('requires regular admins to be associated with the requested profile', async () => {
const { schemas, users, auth } = await initUsers()
const now = Date.now()
db.prepare(
`INSERT INTO ${schemas.USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run('ops', users.hashPassword('secret'), 'admin', 'active', now, now)
const admin = users.findUserByUsername('ops')
expect(admin?.id).toBe(1)
const deniedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research')
await auth.resolveUserProfile(deniedCtx, vi.fn(async () => {}))
expect(deniedCtx.status).toBe(403)
db.prepare(
`INSERT INTO ${schemas.USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at)
VALUES (?, ?, 1, ?)`
).run(admin!.id, 'research', now)
const allowedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research')
const next = vi.fn(async () => {})
await auth.resolveUserProfile(allowedCtx, next)
expect(allowedCtx.state.profile).toEqual({ name: 'research' })
expect(next).toHaveBeenCalledOnce()
})
it('does not infer a profile when the frontend does not send one', async () => {
const { auth } = await initUsers()
const ctx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, '')
const next = vi.fn(async () => {})
await auth.resolveUserProfile(ctx, next)
expect(ctx.state.profile).toBeUndefined()
expect(next).toHaveBeenCalledOnce()
await auth.requireUserProfile(ctx, vi.fn(async () => {}))
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Profile is required' })
})
it('ignores stale profile headers for the aggregate available-models endpoint', async () => {
const { auth } = await initUsers()
const ctx = {
path: '/api/hermes/available-models',
state: { user: { id: 1, username: 'ops', role: 'admin' } },
query: {},
request: { body: {} },
get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : ''),
status: 200,
body: null,
} as any
const next = vi.fn(async () => {})
await auth.resolveUserProfile(ctx, next)
expect(ctx.state.profile).toBeUndefined()
expect(next).toHaveBeenCalledOnce()
})
it('does not create the default super admin until first valid bootstrap login', async () => {
const { schemas, users } = await initUsers()
expect(users.countUsers()).toBe(0)
expect(users.bootstrapDefaultSuperAdmin('admin', 'bad-password')).toBeNull()
expect(users.countUsers()).toBe(0)
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
expect(created?.role).toBe('super_admin')
expect(users.countUsers()).toBe(1)
const userCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USERS_TABLE}`).get() as any
expect(userCount.count).toBe(1)
})
it('signs and verifies user JWTs', async () => {
const { auth } = await initUsers()
const token = auth.signUserJwt({ id: 1, username: 'admin', role: 'super_admin' }, 'secret', 1000)
const payload = auth.verifyUserJwt(token, 'secret', 1000)
expect(payload?.sub).toBe('1')
expect(payload?.username).toBe('admin')
expect(payload?.role).toBe('super_admin')
expect(auth.verifyUserJwt(token, 'wrong', 1000)).toBeNull()
})
it('authenticates JWTs passed as query tokens for download and websocket URLs', async () => {
const { users, auth } = await initUsers()
const user = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const token = auth.signUserJwt(user, 'test-secret')
const ctx = {
path: '/api/hermes/download',
headers: {},
query: { token },
state: {},
request: { body: {} },
status: 200,
body: null,
} as any
const next = vi.fn(async () => {})
await auth.requireUserJwt(ctx, next)
expect(ctx.state.user).toEqual({ id: user.id, username: 'admin', role: 'super_admin' })
expect(next).toHaveBeenCalledOnce()
})
it('lets SPA and static asset paths pass through without a JWT', async () => {
const { auth } = await initUsers()
const ctx = {
path: '/',
headers: {},
query: {},
state: {},
request: { body: {} },
status: 200,
body: null,
} as any
const next = vi.fn(async () => {})
await auth.requireUserJwt(ctx, next)
expect(next).toHaveBeenCalledOnce()
expect(ctx.status).toBe(200)
expect(ctx.body).toBeNull()
})
it('still requires a JWT for protected API paths', async () => {
const { auth } = await initUsers()
const ctx = {
path: '/api/hermes/sessions',
headers: {},
query: {},
state: {},
request: { body: {} },
status: 200,
body: null,
} as any
const next = vi.fn(async () => {})
await auth.requireUserJwt(ctx, next)
expect(next).not.toHaveBeenCalled()
expect(ctx.status).toBe(401)
expect(ctx.body).toEqual({ error: 'Unauthorized' })
})
it('bootstraps the default super admin through password login and returns a user JWT', async () => {
await initUsers()
const ctrl = await import('../../packages/server/src/controllers/auth')
const ctx = {
request: { body: { username: 'admin', password: '123456' } },
headers: {},
ip: '127.0.0.1',
status: 200,
body: null,
} as any
await ctrl.login(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.token).toMatch(/^[^.]+\.[^.]+\.[^.]+$/)
})
it('marks only admin with password 123456 as requiring a credential change', async () => {
const { users } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctrl = await import('../../packages/server/src/controllers/auth')
const defaultCtx = {
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
status: 200,
body: null,
} as any
await ctrl.currentUser(defaultCtx)
expect(defaultCtx.body.user.requiresCredentialChange).toBe(true)
users.updateUserPassword(admin.id, 'stronger-password')
const passwordChangedCtx = {
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
status: 200,
body: null,
} as any
await ctrl.currentUser(passwordChangedCtx)
expect(passwordChangedCtx.body.user.requiresCredentialChange).toBe(false)
users.updateUserPassword(admin.id, '123456')
users.updateUsername(admin.id, 'owner')
const usernameChangedCtx = {
state: { user: { id: admin.id, username: 'owner', role: 'super_admin' } },
status: 200,
body: null,
} as any
await ctrl.currentUser(usernameChangedCtx)
expect(usernameChangedCtx.body.user.requiresCredentialChange).toBe(false)
})
it('lets super admins create regular admins with profile bindings', async () => {
const { users } = await initUsers()
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
listProfileNamesFromDisk: () => ['default', 'research'],
}))
const ctrl = await import('../../packages/server/src/controllers/auth')
const ctx = {
state: { user: { id: 1, username: 'admin', role: 'super_admin' } },
request: {
body: {
username: 'ops',
password: 'secret1',
role: 'admin',
status: 'active',
profiles: ['research'],
},
},
status: 200,
body: null,
} as any
await ctrl.createManagedUser(ctx)
expect(ctx.status).toBe(201)
const created = users.findUserByUsername('ops')
expect(created?.role).toBe('admin')
expect(users.listUserProfiles(created!.id).map(profile => profile.profile_name)).toEqual(['research'])
})
it('does not allow disabling the last active super admin', async () => {
const { users } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
listProfileNamesFromDisk: () => ['default'],
}))
const ctrl = await import('../../packages/server/src/controllers/auth')
const ctx = {
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
params: { id: String(admin.id) },
request: { body: { status: 'disabled' } },
status: 200,
body: null,
} as any
await ctrl.updateManagedUser(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'You cannot disable your own account' })
})
it('requires super admin for super-admin-only middleware', async () => {
const { auth } = await initUsers()
const adminCtx = makeCtx({ id: 2, username: 'ops', role: 'admin' }, 'default')
await auth.requireSuperAdmin(adminCtx, vi.fn(async () => {}))
expect(adminCtx.status).toBe(403)
const superCtx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, 'default')
const next = vi.fn(async () => {})
await auth.requireSuperAdmin(superCtx, next)
expect(next).toHaveBeenCalledOnce()
})
})
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
const { mockRestartGatewayForProfile } = vi.hoisted(() => ({
mockRestartGatewayForProfile: vi.fn().mockResolvedValue({ running: true, profile: 'research' }),
}))
vi.mock('../../packages/server/src/services/hermes/gateway-autostart', () => ({
restartGatewayForProfile: mockRestartGatewayForProfile,
}))
let hermesHome = ''
const originalHermesHome = process.env.HERMES_HOME
async function loadController() {
vi.resetModules()
process.env.HERMES_HOME = hermesHome
return import('../../packages/server/src/controllers/hermes/weixin')
}
function makeCtx(body: Record<string, any>, profile = 'research'): any {
return {
request: { body },
state: { profile: { name: profile } },
status: 200,
body: undefined,
}
}
describe('weixin controller', () => {
beforeEach(async () => {
vi.clearAllMocks()
hermesHome = await mkdtemp(join(tmpdir(), 'hwui-weixin-controller-'))
await mkdir(join(hermesHome, 'profiles', 'research'), { recursive: true })
await writeFile(join(hermesHome, '.env'), [
'WEIXIN_ACCOUNT_ID=keep-default-account',
'WEIXIN_TOKEN=keep-default-token',
'',
].join('\n'), 'utf-8')
await writeFile(join(hermesHome, 'profiles', 'research', '.env'), [
'OPENROUTER_API_KEY=keep-research-openrouter',
'WEIXIN_ACCOUNT_ID=old-research-account',
'WEIXIN_TOKEN=old-research-token',
'',
].join('\n'), 'utf-8')
})
afterEach(async () => {
vi.resetModules()
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = originalHermesHome
if (hermesHome) await rm(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
it('saves scanned Weixin credentials to the request-scoped profile env only', async () => {
const { save } = await loadController()
const ctx = makeCtx({
account_id: 'new-research-account',
token: 'new-research-token',
base_url: 'https://weixin.invalid',
})
await save(ctx)
expect(ctx.body).toEqual({ success: true })
expect(mockRestartGatewayForProfile).toHaveBeenCalledWith('research')
expect(await readFile(join(hermesHome, '.env'), 'utf-8')).toContain('WEIXIN_TOKEN=keep-default-token')
const researchEnv = await readFile(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')
expect(researchEnv).toContain('OPENROUTER_API_KEY=keep-research-openrouter')
expect(researchEnv).toContain('WEIXIN_ACCOUNT_ID=new-research-account')
expect(researchEnv).toContain('WEIXIN_TOKEN=new-research-token')
expect(researchEnv).toContain('WEIXIN_BASE_URL=https://weixin.invalid')
})
it('rejects missing required credentials without touching the profile env', async () => {
const { save } = await loadController()
const ctx = makeCtx({ account_id: 'new-research-account' })
const envBefore = await readFile(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')
await save(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Missing account_id or token' })
expect(mockRestartGatewayForProfile).not.toHaveBeenCalled()
await expect(readFile(join(hermesHome, 'profiles', 'research', '.env'), 'utf-8')).resolves.toBe(envBefore)
})
})
+123
View File
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { tmpdir } from 'os'
import YAML from 'js-yaml'
import { applyXaiOAuthDefaultModel, saveXaiOAuthTokensForProfile, status } from '../../packages/server/src/controllers/hermes/xai-auth'
let hermesHome = ''
function writeFile(relativePath: string, content: string) {
const target = join(hermesHome, relativePath)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, content)
}
function readYaml(relativePath: string) {
return YAML.load(readFileSync(join(hermesHome, relativePath), 'utf-8')) as any
}
function readJson(relativePath: string) {
return JSON.parse(readFileSync(join(hermesHome, relativePath), 'utf-8'))
}
function makeCtx(profile: string): any {
return {
state: { profile: { name: profile } },
query: {},
request: { body: {} },
get: () => '',
status: 200,
body: undefined as unknown,
}
}
describe('xAI auth controller', () => {
beforeEach(() => {
hermesHome = mkdtempSync(join(tmpdir(), 'hwui-xai-auth-'))
process.env.HERMES_HOME = hermesHome
})
afterEach(() => {
delete process.env.HERMES_HOME
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
hermesHome = ''
})
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',
})
})
it('persists OAuth credentials and default model in the request-scoped profile only', async () => {
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
writeFile('config.yaml', 'model:\n provider: deepseek\n default: deepseek-chat\n')
writeFile('profiles/research/config.yaml', 'model:\n provider: openrouter\n default: openrouter-model\n')
await saveXaiOAuthTokensForProfile(
'research',
{
discovery: { token_endpoint: 'https://auth.x.ai/oauth/token' },
redirectUri: 'http://127.0.0.1:56121/callback',
},
{
access_token: 'research-access-token',
refresh_token: 'research-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
},
)
expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false)
const auth = readJson('profiles/research/auth.json')
expect(auth.providers['xai-oauth'].tokens.access_token).toBe('research-access-token')
expect(auth.credential_pool['xai-oauth'][0].refresh_token).toBe('research-refresh-token')
expect(readYaml('config.yaml').model).toEqual({ provider: 'deepseek', default: 'deepseek-chat' })
expect(readYaml('profiles/research/config.yaml').model).toEqual({ provider: 'xai-oauth', default: 'grok-4.3' })
})
it('checks xAI OAuth status against the request-scoped profile', async () => {
mkdirSync(join(hermesHome, 'profiles', 'research'), { recursive: true })
writeFile('auth.json', JSON.stringify({ version: 1, providers: {}, credential_pool: {} }, null, 2))
writeFile('profiles/research/auth.json', JSON.stringify({
version: 1,
providers: {
'xai-oauth': {
last_refresh: '2026-06-02T00:00:00.000Z',
tokens: { access_token: 'research-access-token' },
},
},
}, null, 2))
const ctx = makeCtx('research')
await status(ctx)
expect(ctx.body).toEqual({ authenticated: true, last_refresh: '2026-06-02T00:00:00.000Z' })
})
})