feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('过滤掉噪音 ID(accounts/、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()
|
||||
// 账号 A:token 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: {} })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,/)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user