Files
hermes-ui/tests/server/outbound-relay-client.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

959 lines
33 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIo, mockSocket, sockets, socketHandlers, mockWebSockets, MockWebSocket, resetMockSockets } = vi.hoisted(() => {
function createMockSocket(id: string, url = '') {
const handlers = new Map<string, (...args: any[]) => void>()
const anyHandlers: Array<(event: string, ...args: any[]) => void> = []
const socket: any = {
id,
__url: url,
connected: false,
io: { opts: {} },
__handlers: handlers,
__anyHandlers: anyHandlers,
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, (...args: any[]) => {
if (event === 'connect') socket.connected = true
if (event === 'disconnect') socket.connected = false
return handler(...args)
})
return socket
}),
onAny: vi.fn((handler: (event: string, ...args: any[]) => void) => {
anyHandlers.push(handler)
return socket
}),
emit: vi.fn(),
removeAllListeners: vi.fn(),
disconnect: vi.fn(() => {
socket.connected = false
}),
}
return socket
}
const sockets: any[] = []
const mockSocket: any = createMockSocket('socket-1')
const socketHandlers = mockSocket.__handlers as Map<string, (...args: any[]) => void>
const mockIo = vi.fn((url = '') => {
const socket = sockets.length === 0 ? mockSocket : createMockSocket(`socket-${sockets.length + 1}`, url)
socket.__url = url
sockets.push(socket)
return socket
})
const resetMockSockets = () => {
sockets.length = 0
mockSocket.__handlers.clear()
mockSocket.__anyHandlers.length = 0
mockWebSockets.length = 0
}
const mockWebSockets: any[] = []
class MockWebSocket {
static OPEN = 1
readyState = MockWebSocket.OPEN
__handlers = new Map<string, (...args: any[]) => void>()
send = vi.fn()
close = vi.fn()
constructor(public url: string) {
mockWebSockets.push(this)
}
on(event: string, handler: (...args: any[]) => void) {
this.__handlers.set(event, handler)
return this
}
}
return {
socketHandlers,
sockets,
mockSocket,
mockIo,
mockWebSockets,
MockWebSocket,
resetMockSockets,
}
})
vi.mock('socket.io-client', () => ({
io: mockIo,
}))
vi.mock('ws', () => ({
default: MockWebSocket,
}))
describe('outbound relay client', () => {
beforeEach(async () => {
const { stopOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
stopOutboundRelayClient()
resetMockSockets()
vi.clearAllMocks()
})
function connectRemoteSocket() {
const socket = sockets[0]
socket.__handlers.get('connect')?.()
return socket
}
function emitRemote(socket: any, event: string, payload: unknown) {
const authorizedPayload = payload && typeof payload === 'object' && !Array.isArray(payload) && event !== 'mcu.auth.ok' && event !== 'relay.replaced'
? { apiToken: 'user-jwt', ...(payload as Record<string, unknown>) }
: payload
for (const handler of socket.__anyHandlers) {
handler(event, authorizedPayload)
}
}
function findEmittedPayload(socket: any, event: string, predicate: (payload: any) => boolean = () => true) {
return socket.emit.mock.calls
.filter(([eventName]: [string]) => eventName === event)
.map(([, payload]: [string, any]) => payload)
.find(predicate)
}
function socketForUrl(url: string) {
return sockets.find(socket => socket.__url === url)
}
it('stays disabled when no relay url is passed explicitly', async () => {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const client = startOutboundRelayClient({ relayUrl: '' })
expect(client).toBeNull()
expect(mockIo).not.toHaveBeenCalled()
})
it('connects to the configured remote relay as a socket client', async () => {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const client = startOutboundRelayClient({
relayUrl: 'https://user:pass@relay.example.com/hermes',
relayToken: 'relay-token',
instanceId: 'studio-1',
localBaseUrl: 'http://127.0.0.1:9999',
fetchImpl: vi.fn() as any,
})
expect(client).not.toBeNull()
expect(mockIo).toHaveBeenCalledWith('https://user:pass@relay.example.com/hermes', expect.objectContaining({
auth: {
token: 'relay-token',
instanceId: 'studio-1',
role: 'hermes-studio',
},
transports: ['websocket', 'polling'],
reconnection: true,
}))
socketHandlers.get('connect')?.()
expect(mockSocket.emit).toHaveBeenCalledWith('relay.ready', {
capabilities: ['http.request', 'socket.chat-run'],
instanceId: 'studio-1',
})
})
it('queues missing-STT prompt audio from Socket.IO MCU voice turns', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
ok: false,
audio: {
text: 'STT is not configured',
url: 'https://cdn.example.com/missing-stt.pcm',
mimeType: 'audio/x-pcm',
sampleRate: 16000,
channels: 2,
durationMs: 1800,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://device.local:8787/',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
expect(mockIo).toHaveBeenCalledWith('http://device.local:8787/global-agent', expect.objectContaining({
auth: expect.objectContaining({
clientRole: 'web',
relayRole: 'web',
}),
}))
const remoteSocket = connectRemoteSocket()
emitRemote(remoteSocket, 'voice.recorded', {
type: 'voice.recorded',
interactionId: 'voice-1',
mimeType: 'audio/wav',
profile: 'default',
})
emitRemote(remoteSocket, 'voice.stream.chunk', {
type: 'voice.stream.chunk',
data: Buffer.from('wav-audio').toString('base64'),
})
await vi.waitFor(() => {
expect(remoteSocket.emit).toHaveBeenCalledWith('audio.enqueue', expect.any(Object))
})
const enqueuePayload = findEmittedPayload(remoteSocket, 'audio.enqueue')
expect(enqueuePayload).toMatchObject({
type: 'audio.enqueue',
interactionId: 'voice-1',
segmentId: 'voice-1-prompt',
text: 'STT is not configured',
url: 'https://cdn.example.com/missing-stt.pcm',
mimeType: 'audio/x-pcm',
channels: 2,
sampleRate: 16000,
})
expect(enqueuePayload).not.toHaveProperty('completionManagedByServer')
emitRemote(remoteSocket, 'audio.done', {
type: 'audio.done',
segmentId: 'voice-1-prompt',
})
await vi.waitFor(() => {
expect(remoteSocket.emit).toHaveBeenCalledWith('interaction.status', expect.objectContaining({
status: 'completed',
}))
})
})
it('does not reconnect the Socket.IO relay client after it is replaced remotely', async () => {
vi.useFakeTimers()
try {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://relay.example.com',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
deviceCode: 'device-code-1',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
const remoteSocket = connectRemoteSocket()
const localSocket = sockets[1]
emitRemote(remoteSocket, 'relay.replaced', {
type: 'relay.replaced',
deviceCode: 'device-code-1',
role: 'web',
})
remoteSocket.__handlers.get('disconnect')?.('io server disconnect')
await vi.advanceTimersByTimeAsync(60_000)
expect(localSocket.disconnect).toHaveBeenCalled()
expect(sockets).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('does not reconnect the Socket.IO relay client after device-code auth is rejected', async () => {
vi.useFakeTimers()
try {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://relay.example.com',
relayProtocol: 'mcu-socket.io',
deviceCode: 'device-code-1',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
const remoteSocket = sockets[0]
remoteSocket.__handlers.get('connect_error')?.(new Error('非官方设备码'))
await vi.advanceTimersByTimeAsync(60_000)
expect(sockets).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('bridges Socket.IO MCU voice streams to the local global-agent server without completing early', async () => {
const fetchImpl = vi.fn()
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://device.local:8787',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
instanceId: 'mcu-1',
deviceCode: 'device-code-1',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
const remoteSocket = connectRemoteSocket()
const localGlobalAgentSocket = sockets.at(-1)
expect(mockIo).toHaveBeenCalledWith('http://127.0.0.1:8648/global-agent', expect.objectContaining({
auth: expect.objectContaining({
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'mcu-1',
}),
}))
emitRemote(remoteSocket, 'voice.stream.start', {
type: 'voice.stream.start',
interactionId: 'voice-stream-1',
mimeType: 'audio/x-ima-adpcm',
frameFormat: 'hadp-chunk-v1',
sampleRate: 24000,
channels: 1,
bitsPerSample: 16,
profile: 'default',
})
emitRemote(remoteSocket, 'voice.stream.chunk', {
type: 'voice.stream.chunk',
data: Buffer.from([1, 2, 3, 4]).toString('base64'),
})
emitRemote(remoteSocket, 'voice.stream.end', {
type: 'voice.stream.end',
interactionId: 'voice-stream-1',
bytes: 4,
})
expect(localGlobalAgentSocket.emit).toHaveBeenCalledWith('voice.stream.start', expect.objectContaining({
type: 'voice.stream.start',
interactionId: 'voice-stream-1',
mimeType: 'audio/x-ima-adpcm',
frameFormat: 'hadp-chunk-v1',
profile: 'default',
}))
expect(localGlobalAgentSocket.emit).toHaveBeenCalledWith('voice.stream.chunk', expect.objectContaining({
interactionId: 'voice-stream-1',
offset: 0,
bytes: 4,
data: Buffer.from([1, 2, 3, 4]),
}))
expect(localGlobalAgentSocket.emit).toHaveBeenCalledWith('voice.stream.end', expect.objectContaining({
type: 'voice.stream.end',
interactionId: 'voice-stream-1',
bytes: 4,
}))
expect(fetchImpl).not.toHaveBeenCalledWith(expect.stringContaining('/api/hermes/mcu/voice-turn'), expect.any(Object))
expect(remoteSocket.emit).not.toHaveBeenCalledWith('interaction.status', expect.objectContaining({ status: 'completed' }))
localGlobalAgentSocket.__anyHandlers[0]('interaction.status', {
type: 'interaction.status',
interactionId: 'voice-stream-1',
status: 'thinking',
text: '你好',
})
await vi.waitFor(() => {
expect(remoteSocket.emit).toHaveBeenCalledWith('interaction.status', expect.objectContaining({
status: 'thinking',
}))
})
})
it('preserves local MCU audio enqueue order while uploading audio to the relay', async () => {
let uploadCount = 0
let releaseFirstUpload: (() => void) | undefined
const firstUploadStarted = new Promise<void>((resolve) => {
const fetchImpl = vi.fn(async (url: string) => {
if (url.includes('/api/hermes/mcu/audio/slow.pcm')) {
return new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'audio/x-pcm' },
})
}
if (url.includes('/api/hermes/mcu/audio/fast.pcm')) {
return new Response(new Uint8Array([4, 5, 6]), {
status: 200,
headers: { 'content-type': 'audio/x-pcm' },
})
}
if (url === 'http://device.local:8787/global-agent/audio') {
uploadCount += 1
const uploadIndex = uploadCount
if (uploadIndex === 1) {
resolve()
await new Promise<void>((release) => {
releaseFirstUpload = release
})
}
return new Response(JSON.stringify({
ok: true,
url: `http://device.local:8787/global-agent/audio/audio-${uploadIndex}?token=download-token`,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response('not found', { status: 404 })
})
void import('../../packages/server/src/services/global-agent/outbound-relay-client').then(({ startOutboundRelayClient }) => {
startOutboundRelayClient({
relayUrl: 'http://device.local:8787',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
instanceId: 'mcu-1',
deviceCode: 'device-code-1',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
})
})
await vi.waitFor(() => {
expect(sockets.length).toBeGreaterThan(0)
})
const remoteSocket = connectRemoteSocket()
emitRemote(remoteSocket, 'mcu.auth.ok', {
type: 'mcu.auth.ok',
audioUpload: {
url: '/global-agent/audio',
token: 'upload-token',
},
})
emitRemote(remoteSocket, 'voice.stream.start', {
type: 'voice.stream.start',
interactionId: 'voice-order',
profile: 'default',
})
const localGlobalAgentSocket = sockets.at(-1)
localGlobalAgentSocket.__anyHandlers[0]('audio.enqueue', {
type: 'audio.enqueue',
interactionId: 'voice-order',
segmentId: 'voice-order-tts-1',
url: '/api/hermes/mcu/audio/slow.pcm',
})
localGlobalAgentSocket.__anyHandlers[0]('audio.enqueue', {
type: 'audio.enqueue',
interactionId: 'voice-order',
segmentId: 'voice-order-tts-2',
url: '/api/hermes/mcu/audio/fast.pcm',
})
await firstUploadStarted
expect(remoteSocket.emit).not.toHaveBeenCalledWith('audio.enqueue', expect.any(Object))
releaseFirstUpload?.()
await vi.waitFor(() => {
const enqueues = remoteSocket.emit.mock.calls.filter(([event]: [string]) => event === 'audio.enqueue')
expect(enqueues).toHaveLength(2)
})
const segmentIds = remoteSocket.emit.mock.calls
.filter(([event]: [string]) => event === 'audio.enqueue')
.map(([, payload]: [string, { segmentId: string }]) => payload.segmentId)
expect(segmentIds).toEqual(['voice-order-tts-1', 'voice-order-tts-2'])
})
it('uploads Socket.IO MCU TTS audio to the remote relay before enqueueing playback', async () => {
const pcm = Buffer.from('pcm-audio')
const fetchImpl = vi.fn(async (url: string) => {
if (url.includes('/api/hermes/mcu/voice-turn')) {
return new Response(JSON.stringify({ ok: true, transcript: '你好' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('/api/hermes/tts/synthesize')) {
return new Response(pcm, {
status: 200,
headers: { 'content-type': 'audio/x-pcm' },
})
}
if (url === 'http://device.local:8787/global-agent/audio') {
return new Response(JSON.stringify({
ok: true,
url: 'http://device.local:8787/global-agent/audio/audio-1?token=download-token',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response(JSON.stringify({ error: 'unexpected url' }), {
status: 500,
headers: { 'content-type': 'application/json' },
})
})
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://device.local:8787',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
deviceCode: 'device-code-1',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
const remoteSocket = connectRemoteSocket()
emitRemote(remoteSocket, 'mcu.auth.ok', {
type: 'mcu.auth.ok',
audioUpload: {
url: '/global-agent/audio',
token: 'upload-token',
},
})
emitRemote(remoteSocket, 'voice.recorded', {
type: 'voice.recorded',
interactionId: 'voice-tts-ok',
mimeType: 'audio/wav',
profile: 'research',
})
emitRemote(remoteSocket, 'voice.stream.chunk', {
type: 'voice.stream.chunk',
data: Buffer.from('wav-audio').toString('base64'),
})
await vi.waitFor(() => {
expect(socketForUrl('http://127.0.0.1:8648/chat-run')).toBeTruthy()
})
const localSocket = socketForUrl('http://127.0.0.1:8648/chat-run')
localSocket.__handlers.get('connect')?.()
localSocket.__handlers.get('message.delta')?.({ delta: '你好' })
localSocket.__handlers.get('run.completed')?.({})
await vi.waitFor(() => {
expect(remoteSocket.emit).toHaveBeenCalledWith('audio.enqueue', expect.objectContaining({
url: 'http://device.local:8787/global-agent/audio/audio-1?token=download-token',
}))
})
const uploadCall = fetchImpl.mock.calls.find(([url]: [string]) => url === 'http://device.local:8787/global-agent/audio')
expect(uploadCall).toBeTruthy()
expect(uploadCall?.[1].headers).toMatchObject({
Authorization: 'Bearer upload-token',
'Content-Type': 'audio/x-ima-adpcm',
'X-Device-Code': 'device-code-1',
'X-Audio-Sample-Rate': '24000',
'X-Audio-Channels': '1',
})
expect(uploadCall?.[1].body).toBeInstanceOf(Uint8Array)
expect(Buffer.from(uploadCall?.[1].body as Uint8Array).subarray(0, 4).toString('ascii')).toBe('HADP')
})
it('queues the hosted TTS-failed prompt when Socket.IO MCU speech synthesis fails', async () => {
const fetchImpl = vi.fn(async (url: string) => {
if (url.includes('/api/hermes/mcu/voice-turn')) {
return new Response(JSON.stringify({ ok: true, transcript: '你好' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
return new Response(JSON.stringify({ error: 'tts down' }), {
status: 502,
headers: { 'content-type': 'application/json' },
})
})
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://device.local:8787/',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
const remoteSocket = connectRemoteSocket()
emitRemote(remoteSocket, 'voice.recorded', {
type: 'voice.recorded',
interactionId: 'voice-tts-fail',
mimeType: 'audio/wav',
profile: 'research',
})
emitRemote(remoteSocket, 'voice.stream.chunk', {
type: 'voice.stream.chunk',
data: Buffer.from('wav-audio').toString('base64'),
})
await vi.waitFor(() => {
expect(socketForUrl('http://127.0.0.1:8648/chat-run')).toBeTruthy()
})
const localSocket = socketForUrl('http://127.0.0.1:8648/chat-run')
localSocket.__handlers.get('connect')?.()
localSocket.__handlers.get('message.delta')?.({ delta: '你好' })
localSocket.__handlers.get('run.completed')?.({})
await vi.waitFor(() => {
expect(remoteSocket.emit).toHaveBeenCalledWith('audio.enqueue', expect.objectContaining({
url: '/api/hermes/mcu/audio/tts-failed-24k.s16le.pcm',
}))
})
const ttsCalls = fetchImpl.mock.calls.filter(([url]: [string]) => url.includes('/api/hermes/tts/synthesize'))
expect(ttsCalls).toHaveLength(2)
expect(ttsCalls[0][1].headers).toMatchObject({
'X-Hermes-Profile': 'research',
})
expect(ttsCalls[1][1].headers).toMatchObject({
'X-Hermes-Profile': 'research',
})
const enqueuePayload = findEmittedPayload(remoteSocket, 'audio.enqueue', payload => payload.url === '/api/hermes/mcu/audio/tts-failed-24k.s16le.pcm')
expect(enqueuePayload).toMatchObject({
type: 'audio.enqueue',
interactionId: 'voice-tts-fail',
segmentId: 'voice-tts-fail-tts-1-failed-prompt',
text: '当前文字转语音失败了,请配置下文字转语音再使用哦',
url: '/api/hermes/mcu/audio/tts-failed-24k.s16le.pcm',
mimeType: 'audio/x-pcm',
format: 's16le',
channels: 1,
sampleRate: 24000,
})
expect(enqueuePayload).not.toHaveProperty('completionManagedByServer')
})
it('aborts in-flight Socket.IO MCU TTS synthesis when playback is interrupted', async () => {
let ttsSignal: AbortSignal | undefined
const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => {
if (url.includes('/api/hermes/mcu/voice-turn')) {
return new Response(JSON.stringify({ ok: true, transcript: '你好' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
ttsSignal = init?.signal || undefined
return await new Promise<Response>((_resolve, reject) => {
ttsSignal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true })
})
})
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'http://device.local:8787/',
relayProtocol: 'mcu-socket.io',
userToken: 'user-jwt',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
const remoteSocket = connectRemoteSocket()
emitRemote(remoteSocket, 'voice.recorded', {
type: 'voice.recorded',
interactionId: 'voice-abort',
mimeType: 'audio/wav',
profile: 'research',
})
emitRemote(remoteSocket, 'voice.stream.chunk', {
type: 'voice.stream.chunk',
data: Buffer.from('wav-audio').toString('base64'),
})
await vi.waitFor(() => {
expect(socketForUrl('http://127.0.0.1:8648/chat-run')).toBeTruthy()
})
const localSocket = socketForUrl('http://127.0.0.1:8648/chat-run')
localSocket.__handlers.get('connect')?.()
localSocket.__handlers.get('message.delta')?.({ delta: '这段正在合成。\n' })
await vi.waitFor(() => {
expect(ttsSignal).toBeDefined()
})
expect(ttsSignal?.aborted).toBe(false)
emitRemote(remoteSocket, 'audio.interrupted', {
type: 'audio.interrupted',
interactionId: 'voice-abort',
segmentId: 'voice-abort-tts-1',
})
await vi.waitFor(() => {
expect(ttsSignal?.aborted).toBe(true)
})
expect(localSocket.emit).toHaveBeenCalledWith('abort', { session_id: 'mcu-device-research' })
expect(remoteSocket.emit).not.toHaveBeenCalledWith('audio.enqueue', expect.any(Object))
})
it('forwards an allowed HTTP request to the local Web UI server', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
status: 202,
headers: {
'content-type': 'application/json',
'x-result': 'accepted',
'transfer-encoding': 'chunked',
},
}))
const { OutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const client = new OutboundRelayClient({
relayUrl: 'https://relay.example.com',
relayToken: '',
instanceId: '',
localBaseUrl: 'http://127.0.0.1:8648/',
fetchImpl: fetchImpl as any,
})
const response = await client.handleHttpRequest({
id: 'req-1',
method: 'POST',
path: '/api/hermes/sessions?profile=default',
headers: {
authorization: 'Bearer user-jwt',
'content-type': 'application/json',
connection: 'keep-alive',
host: 'relay.example.com',
'x-hermes-profile': 'default',
},
body: { message: 'hello' },
})
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:8648/api/hermes/sessions?profile=default', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ message: 'hello' }),
}))
const init = fetchImpl.mock.calls[0][1] as RequestInit
expect(Array.from((init.headers as Headers).entries())).toEqual([
['authorization', 'Bearer user-jwt'],
['content-type', 'application/json'],
['x-hermes-profile', 'default'],
])
expect(response).toEqual({
id: 'req-1',
status: 202,
headers: {
'content-type': 'application/json',
'x-result': 'accepted',
},
body: '{"ok":true}',
truncated: false,
})
})
it('forwards non-v1 local paths through the relay', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ users: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
const { OutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const client = new OutboundRelayClient({
relayUrl: 'https://relay.example.com',
relayToken: '',
instanceId: '',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
const response = await client.handleHttpRequest({
id: 'req-2',
method: 'GET',
path: '/api/auth/users',
})
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:8648/api/auth/users', expect.objectContaining({
method: 'GET',
}))
expect(response).toEqual({
id: 'req-2',
status: 200,
headers: {
'content-type': 'application/json',
},
body: '{"users":[]}',
truncated: false,
})
})
it('rejects /v1 paths without calling local fetch', async () => {
const fetchImpl = vi.fn()
const { OutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const client = new OutboundRelayClient({
relayUrl: 'https://relay.example.com',
relayToken: '',
instanceId: '',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
const response = await client.handleHttpRequest({
id: 'req-3',
method: 'GET',
path: '/v1/runs',
})
expect(fetchImpl).not.toHaveBeenCalled()
expect(response).toEqual({
id: 'req-3',
status: 403,
error: {
code: 'path_not_allowed',
message: 'Relay request path is not allowed',
},
})
})
it('opens a local /chat-run socket and relays chat events both ways', async () => {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const client = startOutboundRelayClient({
relayUrl: 'https://relay.example.com',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
expect(client).not.toBeNull()
const openAck = vi.fn()
socketHandlers.get('socket.open')?.({
id: 'chat-1',
namespace: '/chat-run',
auth: { token: 'user-jwt' },
query: { profile: 'default' },
}, openAck)
const localSocket = sockets[1]
expect(mockIo).toHaveBeenCalledWith('http://127.0.0.1:8648/chat-run', expect.objectContaining({
auth: { token: 'user-jwt' },
query: { profile: 'default' },
transports: ['websocket', 'polling'],
}))
expect(openAck).toHaveBeenCalledWith({ id: 'chat-1', ok: true, namespace: '/chat-run', stream: true })
localSocket.__handlers.get('message.delta')?.({ session_id: 's1', delta: 'hello' })
expect(mockSocket.emit).toHaveBeenCalledWith('socket.event', {
id: 'chat-1',
namespace: '/chat-run',
event: 'message.delta',
payload: { session_id: 's1', delta: 'hello' },
})
const eventAck = vi.fn()
socketHandlers.get('socket.event')?.({
id: 'chat-1',
event: 'run',
payload: { session_id: 's1', input: 'hi' },
}, eventAck)
expect(localSocket.emit).toHaveBeenCalledWith('run', { session_id: 's1', input: 'hi' })
expect(eventAck).toHaveBeenCalledWith({ id: 'chat-1', ok: true, namespace: '/chat-run', event: 'run', stream: true })
})
it('supports non-streaming chat-run mode by suppressing deltas and returning final output', async () => {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'https://relay.example.com',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
const openAck = vi.fn()
socketHandlers.get('socket.open')?.({
id: 'chat-1',
namespace: '/chat-run',
stream: false,
}, openAck)
expect(openAck).toHaveBeenCalledWith({ id: 'chat-1', ok: true, namespace: '/chat-run', stream: false })
const localSocket = sockets[1]
const eventAck = vi.fn()
socketHandlers.get('socket.event')?.({
id: 'chat-1',
event: 'run',
stream: false,
payload: { session_id: 's1', input: 'hi' },
}, eventAck)
expect(eventAck).toHaveBeenCalledWith({ id: 'chat-1', ok: true, namespace: '/chat-run', event: 'run', stream: false })
mockSocket.emit.mockClear()
localSocket.__handlers.get('message.delta')?.({ session_id: 's1', delta: 'Hello ' })
localSocket.__handlers.get('message.delta')?.({ session_id: 's1', delta: 'world' })
localSocket.__handlers.get('reasoning.delta')?.({ session_id: 's1', delta: 'thinking' })
expect(mockSocket.emit).not.toHaveBeenCalled()
localSocket.__handlers.get('run.completed')?.({ session_id: 's1', run_id: 'run-1' })
expect(mockSocket.emit).toHaveBeenCalledWith('socket.event', {
id: 'chat-1',
namespace: '/chat-run',
event: 'run.completed',
payload: {
session_id: 's1',
run_id: 'run-1',
output: 'Hello world',
reasoning: 'thinking',
},
})
})
it('rejects socket namespaces and events outside the chat-run allowlist', async () => {
const { startOutboundRelayClient } = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
startOutboundRelayClient({
relayUrl: 'https://relay.example.com',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
const openAck = vi.fn()
socketHandlers.get('socket.open')?.({ id: 'room-1', namespace: '/group-chat' }, openAck)
expect(openAck).toHaveBeenCalledWith({
id: 'room-1',
ok: false,
error: {
code: 'namespace_not_allowed',
message: 'Relay socket namespace is not allowed',
},
})
const eventAck = vi.fn()
socketHandlers.get('socket.event')?.({ id: 'chat-1', event: 'not.allowed', payload: {} }, eventAck)
expect(eventAck).toHaveBeenCalledWith({
id: 'chat-1',
ok: false,
error: {
code: 'event_not_allowed',
message: 'Relay socket event is not allowed',
},
})
})
it('manages multiple active relay clients by connection id', async () => {
const {
getOutboundRelayClient,
getOutboundRelayClients,
startOutboundRelayClient,
stopOutboundRelayClient,
} = await import('../../packages/server/src/services/global-agent/outbound-relay-client')
const first = startOutboundRelayClient({
connectionId: 'primary',
relayUrl: 'https://relay.example.com',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
const second = startOutboundRelayClient({
connectionId: 'backup',
relayUrl: 'https://other-relay.example.com',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
const duplicate = startOutboundRelayClient({
connectionId: 'primary',
relayUrl: 'https://duplicate.example.com',
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: vi.fn() as any,
})
expect(first).not.toBeNull()
expect(second).not.toBe(first)
expect(duplicate).toBe(first)
expect(getOutboundRelayClient()).toBe(first)
expect(getOutboundRelayClient('primary')).toBe(first)
expect(getOutboundRelayClient('backup')).toBe(second)
expect(getOutboundRelayClients().size).toBe(2)
expect(mockIo).toHaveBeenCalledTimes(2)
stopOutboundRelayClient('primary')
expect(mockSocket.disconnect).toHaveBeenCalledTimes(1)
expect(getOutboundRelayClient('primary')).toBeNull()
expect(getOutboundRelayClient('backup')).toBe(second)
stopOutboundRelayClient()
expect(sockets[1].disconnect).toHaveBeenCalledTimes(1)
expect(getOutboundRelayClients().size).toBe(0)
})
})