Files
hermes-web-ui/tests/server/global-agent-server.test.ts
ekko a7ed55e150 [codex] release 0.6.26 changelog (#1959)
* release 0.6.26 changelog

* stabilize MCU audio enqueue test
2026-07-06 10:43:04 +08:00

1007 lines
37 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const authMocks = vi.hoisted(() => ({
authenticateUserToken: vi.fn(),
userCanAccessProfile: vi.fn(),
}))
const clientSocketMocks = vi.hoisted(() => {
function createLocalSocket(id: string) {
const handlers = new Map<string, (...args: any[]) => void>()
const socket: any = {
id,
__handlers: handlers,
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
return socket
}),
emit: vi.fn(),
removeAllListeners: vi.fn(),
disconnect: vi.fn(),
}
return socket
}
const localSockets: any[] = []
const clientIo = vi.fn(() => {
const socket = createLocalSocket(`local-socket-${localSockets.length + 1}`)
localSockets.push(socket)
return socket
})
const reset = () => {
localSockets.length = 0
clientIo.mockClear()
}
return {
clientIo,
localSockets,
reset,
}
})
const chatRunMocks = vi.hoisted(() => ({
getChatRunServer: vi.fn(),
clearSessionHistory: vi.fn(),
}))
vi.mock('../../packages/server/src/middleware/user-auth', () => ({
authenticateUserToken: authMocks.authenticateUserToken,
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
userCanAccessProfile: authMocks.userCanAccessProfile,
}))
vi.mock('socket.io-client', () => ({
io: clientSocketMocks.clientIo,
}))
vi.mock('../../packages/server/src/routes/hermes/chat-run', () => ({
getChatRunServer: chatRunMocks.getChatRunServer,
}))
function createMockNamespace() {
const middleware: Array<(socket: any, next: (err?: Error) => void) => void> = []
const handlers = new Map<string, (...args: any[]) => void>()
const nsp: any = {
use: vi.fn((fn: (socket: any, next: (err?: Error) => void) => void) => {
middleware.push(fn)
return nsp
}),
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
return nsp
}),
emit: vi.fn(),
__middleware: middleware,
__handlers: handlers,
}
return nsp
}
function createMockSocket(id: string, auth: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
const handlers = new Map<string, (...args: any[]) => void>()
const socket: any = {
id,
data: {},
handshake: { auth, headers },
broadcast: { emit: vi.fn() },
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
handlers.set(event, handler)
return socket
}),
emit: vi.fn((_event: string, payload: unknown, ack?: (response: unknown) => void) => {
ack?.({ id: (payload as any)?.id, status: 200, body: '{"ok":true}' })
return socket
}),
disconnect: vi.fn(),
__handlers: handlers,
}
return socket
}
async function waitForMockCalls(mock: { mock: { calls: unknown[] } }, count: number): Promise<void> {
const startedAt = Date.now()
while (mock.mock.calls.length < count && Date.now() - startedAt < 1000) {
await new Promise(resolve => setTimeout(resolve, 5))
}
}
async function waitForMockCallWith(
mock: { mock: { calls: unknown[][] } },
predicate: (call: unknown[]) => boolean,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < 1000) {
if (mock.mock.calls.some(call => predicate(call))) return
await new Promise(resolve => setTimeout(resolve, 5))
}
throw new Error('Timed out waiting for matching mock call')
}
describe('GlobalAgentServer', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
clientSocketMocks.reset()
chatRunMocks.getChatRunServer.mockReturnValue({
clearSessionHistory: chatRunMocks.clearSessionHistory,
})
chatRunMocks.clearSessionHistory.mockReturnValue({ deleted: 2, hadMemoryState: true })
authMocks.authenticateUserToken.mockResolvedValue(null)
authMocks.userCanAccessProfile.mockReturnValue(false)
})
it('registers a local control namespace with token auth', async () => {
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
expect(io.of).toHaveBeenCalledWith('/global-agent')
expect(nsp.use).toHaveBeenCalledTimes(1)
expect(nsp.on).toHaveBeenCalledWith('connection', expect.any(Function))
const denied = createMockSocket('socket-denied', { token: 'wrong' })
const deniedNext = vi.fn()
await nsp.__middleware[0](denied, deniedNext)
expect(deniedNext.mock.calls[0][0]).toBeInstanceOf(Error)
const deniedAgent = createMockSocket('socket-denied-agent', { token: 'wrong', role: 'hermes-studio' })
const deniedAgentNext = vi.fn()
await nsp.__middleware[0](deniedAgent, deniedAgentNext)
expect(deniedAgentNext.mock.calls[0][0]).toBeInstanceOf(Error)
const allowed = createMockSocket('socket-allowed', { token: server.getAuthToken() })
const allowedNext = vi.fn()
await nsp.__middleware[0](allowed, allowedNext)
expect(allowedNext).toHaveBeenCalledWith()
})
it('tracks connected clients and forwards requests with ack', async () => {
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const socket = createMockSocket('socket-1', { token: server.getAuthToken(), instanceId: 'local-global-agent' })
nsp.__handlers.get('connection')?.(socket)
expect(server.getClientIds()).toEqual(['local-global-agent'])
const response = await server.httpRequest({
id: 'req-1',
method: 'GET',
path: '/api/auth/users',
}, { clientId: 'local-global-agent' })
expect(socket.emit).toHaveBeenCalledWith(
'http.request',
{ id: 'req-1', method: 'GET', path: '/api/auth/users' },
expect.any(Function),
)
expect(response).toEqual({ id: 'req-1', status: 200, body: '{"ok":true}' })
})
it('accepts frontend JWT clients and injects their token and profile into forwarded requests', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('agent-socket', { token: server.getAuthToken(), instanceId: 'local-global-agent' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
const frontendSocket = createMockSocket('frontend-socket', { token: 'frontend-jwt', profile: 'research' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](frontendSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(frontendSocket)
const httpAck = vi.fn()
frontendSocket.__handlers.get('http.request')?.({
id: 'req-frontend',
method: 'GET',
path: '/api/auth/users',
clientId: 'local-global-agent',
}, httpAck)
await new Promise(resolve => setTimeout(resolve, 0))
expect(agentSocket.emit).toHaveBeenCalledWith(
'http.request',
{
id: 'req-frontend',
method: 'GET',
path: '/api/auth/users',
clientId: 'local-global-agent',
headers: {
authorization: 'Bearer frontend-jwt',
'x-hermes-profile': 'research',
},
},
expect.any(Function),
)
expect(httpAck).toHaveBeenCalledWith({ id: 'req-frontend', status: 200, body: '{"ok":true}' })
const openAck = vi.fn()
frontendSocket.__handlers.get('socket.open')?.({
id: 'chat-1',
namespace: '/chat-run',
clientId: 'local-global-agent',
}, openAck)
await new Promise(resolve => setTimeout(resolve, 0))
expect(agentSocket.emit).toHaveBeenCalledWith(
'socket.open',
{
id: 'chat-1',
namespace: '/chat-run',
clientId: 'local-global-agent',
auth: { token: 'frontend-jwt' },
query: { profile: 'research' },
},
expect.any(Function),
)
})
it('accepts JWT agent clients and handles inbound HTTP and chat-run socket relay requests locally', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
status: 202,
headers: { 'content-type': 'application/json', 'x-result': 'accepted' },
}))
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
localBaseUrl: 'http://127.0.0.1:8648',
fetchImpl: fetchImpl as any,
})
server.init()
const agentSocket = createMockSocket('jwt-agent-socket', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
expect(server.getClientIds()).toEqual(['device-1'])
const httpAck = vi.fn()
agentSocket.__handlers.get('http.request')?.({
id: 'req-1',
method: 'POST',
path: '/api/hermes/sessions',
headers: {
authorization: 'Bearer attacker',
'content-type': 'application/json',
},
body: { title: 'hello' },
}, httpAck)
await new Promise(resolve => setTimeout(resolve, 0))
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:8648/api/hermes/sessions', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: 'hello' }),
}))
expect(Array.from((fetchImpl.mock.calls[0][1].headers as Headers).entries())).toEqual([
['authorization', 'Bearer user-jwt'],
['content-type', 'application/json'],
['x-hermes-profile', 'research'],
])
expect(httpAck).toHaveBeenCalledWith(expect.objectContaining({
id: 'req-1',
status: 202,
body: '{"ok":true}',
}))
const openAck = vi.fn()
agentSocket.__handlers.get('socket.open')?.({
id: 'chat-1',
namespace: '/chat-run',
}, openAck)
expect(clientSocketMocks.clientIo).toHaveBeenCalledWith('http://127.0.0.1:8648/chat-run', expect.objectContaining({
auth: { token: 'user-jwt' },
query: { profile: 'research' },
transports: ['websocket', 'polling'],
}))
expect(openAck).toHaveBeenCalledWith({ id: 'chat-1', ok: true, namespace: '/chat-run', stream: true })
const localSocket = clientSocketMocks.localSockets[0]
const eventAck = vi.fn()
agentSocket.__handlers.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',
profile: 'research',
})
expect(eventAck).toHaveBeenCalledWith({ id: 'chat-1', ok: true, namespace: '/chat-run', event: 'run', stream: true })
localSocket.__handlers.get('message.delta')?.({ session_id: 's1', delta: 'hello' })
expect(agentSocket.emit).toHaveBeenCalledWith('socket.event', {
id: 'chat-1',
namespace: '/chat-run',
event: 'message.delta',
payload: { session_id: 's1', delta: 'hello' },
})
})
it('replaces the previous agent socket for the same instance id', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, { localBaseUrl: 'http://127.0.0.1:8648' })
server.init()
const first = createMockSocket('agent-old', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](first, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(first)
first.__handlers.get('socket.open')?.({ id: 'chat-1', namespace: '/chat-run' }, vi.fn())
expect(clientSocketMocks.localSockets).toHaveLength(1)
const second = createMockSocket('agent-new', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](second, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(second)
expect(first.disconnect).toHaveBeenCalledWith(true)
expect(clientSocketMocks.localSockets[0].disconnect).toHaveBeenCalled()
expect(server.getClientIds()).toEqual(['device-1'])
})
it('pushes MCU events only to the selected agent socket and forwards MCU status events to frontends', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('jwt-agent-socket', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
const otherAgentSocket = createMockSocket('jwt-agent-socket-2', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-2',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](otherAgentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(otherAgentSocket)
expect(server.emitMcuEvent({
type: 'audio.enqueue',
interactionId: 'voice-1',
segmentId: 'voice-1-tts-1',
url: 'http://127.0.0.1/audio.pcm',
}, { clientId: 'device-1' })).toBe(true)
expect(agentSocket.emit).toHaveBeenCalledWith('audio.enqueue', {
type: 'audio.enqueue',
interactionId: 'voice-1',
segmentId: 'voice-1-tts-1',
url: 'http://127.0.0.1/audio.pcm',
})
expect(otherAgentSocket.emit).not.toHaveBeenCalledWith('audio.enqueue', expect.anything())
expect(agentSocket.broadcast.emit).not.toHaveBeenCalled()
agentSocket.__handlers.get('audio.queued')?.({
interactionId: 'voice-1',
segmentId: 'voice-1-tts-1',
})
expect(agentSocket.broadcast.emit).not.toHaveBeenCalled()
expect(otherAgentSocket.emit).not.toHaveBeenCalledWith('relay.socket.event', expect.anything())
})
it('keeps MCU voice stream in listening state until the upload is ended', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('jwt-agent-socket', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
agentSocket.__handlers.get('voice.stream.start')?.({
interactionId: 'voice-1',
sampleRate: 16000,
channels: 1,
bitsPerSample: 16,
})
expect(agentSocket.emit).toHaveBeenCalledWith('interaction.status', {
type: 'interaction.status',
interactionId: 'voice-1',
status: 'listening',
})
expect(agentSocket.emit).not.toHaveBeenCalledWith('interaction.status', expect.objectContaining({
interactionId: 'voice-1',
status: 'transcribing',
}))
})
it('returns relative MCU audio URLs for device-side playback', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const fetchImpl = vi.fn(async () => new Response(Buffer.from('pcm-audio'), {
status: 200,
headers: { 'Content-Type': 'audio/x-pcm' },
}))
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
fetchImpl: fetchImpl as any,
localBaseUrl: 'http://127.0.0.1:8647',
})
server.init()
const audio = await (server as any).synthesizeMcuSpeech('hello', 'user-jwt', 'research')
expect(audio.url).toMatch(/^\/api\/hermes\/mcu\/audio\/[a-f0-9-]+\.pcm$/)
expect(fetchImpl.mock.calls[0][1]?.headers).toMatchObject({
'X-Hermes-Profile': 'research',
})
})
it('marks TTS requests as MCU playback without forcing every provider to PCM', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const fetchImpl = vi.fn(async () => new Response(Buffer.from('pcm-audio'), {
status: 200,
headers: { 'Content-Type': 'audio/x-pcm' },
}))
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
fetchImpl: fetchImpl as any,
localBaseUrl: 'http://127.0.0.1:8647',
})
server.init()
await (server as any).synthesizeMcuSpeech('hello', 'user-jwt', 'research')
expect(fetchImpl.mock.calls[0][1]?.headers).toMatchObject({
'X-Hermes-Profile': 'research',
})
expect(JSON.parse(String(fetchImpl.mock.calls[0][1]?.body))).toMatchObject({
text: 'hello',
options: {
mcuPlayback: true,
sampleRate: 24000,
},
})
})
it('falls back to Edge TTS when active MCU TTS output cannot be converted to PCM', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body || '{}'))
if (body.provider === 'edge') {
return new Response(Buffer.from('edge-pcm'), {
status: 200,
headers: { 'Content-Type': 'audio/x-pcm' },
})
}
return new Response(Buffer.from('not-real-mp3'), {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' },
})
})
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
fetchImpl: fetchImpl as any,
localBaseUrl: 'http://127.0.0.1:8647',
})
server.init()
const audio = await (server as any).synthesizeMcuSpeech('hello', 'user-jwt', 'research')
expect(audio.url).toMatch(/^\/api\/hermes\/mcu\/audio\/[a-f0-9-]+\.pcm$/)
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(fetchImpl.mock.calls[1][1]?.headers).toMatchObject({
'X-Hermes-Profile': 'research',
})
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toMatchObject({
provider: 'edge',
text: 'hello',
options: {
mcuPlayback: true,
sampleRate: 24000,
},
})
})
it('falls back to Edge TTS when the active MCU TTS provider fails', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body || '{}'))
if (body.provider === 'edge') {
return new Response(Buffer.from('edge-pcm'), {
status: 200,
headers: { 'Content-Type': 'audio/x-pcm' },
})
}
return new Response(JSON.stringify({ error: 'provider down' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
})
})
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
fetchImpl: fetchImpl as any,
localBaseUrl: 'http://127.0.0.1:8647',
})
server.init()
const audio = await (server as any).synthesizeMcuSpeech('hello', 'user-jwt', 'research')
expect(audio.url).toMatch(/^\/api\/hermes\/mcu\/audio\/[a-f0-9-]+\.pcm$/)
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(fetchImpl.mock.calls[1][1]?.headers).toMatchObject({
'X-Hermes-Profile': 'research',
})
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toMatchObject({
provider: 'edge',
text: 'hello',
options: {
mcuPlayback: true,
sampleRate: 24000,
},
})
})
it('synthesizes MCU speech at completed assistant-message boundaries', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const fetchImpl = vi.fn(async () => new Response(Buffer.from('pcm-audio'), {
status: 200,
headers: { 'Content-Type': 'audio/x-pcm' },
}))
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
fetchImpl: fetchImpl as any,
localBaseUrl: 'http://127.0.0.1:8647',
})
server.init()
const agentSocket = createMockSocket('jwt-agent-socket', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
server.startMcuVoiceChatTurn({
userToken: 'user-jwt',
profile: 'research',
interactionId: 'voice-1',
transcript: 'hi',
clientId: 'device-1',
})
const localSocket = clientSocketMocks.localSockets.at(-1)
localSocket.__handlers.get('connect')?.()
localSocket.__handlers.get('message.delta')?.({ delta: '好嘞,这就去查。' })
expect(fetchImpl).not.toHaveBeenCalled()
localSocket.__handlers.get('tool.started')?.({ tool: 'weather', preview: '厦门天气' })
await waitForMockCalls(fetchImpl, 1)
expect(JSON.parse(String(fetchImpl.mock.calls[0][1]?.body))).toMatchObject({
text: '好嘞,这就去查。',
})
await waitForMockCallWith(agentSocket.emit, ([event, payload]) =>
event === 'audio.enqueue' && (payload as { segmentId?: string })?.segmentId === 'voice-1-tts-1',
)
expect(agentSocket.emit).toHaveBeenCalledWith('audio.enqueue', expect.objectContaining({
interactionId: 'voice-1',
segmentId: 'voice-1-tts-1',
url: expect.stringMatching(/^\/api\/hermes\/mcu\/audio\/[a-f0-9-]+\.pcm$/),
completionManagedByServer: true,
}))
agentSocket.__handlers.get('audio.done')?.({
interactionId: 'voice-1',
segmentId: 'voice-1-tts-1',
})
localSocket.__handlers.get('tool.completed')?.({ tool: 'weather' })
localSocket.__handlers.get('message.delta')?.({ delta: '结果如下:\n| 名称 | 值 |\n' })
localSocket.__handlers.get('message.delta')?.({ delta: '| --- | --- |\n| foo | 1 |\n请确认。' })
localSocket.__handlers.get('run.completed')?.({})
await waitForMockCalls(fetchImpl, 2)
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toMatchObject({
text: '结果如下: 请确认。',
})
await waitForMockCallWith(agentSocket.emit, ([event, payload]) =>
event === 'audio.enqueue' && (payload as { segmentId?: string })?.segmentId === 'voice-1-tts-2',
)
expect(agentSocket.emit).toHaveBeenCalledWith('audio.enqueue', expect.objectContaining({
interactionId: 'voice-1',
segmentId: 'voice-1-tts-2',
url: expect.stringMatching(/^\/api\/hermes\/mcu\/audio\/[a-f0-9-]+\.pcm$/),
completionManagedByServer: true,
}))
})
it('aborts in-flight MCU TTS synthesis when the device interrupts playback', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
let ttsSignal: AbortSignal | undefined
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
ttsSignal = init?.signal || undefined
return await new Promise<Response>((_resolve, reject) => {
ttsSignal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true })
})
})
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, {
fetchImpl: fetchImpl as any,
localBaseUrl: 'http://127.0.0.1:8647',
})
server.init()
const agentSocket = createMockSocket('jwt-agent-socket', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
server.startMcuVoiceChatTurn({
userToken: 'user-jwt',
profile: 'research',
interactionId: 'voice-abort',
transcript: 'hi',
clientId: 'device-1',
})
const localSocket = clientSocketMocks.localSockets.at(-1)
localSocket.__handlers.get('connect')?.()
localSocket.__handlers.get('message.delta')?.({ delta: '这段正在合成。' })
await waitForMockCalls(fetchImpl, 1)
expect(ttsSignal?.aborted).toBe(false)
agentSocket.__handlers.get('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-1-research' })
expect(agentSocket.emit).not.toHaveBeenCalledWith('audio.enqueue', expect.anything())
})
it('auto-approves MCU chat-run approval requests using the same choice order as the client relay', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any, { localBaseUrl: 'http://127.0.0.1:8647' })
server.init()
const agentSocket = createMockSocket('jwt-agent-socket', {
token: 'user-jwt',
role: 'hermes-studio',
instanceId: 'device-1',
profile: 'research',
})
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
server.startMcuVoiceChatTurn({
userToken: 'user-jwt',
profile: 'research',
interactionId: 'voice-1',
transcript: '查天气',
clientId: 'device-1',
})
const localSocket = clientSocketMocks.localSockets.at(-1)
localSocket.__handlers.get('connect')?.()
localSocket.__handlers.get('approval.requested')?.({
approval_id: 'approval-1',
choices: ['once', 'session', 'deny'],
})
expect(agentSocket.emit).toHaveBeenCalledWith('tool.started', {
type: 'tool.started',
interactionId: 'voice-1',
tool: 'approval',
preview: 'session',
})
expect(localSocket.emit).toHaveBeenCalledWith('approval.respond', {
session_id: 'mcu-device-1-research',
approval_id: 'approval-1',
choice: 'session',
})
localSocket.__handlers.get('approval.resolved')?.({ resolved: true })
expect(agentSocket.emit).toHaveBeenCalledWith('tool.completed', {
type: 'tool.completed',
interactionId: 'voice-1',
tool: 'approval',
error: undefined,
})
localSocket.__handlers.get('tool.failed')?.({ tool: 'weather', error: 'permission denied' })
expect(agentSocket.emit).toHaveBeenCalledWith('tool.completed', {
type: 'tool.completed',
interactionId: 'voice-1',
tool: 'weather',
preview: undefined,
error: 'permission denied',
})
localSocket.__handlers.get('run.failed')?.({ error: 'done' })
})
it('does not forward reserved Socket.IO lifecycle events to frontend clients', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('agent-socket', { token: server.getAuthToken(), instanceId: 'local-global-agent' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
const frontendSocket = createMockSocket('frontend-socket', { token: 'frontend-jwt', profile: 'research' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](frontendSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(frontendSocket)
const bridgeId = 'frontend:frontend-socket:chat-run'
const openAck = vi.fn()
frontendSocket.__handlers.get('socket.open')?.({
id: bridgeId,
namespace: '/chat-run',
clientId: 'local-global-agent',
}, openAck)
await new Promise(resolve => setTimeout(resolve, 0))
frontendSocket.emit.mockClear()
agentSocket.__handlers.get('socket.event')?.({
id: bridgeId,
namespace: '/chat-run',
event: 'connect',
payload: { socketId: 'local-chat-run' },
})
expect(frontendSocket.emit).not.toHaveBeenCalledWith('connect', expect.anything())
agentSocket.__handlers.get('socket.event')?.({
id: bridgeId,
namespace: '/chat-run',
event: 'message.delta',
payload: { session_id: 's1', delta: 'hi' },
})
expect(frontendSocket.emit).toHaveBeenCalledWith('message.delta', { session_id: 's1', delta: 'hi' })
})
it('broadcasts MCU session clears as chat session commands for frontend clients', async () => {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('agent-socket', { token: server.getAuthToken(), instanceId: 'device-1' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
const frontendSocket = createMockSocket('frontend-socket', { token: 'frontend-jwt', profile: 'research' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](frontendSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(frontendSocket)
agentSocket.__handlers.get('mcu.session.clear')?.({
interactionId: 'clear-1',
profile: 'research',
})
expect(chatRunMocks.clearSessionHistory).toHaveBeenCalledWith('mcu-device-1-research')
expect(agentSocket.emit).toHaveBeenCalledWith('mcu.session.cleared', expect.objectContaining({
type: 'mcu.session.cleared',
interactionId: 'clear-1',
profile: 'research',
sessionId: 'mcu-device-1-research',
deleted: 2,
memoryCleared: true,
}))
expect(frontendSocket.emit).toHaveBeenCalledWith('session.command', {
event: 'session.command',
session_id: 'mcu-device-1-research',
command: 'clear',
action: 'clear',
clearHistory: true,
ok: true,
deleted: 2,
memoryCleared: true,
})
})
it('cancels a pending MCU interrupt when session clear arrives in the double-click window', async () => {
vi.useFakeTimers()
try {
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('agent-socket', { token: server.getAuthToken(), instanceId: 'device-1' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
const runSocket = { emit: vi.fn() }
;(server as any).mcuSessionRuns.set('mcu-device-1-research', {
interactionId: 'run-1',
socket: runSocket,
})
agentSocket.__handlers.get('mcu.interrupt')?.({
interactionId: 'run-1',
profile: 'research',
})
expect(runSocket.emit).not.toHaveBeenCalled()
agentSocket.__handlers.get('mcu.session.clear')?.({
interactionId: 'clear-1',
profile: 'research',
})
await vi.advanceTimersByTimeAsync(300)
expect(runSocket.emit).not.toHaveBeenCalled()
expect(chatRunMocks.clearSessionHistory).toHaveBeenCalledWith('mcu-device-1-research')
expect(agentSocket.emit).toHaveBeenCalledWith('mcu.session.cleared', expect.objectContaining({
type: 'mcu.session.cleared',
interactionId: 'clear-1',
profile: 'research',
sessionId: 'mcu-device-1-research',
}))
} finally {
vi.useRealTimers()
}
})
it('does not silently clear only the database when chat-run memory is unavailable', async () => {
chatRunMocks.getChatRunServer.mockReturnValue(null)
authMocks.authenticateUserToken.mockResolvedValue({ id: 7, username: 'ada', role: 'user' })
authMocks.userCanAccessProfile.mockReturnValue(true)
const nsp = createMockNamespace()
const io = { of: vi.fn(() => nsp) }
const { GlobalAgentServer } = await import('../../packages/server/src/services/global-agent/server')
const server = new GlobalAgentServer(io as any)
server.init()
const agentSocket = createMockSocket('agent-socket', { token: server.getAuthToken(), instanceId: 'device-1' })
await new Promise<void>((resolve, reject) => {
nsp.__middleware[0](agentSocket, (err?: Error) => err ? reject(err) : resolve())
})
nsp.__handlers.get('connection')?.(agentSocket)
agentSocket.__handlers.get('mcu.session.clear')?.({
interactionId: 'clear-1',
profile: 'research',
})
expect(chatRunMocks.clearSessionHistory).not.toHaveBeenCalled()
expect(agentSocket.emit).toHaveBeenCalledWith('mcu.session.cleared', expect.objectContaining({
type: 'mcu.session.cleared',
sessionId: 'mcu-device-1-research',
ok: false,
error: 'chat_run_server_unavailable',
}))
})
})