mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 13:40:14 +00:00
a575266a84
* require MCU API token for global agent events * fix MCU relay auth tests
955 lines
33 KiB
TypeScript
955 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',
|
|
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',
|
|
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: '这段正在合成。' })
|
|
|
|
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)
|
|
})
|
|
})
|